Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ee/media/sharp-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,19 @@ export function createSharpMediaProvider(config: SharpMediaProviderConfig): Medi
return rowToAsset(row, usage)
},

async getAssetByPath(projectId: string, originalPath: string): Promise<MediaAsset | null> {
const row = await db.findMediaAssetByPath(projectId, originalPath)
if (!row) return null
const usageRows = await db.getMediaUsage(row.id as string)
const usage: MediaUsageRef[] = usageRows.map(u => ({
modelId: u.model_id as string,
entryId: u.entry_id as string,
fieldId: u.field_id as string,
locale: u.locale as string,
}))
return rowToAsset(row, usage)
},

async listAssets(projectId: string, options?: MediaListOptions) {
const result = await db.listMediaAssets(projectId, options)
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@ export default defineEventHandler(async (event) => {
// tracking still resolves the asset after media is normalized to URLs.
const mediaPath = extractMediaStoragePath(value)
if (mediaPath) {
// Find asset by filename
const { assets } = await mediaProvider.listAssets(projectId, { search: mediaPath.split('/').pop(), limit: 1 })
if (assets.length > 0) {
// Resolve by storage path. The old filename `search` never matched:
// the path's uuid names the FILE, while `filename`/`alt` (all
// `search` covers) hold the original upload name — so UI-path
// usage tracking silently recorded nothing.
const asset = await mediaProvider.getAssetByPath?.(projectId, mediaPath)
if (asset) {
await db.trackMediaUsage({
asset_id: assets[0]!.id,
asset_id: asset.id,
project_id: projectId,
model_id: modelId,
entry_id: entryId,
Expand Down
2 changes: 1 addition & 1 deletion server/providers/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface AIMessage {
export type AIContentBlock
= | { type: 'text', text: string }
| { type: 'tool_use', id: string, name: string, input: unknown }
| { type: 'tool_result', toolUseId: string, content: string }
| { type: 'tool_result', toolUseId: string, content: string, isError?: boolean }
| { type: 'image', source: AIImageSource }
| { type: 'document', source: AIDocumentSource }

Expand Down
2 changes: 1 addition & 1 deletion server/providers/anthropic-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export function toAnthropicMessages(messages: AICompletionRequest['messages']):
case 'tool_use':
return { type: 'tool_use' as const, id: block.id, name: block.name, input: block.input as Record<string, unknown> }
case 'tool_result':
return { type: 'tool_result' as const, tool_use_id: block.toolUseId, content: block.content }
return { type: 'tool_result' as const, tool_use_id: block.toolUseId, content: block.content, ...(block.isError ? { is_error: true } : {}) }
case 'image':
return block.source.type === 'url'
? { type: 'image' as const, source: { type: 'url' as const, url: block.source.url } }
Expand Down
6 changes: 6 additions & 0 deletions server/providers/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,12 @@ export interface DatabaseProvider {

createMediaAsset: (asset: MediaAssetInput) => Promise<DatabaseRow>
getMediaAsset: (assetId: string) => Promise<DatabaseRow | null>
/**
* Look an asset up by its storage path (`media/original/<file>`). The path's
* uuid is the FILE name, not the row id, so a path in hand (delivery URL,
* content field value) cannot be resolved through `getMediaAsset`.
*/
findMediaAssetByPath: (projectId: string, originalPath: string) => Promise<DatabaseRow | null>
listMediaAssets: (projectId: string, options?: PaginationOptions & {
search?: string
tags?: string[]
Expand Down
9 changes: 9 additions & 0 deletions server/providers/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ export interface MediaProvider {
/** Get asset metadata from DB. */
getAsset: (assetId: string) => Promise<MediaAsset | null>

/**
* Resolve an asset from its storage path (`media/original/<file>`) —
* the uuid in the path names the FILE, not the DB row, so a delivery
* URL or content-field value cannot be resolved via `getAsset`.
* Optional so existing provider doubles keep compiling; callers must
* degrade when absent.
*/
getAssetByPath?: (projectId: string, originalPath: string) => Promise<MediaAsset | null>

/** List assets for a project with filtering and pagination. */
listAssets: (projectId: string, options?: MediaListOptions) => Promise<{ assets: MediaAsset[], total: number }>

Expand Down
17 changes: 17 additions & 0 deletions server/providers/postgres-db/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type MediaMethods = Pick<
DatabaseProvider,
| 'createMediaAsset'
| 'getMediaAsset'
| 'findMediaAssetByPath'
| 'listMediaAssets'
| 'updateMediaAsset'
| 'deleteMediaAsset'
Expand Down Expand Up @@ -64,6 +65,22 @@ export function mediaMethods(): MediaMethods {
}
},

async findMediaAssetByPath(projectId, originalPath) {
try {
const row = await getAdmin()
.selectFrom('media_assets')
.selectAll()
.where('project_id', '=', projectId)
.where('original_path', '=', originalPath)
.executeTakeFirst()

return (row as DatabaseRow | undefined) ?? null
}
catch {
return null
}
},

async listMediaAssets(projectId, options) {
const page = options?.page ?? 1
const limit = options?.limit ?? 50
Expand Down
12 changes: 12 additions & 0 deletions server/providers/supabase-db/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type MediaMethods = Pick<
DatabaseProvider,
| 'createMediaAsset'
| 'getMediaAsset'
| 'findMediaAssetByPath'
| 'listMediaAssets'
| 'updateMediaAsset'
| 'deleteMediaAsset'
Expand Down Expand Up @@ -41,6 +42,17 @@ export function mediaMethods(): MediaMethods {
return (data as DatabaseRow) ?? null
},

async findMediaAssetByPath(projectId, originalPath) {
const { data } = await getAdmin()
.from('media_assets')
.select('*')
.eq('project_id', projectId)
.eq('original_path', originalPath)
.maybeSingle()

return (data as DatabaseRow) ?? null
},

async listMediaAssets(projectId, options) {
const page = options?.page ?? 1
const limit = options?.limit ?? 50
Expand Down
8 changes: 8 additions & 0 deletions server/utils/agent-system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,14 @@ function formatFieldDef(def: FieldDef, depth: number = 0): string {
return `${parts.join(' ')}\n${nested}`
}

// At the depth cap, still NAME the subfields. Dropping them entirely made
// the schema summary claim e.g. `cards: array (items: object)` with no
// hint an `image` existed — and the agent answered "this content cannot
// be managed here" without ever querying it.
if (def.fields) {
parts.push(`{${Object.keys(def.fields).join(', ')}}`)
}

return parts.join(' ')
}

Expand Down
2 changes: 1 addition & 1 deletion server/utils/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ Provide initial models with full field definitions using Contentrain's 27 type s
},
{
name: 'brain_search',
description: 'Full-text search across all project content. Returns matching entries with model ID, entry ID, and preview text.',
description: 'Full-text search across all project content. Multi-word queries match per word across all of an entry\'s fields (best matches first) — you do not need the exact stored phrasing. Returns matching entries with model ID, entry ID, and preview text.',
inputSchema: {
type: 'object',
properties: {
Expand Down
59 changes: 59 additions & 0 deletions server/utils/brain-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Token-scored matching for the agent's `brain_search` tool.
*
* The previous implementation substring-matched the query against
* `JSON.stringify(entry)`, so a query was only found when it appeared as
* one contiguous run inside the serialized JSON. Real queries concatenate
* across fields ("Review" title + "Approve branches…" description) and
* never match that way — in the 2026-08-13 staging sessions 5 of 7
* searches returned zero results and pushed the agent into full-model
* dumps and a wrong-entry pick.
*
* Matching here is per-token over the entry's string VALUES only (no JSON
* syntax, no keys): every query token must appear somewhere, but not
* contiguously and not in field order. Pure functions — no Nuxt context —
* so the scoring is unit-testable in isolation.
*/

/** Recursively collect string values (nested objects/arrays included). */
export function collectSearchableText(value: unknown, depth = 0): string {
if (typeof value === 'string') return value
if (typeof value !== 'object' || value === null || depth > 6) return ''

const parts: string[] = []
const values = Array.isArray(value) ? value : Object.values(value as Record<string, unknown>)
for (const v of values) {
if (typeof v === 'string') {
if (v.length > 0) parts.push(v)
}
else if (typeof v === 'object' && v !== null) {
const nested = collectSearchableText(v, depth + 1)
if (nested) parts.push(nested)
}
}
return parts.join(' ')
}

/** Lowercased, deduplicated word tokens; 1-char tokens carry no signal. */
export function tokenizeQuery(query: string): string[] {
const tokens = query.toLowerCase().match(/[\p{L}\p{N}]{2,}/gu) ?? []
return [...new Set(tokens)]
}

/**
* Fraction of query tokens present in the entry text (substring match, so
* "review" also hits "reviewer" — mirroring the forward tokenization the
* content panel's client-side search uses).
*/
export function scoreEntryText(text: string, tokens: string[]): number {
if (tokens.length === 0) return 0
const haystack = text.toLowerCase()
let matched = 0
for (const token of tokens) {
if (haystack.includes(token)) matched++
}
return matched / tokens.length
}

/** Entries scoring below this are noise, not partial matches. */
export const BRAIN_SEARCH_MIN_SCORE = 0.5
28 changes: 28 additions & 0 deletions server/utils/content-engine/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,34 @@ export function pinReaderToContentrain(git: GitProvider): RepoReader {
}
}

/**
* Whether every planned file is byte-identical to what `contentrain`
* already holds — i.e. the save is a no-op. Reliable because the plan
* output is deterministic (`canonicalStringify` for JSON, and Studio's
* meta override writes no timestamps), so identical input produces
* identical bytes. Deletions (`content: null`) and brand-new files never
* count as no-ops.
*
* The two extra reads per save are far cheaper than what a no-op used to
* cost: a branch, an empty commit, a merge to `contentrain`, and a
* `contentrain`→main advance (~18s wall-clock on staging).
*/
export async function planMatchesCurrent(reader: RepoReader, changes: FileChange[]): Promise<boolean> {
if (changes.length === 0) return false
for (const change of changes) {
if (typeof change.content !== 'string') return false
let current: string
try {
current = await reader.readFile(change.path)
}
catch {
return false
}
if (current !== change.content) return false
}
return true
}

/**
* Override the meta FileChange produced by `planContentSave` with
* Studio's status semantics:
Expand Down
14 changes: 14 additions & 0 deletions server/utils/content-engine/save-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createFeatureBranch,
shapeEntriesForSave,
toObjectMap,
planMatchesCurrent,
} from './helpers'
import { normalizeModelContentMedia } from '../media-rewrite'
import { saveDocument } from './save-document'
Expand Down Expand Up @@ -222,6 +223,19 @@ export async function saveContent(
const allChanges: FileChange[] = [...patchedChanges]
.toSorted((a, b) => a.path.localeCompare(b.path))

// Byte-identical plan → the requested state is already live on
// `contentrain`. Skip the branch/commit/merge cycle entirely (a no-op
// used to create an empty commit and a full ~18s merge round).
if (await planMatchesCurrent(reader, allChanges)) {
return {
branch: '',
commit: { sha: '', message: '', author: STUDIO_AUTHOR, timestamp: '' },
diff: [],
validation,
unchanged: true,
}
}

const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale)

const commit = await ctx.git.applyPlan({
Expand Down
14 changes: 13 additions & 1 deletion server/utils/content-engine/save-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH, parseMarkdownFrontmatter,
import { planContentSave } from '@contentrain/mcp/core/ops'
import type { EngineInternalContext, WriteResult } from './types'
import { STUDIO_AUTHOR, CONTENT_BRANCH } from './types'
import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch } from './helpers'
import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch, planMatchesCurrent } from './helpers'
import { rewriteEntryMedia, rewriteMarkdownMedia } from '../media-rewrite'

/**
Expand Down Expand Up @@ -147,6 +147,18 @@ export async function saveDocument(
const allChanges: FileChange[] = [...patchedChanges]
.toSorted((a, b) => a.path.localeCompare(b.path))

// Byte-identical plan → no-op; skip the branch/commit/merge cycle
// (same short-circuit as saveContent).
if (await planMatchesCurrent(reader, allChanges)) {
return {
branch: '',
commit: { sha: '', message: '', author: STUDIO_AUTHOR, timestamp: '' },
diff: [],
validation,
unchanged: true,
}
}

const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale)

const commit = await ctx.git.applyPlan({
Expand Down
6 changes: 6 additions & 0 deletions server/utils/content-engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export interface WriteResult {
commit: Commit
diff: FileDiff[]
validation: ValidationResult
/**
* The planned files were byte-identical to `contentrain` — no branch,
* commit, or merge was performed. Not a failure: the requested state was
* already live.
*/
unchanged?: boolean
}

export interface ContentEngineContext {
Expand Down
Loading
Loading