mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Validate wiki refs before writes
This commit is contained in:
parent
7ae692cce0
commit
97a6b91f95
5 changed files with 149 additions and 8 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue