Validate wiki refs before writes

This commit is contained in:
Luca Martial 2026-05-11 21:54:25 -07:00
parent 7ae692cce0
commit 97a6b91f95
5 changed files with 149 additions and 8 deletions

View file

@ -42,7 +42,7 @@ If nothing is worth capturing, respond without calling any tool.
2. **Before writing**, search for related content so cross-references are accurate:
- `wiki_search` with the topic — find related wiki pages to populate `refs`.
- `sl_discover` with the concept — if the page defines a metric (revenue, churn, retention, LTV, ARR, MRR, CAC, attribution, etc.), find matching SL sources or measures to populate `sl_refs`. If no matches, pass `sl_refs: []` so future readers know you checked.
3. If updating an existing page, `wiki_read` it first. The read result begins with `[scope: ... | tags: ... | refs: ... | sl_refs: ...]` showing current frontmatter.
3. If updating an existing page, `wiki_read` it first. Use the returned `structured.content` or markdown body as the exact stored text for targeted replacements; current tags, refs, and sl_refs are returned in structured metadata.
4. `wiki_write` to create or update. Prefer merging into an existing page over creating a new one.
5. `wiki_remove` only when a page is truly obsolete — not to replace stale content (update it instead).

View file

@ -37,4 +37,24 @@ describe('WikiReadTool', () => {
expect(result.structured).toMatchObject({ found: true, blockKey: 'staged-page', scope: 'GLOBAL' });
expect(result.markdown).toContain('A page written earlier in the same ingest worktree.');
});
it('does not append derived refs to the editable markdown body', async () => {
const rootWikiService = {
readPageForUser: vi.fn().mockResolvedValue({
pageKey: 'orbit-how-we-work',
scope: 'GLOBAL',
frontmatter: { summary: 'How we work', tags: ['policy'], refs: ['orbit-company-overview'] },
content: '## How We Work\n\nUse written-first operating norms.',
}),
};
const pagesRepository = { findPageByKey: vi.fn().mockResolvedValue(null), incrementUsageCount: vi.fn() };
const tool = new WikiReadTool(rootWikiService as any, pagesRepository as any);
const result = await tool.call({ key: 'orbit-how-we-work' }, baseContext);
expect(result.markdown).toBe('## How We Work\n\nUse written-first operating norms.');
expect(result.markdown).not.toContain('See also');
expect(result.markdown).not.toContain('[[orbit-company-overview]]');
expect(result.structured.refs).toEqual(['orbit-company-overview']);
});
});

View file

@ -34,6 +34,7 @@ export class WikiReadTool extends BaseTool<typeof WikiReadInputSchema> {
return (
'Load the full content of a knowledge block by its key. ' +
'Use this to retrieve detailed rules, preferences, or definitions listed in the <knowledge_index>. ' +
'The markdown output is the exact stored page body; use it verbatim for wiki_write replacements. ' +
'Call this when the user query relates to a topic covered by an available knowledge block.'
);
}
@ -62,14 +63,8 @@ export class WikiReadTool extends BaseTool<typeof WikiReadInputSchema> {
void this.pagesRepository.incrementUsageCount([indexEntry.id]);
}
let md = `## ${page.pageKey}\n\n${page.content}`;
const refs = page.frontmatter.refs;
if (refs && refs.length > 0) {
md += `\n\nSee also: ${refs.map((r) => `[[${r}]]`).join(', ')}`;
}
return {
markdown: md,
markdown: page.content,
structured: {
blockKey: page.pageKey,
content: page.content,

View file

@ -6,6 +6,7 @@ import { WikiWriteTool } from './wiki-write.tool.js';
function makeTool(overrides: any = {}) {
const wikiService = {
readPage: vi.fn().mockResolvedValue(null),
listPageKeys: vi.fn().mockResolvedValue([]),
writePage: vi.fn().mockResolvedValue(undefined),
syncSinglePage: vi.fn().mockResolvedValue(undefined),
...overrides.wikiService,
@ -245,4 +246,47 @@ describe('WikiWriteTool', () => {
summary: 'Monthly paid orders updated',
});
});
it('rejects frontmatter refs that target missing wiki pages', async () => {
const { tool, wikiService } = makeTool({
wikiService: {
listPageKeys: vi.fn().mockResolvedValue(['orbit-company-overview']),
},
});
const result = await tool.call(
{
key: 'orbit-how-we-work',
summary: 'Operating norms',
content: '## How We Work',
refs: ['orbit-company-overview', 'orbit-team-lanes-detail'],
} as any,
baseContext,
);
expect(result.structured.success).toBe(false);
expect(result.markdown).toMatch(/orbit-team-lanes-detail/);
expect(wikiService.writePage).not.toHaveBeenCalled();
});
it('rejects inline wiki links that target missing wiki pages', async () => {
const { tool, wikiService } = makeTool({
wikiService: {
listPageKeys: vi.fn().mockResolvedValue(['orbit-company-overview']),
},
});
const result = await tool.call(
{
key: 'orbit-how-we-work',
summary: 'Operating norms',
content: 'See [[orbit-company-overview]] and [[orbit-team-lanes-detail]].',
} as any,
baseContext,
);
expect(result.structured.success).toBe(false);
expect(result.markdown).toMatch(/orbit-team-lanes-detail/);
expect(wikiService.writePage).not.toHaveBeenCalled();
});
});

View file

@ -64,6 +64,71 @@ function normalizeAccidentalEscapedMarkdownNewlines(content: string): string {
return content.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\n');
}
function isWikiPageKeyRef(ref: string): boolean {
return /^[a-z0-9][a-z0-9_-]*(?:-[a-z0-9_]+)*$/.test(ref);
}
function extractInlineWikiRefs(content: string): string[] {
const refs = new Set<string>();
const re = /\[\[([^\]\n]+)\]\]/g;
for (const match of content.matchAll(re)) {
const target = match[1]?.split('|', 1)[0]?.trim();
if (target && isWikiPageKeyRef(target)) {
refs.add(target);
}
}
return [...refs].sort();
}
async function visibleWikiPageKeys(
wikiService: KnowledgeWikiService,
scope: BlockScope,
scopeId: string | null,
): Promise<Set<string>> {
const keys = new Set<string>();
if (scope === 'USER') {
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
keys.add(key);
}
for (const key of await wikiService.listPageKeys('USER', scopeId)) {
keys.add(key);
}
return keys;
}
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
keys.add(key);
}
return keys;
}
async function findMissingWikiRefs(input: {
wikiService: KnowledgeWikiService;
scope: BlockScope;
scopeId: string | null;
pageKey: string;
refs?: string[];
content: string;
}): Promise<string[]> {
const candidates = new Set<string>();
for (const ref of input.refs ?? []) {
if (isWikiPageKeyRef(ref)) {
candidates.add(ref);
}
}
for (const ref of extractInlineWikiRefs(input.content)) {
candidates.add(ref);
}
if (candidates.size === 0) {
return [];
}
const available = await visibleWikiPageKeys(input.wikiService, input.scope, input.scopeId);
available.add(input.pageKey);
return [...candidates].filter((ref) => !available.has(ref)).sort();
}
export class WikiWriteTool extends BaseTool<typeof wikiWriteInputSchema> {
readonly name = 'wiki_write';
@ -160,6 +225,23 @@ tags/refs/sl_refs use REPLACE semantics: omit to keep existing on update, [] to
finalContent = existing?.content ?? '';
}
const missingRefs = await findMissingWikiRefs({
wikiService,
scope,
scopeId,
pageKey: input.key,
refs: finalFm.refs,
content: finalContent,
});
if (missingRefs.length > 0) {
return {
markdown:
`Error: wiki references target missing page(s): ${missingRefs.join(', ')}. ` +
'Create those pages first, retarget the links, or remove the refs.',
structured: { success: false, key: input.key },
};
}
await wikiService.writePage(scope, scopeId, input.key, finalFm, finalContent, SYSTEM_AUTHOR, SYSTEM_EMAIL);
if (!skipIndex) {
await wikiService.syncSinglePage(scope, scopeId, input.key, finalFm, finalContent);