@aprimediet/codewalker 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -3
- package/package.json +1 -1
- package/prompts/codewalker.md +3 -1
- package/skills/codewalker/SKILL.md +118 -28
- package/src/cards.test.ts +123 -1
- package/src/cards.ts +53 -0
- package/src/db.test.ts +405 -3
- package/src/db.ts +402 -29
- package/src/enrich.test.ts +102 -0
- package/src/enrich.ts +107 -0
- package/src/format.test.ts +103 -0
- package/src/format.ts +11 -0
- package/src/index.contract.test.ts +77 -1
- package/src/index.ts +273 -19
- package/src/indexer.heal.test.ts +90 -0
- package/src/indexer.ts +9 -1
- package/src/libs/cards.test.ts +86 -0
- package/src/libs/cards.ts +53 -0
- package/src/libs/dts.test.ts +269 -0
- package/src/libs/dts.ts +213 -0
- package/src/libs/indexer.test.ts +236 -0
- package/src/libs/indexer.ts +291 -0
- package/src/libs/resolve.test.ts +218 -0
- package/src/libs/resolve.ts +120 -0
- package/src/notes-cards.test.ts +99 -0
- package/src/notes-cards.ts +92 -0
- package/src/notes.test.ts +172 -0
- package/src/notes.ts +145 -0
- package/src/project.test.ts +12 -1
- package/src/project.ts +7 -1
- package/src/query.test.ts +148 -1
- package/src/query.ts +28 -8
- package/src/types.ts +44 -0
package/src/format.test.ts
CHANGED
|
@@ -2,6 +2,24 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { formatCompact, formatCardBody } from './format.ts';
|
|
3
3
|
import type { QueryResultRow, StalenessInfo } from './types.ts';
|
|
4
4
|
|
|
5
|
+
function makeLibRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
|
|
6
|
+
return {
|
|
7
|
+
id: 100,
|
|
8
|
+
name: 'createMiddleware',
|
|
9
|
+
kind: 'function',
|
|
10
|
+
file_path: 'hono/dist/helper.d.ts',
|
|
11
|
+
line_start: 0,
|
|
12
|
+
line_end: 0,
|
|
13
|
+
signature: 'export declare function createMiddleware<E>(...): MiddlewareHandler',
|
|
14
|
+
summary: 'Define a typed middleware handler.',
|
|
15
|
+
score: 0.3,
|
|
16
|
+
source: 'lib',
|
|
17
|
+
lib: 'hono',
|
|
18
|
+
version: '4.6.3',
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
function makeRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
|
|
6
24
|
return {
|
|
7
25
|
id: 1,
|
|
@@ -63,6 +81,91 @@ describe('formatCompact', () => {
|
|
|
63
81
|
});
|
|
64
82
|
});
|
|
65
83
|
|
|
84
|
+
describe('formatCompact with lib rows', () => {
|
|
85
|
+
it('renders a lib row with [lib@version] origin tag', () => {
|
|
86
|
+
const rows = [makeLibRow()];
|
|
87
|
+
const result = formatCompact(rows, null);
|
|
88
|
+
expect(result).toContain('createMiddleware');
|
|
89
|
+
expect(result).toContain('function');
|
|
90
|
+
expect(result).toContain('[hono@4.6.3]');
|
|
91
|
+
expect(result).toContain('Define a typed middleware handler.');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders mixed code and lib rows', () => {
|
|
95
|
+
const libRow = makeLibRow();
|
|
96
|
+
const codeRow: QueryResultRow = {
|
|
97
|
+
id: 1, name: 'myFunc', kind: 'function',
|
|
98
|
+
file_path: 'src/util/helper.ts', line_start: 10, line_end: 20,
|
|
99
|
+
signature: '(x: number) => string', summary: 'Does something', score: 0.5,
|
|
100
|
+
};
|
|
101
|
+
const result = formatCompact([codeRow, libRow], null);
|
|
102
|
+
expect(result).toContain('helper.ts:10-20');
|
|
103
|
+
expect(result).toContain('[hono@4.6.3]');
|
|
104
|
+
// Two lines
|
|
105
|
+
expect(result.split('\n')).toHaveLength(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('bounded output for lib rows (one line per hit)', () => {
|
|
109
|
+
const rows = Array.from({ length: 5 }, (_, i) =>
|
|
110
|
+
makeLibRow({ name: `fn${i}`, lib: 'test-pkg', version: '1.0.0' })
|
|
111
|
+
);
|
|
112
|
+
const result = formatCompact(rows, null);
|
|
113
|
+
expect(result.split('\n')).toHaveLength(5);
|
|
114
|
+
expect(result).toContain('[test-pkg@1.0.0]');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('formatCompact with note rows', () => {
|
|
119
|
+
it('renders a glossary note row with [glossary] prefix', () => {
|
|
120
|
+
const rows: QueryResultRow[] = [{
|
|
121
|
+
id: 1, name: 'Idempotency Key', kind: 'glossary',
|
|
122
|
+
file_path: '', line_start: 0, line_end: 0,
|
|
123
|
+
signature: '', summary: 'A client-supplied key that makes retries safe.',
|
|
124
|
+
score: 0.5, source: 'note', note_kind: 'glossary', tags: 'api',
|
|
125
|
+
}];
|
|
126
|
+
const result = formatCompact(rows, null);
|
|
127
|
+
expect(result).toContain('Idempotency Key');
|
|
128
|
+
expect(result).toContain('glossary');
|
|
129
|
+
expect(result).toContain('[glossary]');
|
|
130
|
+
expect(result).toContain('A client-supplied key that makes retries safe.');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('renders a decision note row with [decision] prefix', () => {
|
|
134
|
+
const rows: QueryResultRow[] = [{
|
|
135
|
+
id: 1, name: 'Use SQLite', kind: 'decision',
|
|
136
|
+
file_path: '', line_start: 0, line_end: 0,
|
|
137
|
+
signature: '', summary: 'Chosen for zero infra.',
|
|
138
|
+
score: 0.5, source: 'note', note_kind: 'decision', tags: '',
|
|
139
|
+
}];
|
|
140
|
+
const result = formatCompact(rows, null);
|
|
141
|
+
expect(result).toContain('Use SQLite');
|
|
142
|
+
expect(result).toContain('decision');
|
|
143
|
+
expect(result).toContain('[decision]');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('renders mixed code + lib + note rows all on separate lines', () => {
|
|
147
|
+
const codeRow: QueryResultRow = {
|
|
148
|
+
id: 1, name: 'myFunc', kind: 'function',
|
|
149
|
+
file_path: 'src/util/helper.ts', line_start: 10, line_end: 20,
|
|
150
|
+
signature: '', summary: 'Does something', score: 0.5,
|
|
151
|
+
};
|
|
152
|
+
const libRow = makeLibRow();
|
|
153
|
+
const noteRow: QueryResultRow = {
|
|
154
|
+
id: 1, name: 'Retry Key', kind: 'glossary',
|
|
155
|
+
file_path: '', line_start: 0, line_end: 0,
|
|
156
|
+
signature: '', summary: 'Key for idempotent retries.',
|
|
157
|
+
score: 0.5, source: 'note', note_kind: 'glossary', tags: '',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const result = formatCompact([codeRow, libRow, noteRow], null);
|
|
161
|
+
expect(result).toContain('helper.ts:10-20');
|
|
162
|
+
expect(result).toContain('[hono@4.6.3]');
|
|
163
|
+
expect(result).toContain('[glossary]');
|
|
164
|
+
expect(result).toContain('Retry Key');
|
|
165
|
+
expect(result.split('\n')).toHaveLength(3);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
66
169
|
describe('formatCardBody', () => {
|
|
67
170
|
it('returns the card body text', () => {
|
|
68
171
|
const body = '# myFunc\n\nDoes something.\n';
|
package/src/format.ts
CHANGED
|
@@ -27,6 +27,17 @@ export function formatCompact(
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const lines = rows.map((row) => {
|
|
30
|
+
if (row.source === "note" && row.note_kind) {
|
|
31
|
+
const prefix = `[${row.note_kind}]`;
|
|
32
|
+
const summary = truncate(row.summary || "", SUMMARY_MAX);
|
|
33
|
+
return `${row.name} · ${row.note_kind} · ${prefix} · ${summary}`;
|
|
34
|
+
}
|
|
35
|
+
if (row.source === "lib" && row.lib && row.version) {
|
|
36
|
+
const origin = `[${row.lib}@${row.version}]`;
|
|
37
|
+
const summary = truncate(row.summary || "", SUMMARY_MAX);
|
|
38
|
+
const loc = row.file_path ? `${basename(row.file_path)}:${row.line_start}-${row.line_end}` : `lib`;
|
|
39
|
+
return `${row.name} · ${row.kind} · ${origin} · ${loc} · ${summary}`;
|
|
40
|
+
}
|
|
30
41
|
const loc = `${basename(row.file_path)}:${row.line_start}-${row.line_end}`;
|
|
31
42
|
const summary = truncate(row.summary || "", SUMMARY_MAX);
|
|
32
43
|
return `${row.name} · ${row.kind} · ${loc} · ${summary}`;
|
|
@@ -58,10 +58,35 @@ describe('index.ts contract', () => {
|
|
|
58
58
|
expect(queryTool).toBeDefined();
|
|
59
59
|
expect(queryTool!.description).toContain('code index');
|
|
60
60
|
|
|
61
|
+
// Check new v1.3 tools
|
|
62
|
+
const enrichTool = stub.tools.find(t => t.name === 'codewalker_enrich');
|
|
63
|
+
expect(enrichTool).toBeDefined();
|
|
64
|
+
expect(enrichTool!.description).toContain('summary');
|
|
65
|
+
|
|
66
|
+
const noteTool = stub.tools.find(t => t.name === 'codewalker_note');
|
|
67
|
+
expect(noteTool).toBeDefined();
|
|
68
|
+
expect(noteTool!.description).toContain('glossary');
|
|
69
|
+
|
|
70
|
+
// Check enrich tool parameters
|
|
71
|
+
const enrichParams = (enrichTool!.parameters as any);
|
|
72
|
+
expect(enrichParams.properties).toHaveProperty('card');
|
|
73
|
+
expect(enrichParams.properties).toHaveProperty('summary');
|
|
74
|
+
|
|
75
|
+
// Check note tool parameters
|
|
76
|
+
const noteParams = (noteTool!.parameters as any);
|
|
77
|
+
expect(noteParams.properties).toHaveProperty('type');
|
|
78
|
+
expect(noteParams.properties).toHaveProperty('title');
|
|
79
|
+
expect(noteParams.properties).toHaveProperty('body');
|
|
80
|
+
|
|
81
|
+
// Check tool has a source parameter
|
|
82
|
+
const toolParams = (queryTool!.parameters as any);
|
|
83
|
+
expect(toolParams.properties).toHaveProperty('source');
|
|
84
|
+
|
|
61
85
|
// Check command registered
|
|
62
86
|
const cmd = stub.commands.find(c => c.name === 'codewalker');
|
|
63
87
|
expect(cmd).toBeDefined();
|
|
64
|
-
expect(cmd!.description).
|
|
88
|
+
expect(cmd!.description).toContain('libs');
|
|
89
|
+
expect(cmd!.description).toContain('lib');
|
|
65
90
|
});
|
|
66
91
|
|
|
67
92
|
it('tool.execute returns { content, details } with compact text content', async () => {
|
|
@@ -97,4 +122,55 @@ describe('index.ts contract', () => {
|
|
|
97
122
|
|
|
98
123
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
99
124
|
});
|
|
125
|
+
|
|
126
|
+
it('tool.execute with source="libs" returns valid result even with no lib data', async () => {
|
|
127
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-contract-libs-'));
|
|
128
|
+
const piDir = path.join(tmpDir, '.pi');
|
|
129
|
+
fs.mkdirSync(piDir, { recursive: true });
|
|
130
|
+
const markerId = 'test-project-libs-' + Math.random().toString(36).slice(2, 8);
|
|
131
|
+
fs.writeFileSync(path.join(piDir, markerId + '.md'), `---\npi-project: true\nid: ${markerId}\n---\n`);
|
|
132
|
+
|
|
133
|
+
const homePi = path.join(os.homedir(), '.pi', 'projects', markerId, 'codewalker');
|
|
134
|
+
fs.mkdirSync(homePi, { recursive: true });
|
|
135
|
+
|
|
136
|
+
const mod = await import('./index.ts');
|
|
137
|
+
const stub = createPiStub();
|
|
138
|
+
mod.default(stub.api as any);
|
|
139
|
+
|
|
140
|
+
const tool = stub.tools.find(t => t.name === 'codewalker_query')!;
|
|
141
|
+
const result = await tool.execute(
|
|
142
|
+
'test-id',
|
|
143
|
+
{ query: 'test', source: 'libs' },
|
|
144
|
+
new AbortController().signal,
|
|
145
|
+
() => {},
|
|
146
|
+
{ cwd: tmpDir },
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(result).toHaveProperty('content');
|
|
150
|
+
expect(result.content[0]!.text).toContain('No matches');
|
|
151
|
+
expect(result.details.rows).toEqual([]);
|
|
152
|
+
|
|
153
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('command description mentions libs and lib subcommands', async () => {
|
|
157
|
+
const mod = await import('./index.ts');
|
|
158
|
+
const stub = createPiStub();
|
|
159
|
+
mod.default(stub.api as any);
|
|
160
|
+
|
|
161
|
+
const cmd = stub.commands.find(c => c.name === 'codewalker')!;
|
|
162
|
+
expect(cmd.description).toContain('libs [--dev]');
|
|
163
|
+
expect(cmd.description).toContain('lib <pkg>');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('command description includes enrich, glossary, decisions subcommands', async () => {
|
|
167
|
+
const mod = await import('./index.ts');
|
|
168
|
+
const stub = createPiStub();
|
|
169
|
+
mod.default(stub.api as any);
|
|
170
|
+
|
|
171
|
+
const cmd = stub.commands.find(c => c.name === 'codewalker')!;
|
|
172
|
+
expect(cmd.description).toContain('enrich');
|
|
173
|
+
expect(cmd.description).toContain('glossary');
|
|
174
|
+
expect(cmd.description).toContain('decisions');
|
|
175
|
+
});
|
|
100
176
|
});
|
package/src/index.ts
CHANGED
|
@@ -4,38 +4,52 @@
|
|
|
4
4
|
* Queryable, token-economical project & code index for the pi coding agent.
|
|
5
5
|
*
|
|
6
6
|
* Registers:
|
|
7
|
-
* - `codewalker_query` tool (agent-facing, compact results)
|
|
8
|
-
* - `/codewalker` command (human-facing) with subcommands scan, sync, query
|
|
7
|
+
* - `codewalker_query` tool (agent-facing, compact results) — now with `source` param
|
|
8
|
+
* - `/codewalker` command (human-facing) with subcommands scan, sync, query, libs, lib
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
11
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { Type } from "typebox";
|
|
13
15
|
import { resolveProject, ensureProject } from "./project.ts";
|
|
14
16
|
import { runQuery } from "./query.ts";
|
|
15
17
|
import { scan, sync } from "./indexer.ts";
|
|
16
|
-
import {
|
|
18
|
+
import { indexLibraries } from "./libs/indexer.ts";
|
|
19
|
+
import { formatCompact } from "./format.ts";
|
|
20
|
+
import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote } from "./db.ts";
|
|
21
|
+
import { updateCardSummary } from "./cards.ts";
|
|
22
|
+
import { addNote } from "./notes.ts";
|
|
23
|
+
import { formatEnrichWorklist, validateEnrichPath, checkEnrichCap } from "./enrich.ts";
|
|
24
|
+
import type { NoteKind } from "./types.ts";
|
|
17
25
|
|
|
18
26
|
export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
19
|
-
// -----------------------------------------------------------------
|
|
27
|
+
// ----------------------------------------------------------------- tools
|
|
28
|
+
|
|
29
|
+
// -- codewalker_query (v1.1 — extended with source='notes'|'all')
|
|
20
30
|
pi.registerTool({
|
|
21
31
|
name: "codewalker_query",
|
|
22
32
|
label: "Codewalker Query",
|
|
23
33
|
description:
|
|
24
34
|
"Search the project's code index for symbols (functions, consts, classes, types). " +
|
|
25
|
-
"Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files."
|
|
35
|
+
"Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files. " +
|
|
36
|
+
"Optionally search libraries (source='libs') or notes/glossary/decisions (source='notes') or both (source='all').",
|
|
26
37
|
parameters: Type.Object({
|
|
27
38
|
query: Type.String({ description: "Search text — symbol name or concept keywords." }),
|
|
28
|
-
kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum" })),
|
|
39
|
+
kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum|glossary|decision" })),
|
|
29
40
|
limit: Type.Optional(Type.Number({ description: "Max hits (default 10)." })),
|
|
41
|
+
source: Type.Optional(Type.String({ description: "Where to search: code | libs | notes | all (default code)." })),
|
|
30
42
|
}),
|
|
31
43
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
44
|
+
const p = params as any;
|
|
32
45
|
const project = resolveProject(ctx.cwd);
|
|
33
46
|
const { rows, staleness } = runQuery(
|
|
34
47
|
project.dbPath,
|
|
35
48
|
{
|
|
36
|
-
query:
|
|
37
|
-
kind:
|
|
38
|
-
limit:
|
|
49
|
+
query: p.query as string,
|
|
50
|
+
kind: p.kind as string | undefined,
|
|
51
|
+
limit: p.limit as number | undefined,
|
|
52
|
+
source: (p.source as "code" | "libs" | "notes" | "all") ?? "code",
|
|
39
53
|
},
|
|
40
54
|
project.root,
|
|
41
55
|
);
|
|
@@ -48,14 +62,128 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
48
62
|
},
|
|
49
63
|
});
|
|
50
64
|
|
|
65
|
+
// -- codewalker_enrich (v1.3 — write a semantic summary back to a symbol)
|
|
66
|
+
pi.registerTool({
|
|
67
|
+
name: "codewalker_enrich",
|
|
68
|
+
label: "Codewalker Enrich",
|
|
69
|
+
description:
|
|
70
|
+
"Write a one-line semantic summary back to a symbol's card and DB index. " +
|
|
71
|
+
"Call this AFTER reading the symbol's source span. The summary (≤120 chars) " +
|
|
72
|
+
"is cached so future queries surface meaning, not just names.",
|
|
73
|
+
parameters: Type.Object({
|
|
74
|
+
card: Type.String({ description: "card_path of the symbol (from the enrich worklist)." }),
|
|
75
|
+
summary: Type.String({ description: "One-line (≤120 char) plain-English summary of what it does." }),
|
|
76
|
+
}),
|
|
77
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
78
|
+
const p = params as any;
|
|
79
|
+
const card = p.card as string;
|
|
80
|
+
const summary = p.summary as string;
|
|
81
|
+
const project = resolveProject(ctx.cwd);
|
|
82
|
+
|
|
83
|
+
// Resolve the card path (may be absolute or relative to codewalker dir)
|
|
84
|
+
let cardPath = card;
|
|
85
|
+
if (!path.isAbsolute(cardPath)) {
|
|
86
|
+
cardPath = path.resolve(project.codewalkerDir, card);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check the card file exists
|
|
90
|
+
if (!fs.existsSync(cardPath)) {
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: "text" as const, text: `No card file found at ${cardPath}.` }],
|
|
93
|
+
details: { error: "card_not_found" },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read and update the card
|
|
98
|
+
const cardContent = fs.readFileSync(cardPath, "utf-8");
|
|
99
|
+
const updated = updateCardSummary(cardContent, summary);
|
|
100
|
+
|
|
101
|
+
// Atomic write back to the card file
|
|
102
|
+
const tmpPath = cardPath + ".tmp";
|
|
103
|
+
fs.writeFileSync(tmpPath, updated, { encoding: "utf-8", mode: 0o600 });
|
|
104
|
+
fs.renameSync(tmpPath, cardPath);
|
|
105
|
+
|
|
106
|
+
// Update DB
|
|
107
|
+
const db = openDb(project.dbPath);
|
|
108
|
+
let updatedRow = false;
|
|
109
|
+
try {
|
|
110
|
+
updatedRow = updateSymbolSummary(db, cardPath, summary);
|
|
111
|
+
} finally {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!updatedRow) {
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text" as const, text: `Card ${path.basename(cardPath)} updated but no matching symbol row found in DB. Run /codewalker scan first.` }],
|
|
118
|
+
details: { card_updated: true, db_updated: false },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text" as const, text: `Summary written to ${path.basename(cardPath)} and indexed.` }],
|
|
124
|
+
details: { card_updated: true, db_updated: true, card_path: cardPath, summary },
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// -- codewalker_note (v1.3 — write a glossary term or decision)
|
|
130
|
+
pi.registerTool({
|
|
131
|
+
name: "codewalker_note",
|
|
132
|
+
label: "Codewalker Note",
|
|
133
|
+
description:
|
|
134
|
+
"Write a glossary term or decision note. Persists to a markdown card " +
|
|
135
|
+
"under entries/{glossary,decisions}/ and the FTS index. Future queries " +
|
|
136
|
+
"will surface this conceptual knowledge alongside code symbols.",
|
|
137
|
+
parameters: Type.Object({
|
|
138
|
+
type: Type.String({ description: "glossary | decision" }),
|
|
139
|
+
title: Type.String({ description: "Glossary term, or decision title." }),
|
|
140
|
+
body: Type.String({ description: "The definition, or the decision + rationale." }),
|
|
141
|
+
tags: Type.Optional(Type.String({ description: "Comma-separated tags." })),
|
|
142
|
+
related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
|
|
143
|
+
}),
|
|
144
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
145
|
+
const p = params as any;
|
|
146
|
+
const type = (p.type as string).toLowerCase();
|
|
147
|
+
|
|
148
|
+
if (type !== "glossary" && type !== "decision") {
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary" or "decision".` }],
|
|
151
|
+
details: { error: "invalid_type" },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const project = await ensureProject(ctx.cwd);
|
|
156
|
+
const notesDir = type === "glossary" ? project.glossaryDir : project.decisionsDir;
|
|
157
|
+
|
|
158
|
+
addNote(project.dbPath, {
|
|
159
|
+
note_kind: type as NoteKind,
|
|
160
|
+
title: (p.title as string).trim(),
|
|
161
|
+
body: (p.body as string).trim(),
|
|
162
|
+
tags: (p.tags as string ?? "").trim(),
|
|
163
|
+
related: (p.related as string ?? "").trim(),
|
|
164
|
+
card_path: "",
|
|
165
|
+
}, notesDir);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text" as const, text: `${type === "glossary" ? "Glossary term" : "Decision"} "${p.title}" saved and indexed.` }],
|
|
169
|
+
details: { type, title: p.title },
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
51
174
|
// ----------------------------------------------------------------- command
|
|
52
175
|
pi.registerCommand("codewalker", {
|
|
53
176
|
description:
|
|
54
|
-
"codewalker: scan | sync | query <text> | help\n" +
|
|
55
|
-
" scan
|
|
56
|
-
" sync
|
|
57
|
-
" query <text>
|
|
58
|
-
"
|
|
177
|
+
"codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
|
|
178
|
+
" scan Full (re)build of the code index\n" +
|
|
179
|
+
" sync Git-anchored incremental update\n" +
|
|
180
|
+
" query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
|
|
181
|
+
" enrich <path> Select unenriched symbols under <path> and write summaries\n" +
|
|
182
|
+
" glossary [query] Search glossary terms\n" +
|
|
183
|
+
" decisions [query] Search decision notes\n" +
|
|
184
|
+
" libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
|
|
185
|
+
" lib <pkg> [query] Search a specific library's API symbols\n" +
|
|
186
|
+
" help Show this help",
|
|
59
187
|
handler: async (args, ctx) => {
|
|
60
188
|
const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
61
189
|
const sub = tokens[0] ?? "help";
|
|
@@ -106,13 +234,139 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
|
|
|
106
234
|
break;
|
|
107
235
|
}
|
|
108
236
|
|
|
237
|
+
// ── v1.3: enrich ───────────────────────────────────
|
|
238
|
+
case "enrich": {
|
|
239
|
+
const enrichPath = tokens[1];
|
|
240
|
+
const pathCheck = validateEnrichPath(enrichPath);
|
|
241
|
+
if (!pathCheck.valid) {
|
|
242
|
+
notify(pathCheck.error!, "error");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Parse optional --max=N
|
|
247
|
+
const maxToken = tokens.find(t => t.startsWith("--max="));
|
|
248
|
+
const cap = maxToken ? parseInt(maxToken.slice(6), 10) : 40;
|
|
249
|
+
|
|
250
|
+
// Select unenriched symbols
|
|
251
|
+
const db = openDb(project.dbPath);
|
|
252
|
+
let symbols;
|
|
253
|
+
try {
|
|
254
|
+
symbols = selectUnenrichedSymbols(db, enrichPath!, cap + 1); // get one extra to detect overflow
|
|
255
|
+
} finally {
|
|
256
|
+
db.close();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Cap check
|
|
260
|
+
const capCheck = checkEnrichCap(symbols.length, cap);
|
|
261
|
+
if (!capCheck.ok) {
|
|
262
|
+
notify(capCheck.error!, "error");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (symbols.length === 0) {
|
|
267
|
+
notify(`No unenriched symbols found under "${enrichPath}".`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Format the worklist
|
|
272
|
+
const worklist = formatEnrichWorklist(symbols, enrichPath!);
|
|
273
|
+
|
|
274
|
+
// With UI, drive the agent; without UI, print the worklist
|
|
275
|
+
if ((ctx as any).hasUI) {
|
|
276
|
+
notify(worklist);
|
|
277
|
+
try {
|
|
278
|
+
(ctx as any).sendUserMessage?.(worklist);
|
|
279
|
+
} catch {
|
|
280
|
+
// sendUserMessage may not be available in all contexts
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
console.log(worklist);
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── v1.3: glossary ─────────────────────────────────
|
|
289
|
+
case "glossary": {
|
|
290
|
+
const q = tokens.slice(1).join(" ");
|
|
291
|
+
const db = openDb(project.dbPath);
|
|
292
|
+
let rows;
|
|
293
|
+
try {
|
|
294
|
+
rows = searchNotes(db, q || "", "glossary", 20);
|
|
295
|
+
} finally {
|
|
296
|
+
db.close();
|
|
297
|
+
}
|
|
298
|
+
const text = formatCompact(rows as any, null);
|
|
299
|
+
notify(text || "No glossary terms found.");
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── v1.3: decisions ────────────────────────────────
|
|
304
|
+
case "decisions": {
|
|
305
|
+
const q = tokens.slice(1).join(" ");
|
|
306
|
+
const db = openDb(project.dbPath);
|
|
307
|
+
let rows;
|
|
308
|
+
try {
|
|
309
|
+
rows = searchNotes(db, q || "", "decision", 20);
|
|
310
|
+
} finally {
|
|
311
|
+
db.close();
|
|
312
|
+
}
|
|
313
|
+
const text = formatCompact(rows as any, null);
|
|
314
|
+
notify(text || "No decision notes found.");
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case "libs": {
|
|
319
|
+
const includeDev = tokens.includes("--dev");
|
|
320
|
+
notify(`Indexing libraries${includeDev ? " (including devDependencies)" : ""}…`);
|
|
321
|
+
const result = await indexLibraries({
|
|
322
|
+
projectRoot: project.root,
|
|
323
|
+
libsDir: project.libsDir,
|
|
324
|
+
dbPath: project.dbPath,
|
|
325
|
+
includeDev,
|
|
326
|
+
});
|
|
327
|
+
if (result.indexed === 0 && result.symbols === 0) {
|
|
328
|
+
notify("No libraries indexed. Ensure node_modules exists and has dependencies installed.");
|
|
329
|
+
} else {
|
|
330
|
+
notify(`Indexed ${result.indexed} libraries, ${result.symbols} symbols${result.errors > 0 ? ` (${result.errors} errors)` : ""}.`);
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case "lib": {
|
|
336
|
+
const pkg = tokens[1];
|
|
337
|
+
if (!pkg) {
|
|
338
|
+
notify("Usage: /codewalker lib <pkg> [query]", "error");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const q = tokens.slice(2).join(" ");
|
|
342
|
+
const { rows, staleness } = runQuery(
|
|
343
|
+
project.dbPath,
|
|
344
|
+
{ query: q || "", source: "libs", limit: 20 },
|
|
345
|
+
project.root,
|
|
346
|
+
);
|
|
347
|
+
// Filter to the requested package
|
|
348
|
+
const pkgRows = rows.filter(r => r.lib === pkg || r.name === pkg);
|
|
349
|
+
if (pkgRows.length === 0) {
|
|
350
|
+
notify(`No API symbols found for "${pkg}". Run /codewalker libs first to index libraries.`);
|
|
351
|
+
} else {
|
|
352
|
+
const text = formatCompact(pkgRows, staleness);
|
|
353
|
+
notify(text);
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
109
358
|
default: {
|
|
110
359
|
notify(
|
|
111
|
-
"codewalker: scan | sync | query <text> | help\n" +
|
|
112
|
-
" scan
|
|
113
|
-
" sync
|
|
114
|
-
" query <text>
|
|
115
|
-
"
|
|
360
|
+
"codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
|
|
361
|
+
" scan Full (re)build of the code index\n" +
|
|
362
|
+
" sync Git-anchored incremental update\n" +
|
|
363
|
+
" query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
|
|
364
|
+
" enrich <path> Select unenriched symbols under <path> and write summaries\n" +
|
|
365
|
+
" glossary [query] Search glossary terms\n" +
|
|
366
|
+
" decisions [query] Search decision notes\n" +
|
|
367
|
+
" libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
|
|
368
|
+
" lib <pkg> [query] Search a specific library's API symbols\n" +
|
|
369
|
+
" help Show this help",
|
|
116
370
|
);
|
|
117
371
|
}
|
|
118
372
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for the "database disk image is malformed" crash on `/codewalker scan`.
|
|
3
|
+
*
|
|
4
|
+
* A DB written by an older (pre-trigger, manual-FTS-sync) build can have a `symbols_fts`
|
|
5
|
+
* external-content index that is silently out of sync with the `symbols` table. v1.3's bootstrap
|
|
6
|
+
* adds the FTS-sync triggers, but those don't reconcile the already-stale index — so the per-row
|
|
7
|
+
* DELETEs in scan() fire `symbols_ad` 'delete' commands against mismatched `old.*` values and
|
|
8
|
+
* corrupt the index. scan()/sync() guard against this by calling rebuildFtsIndexes() first, which
|
|
9
|
+
* re-derives every `*_fts` from its content table (the FTS5 'rebuild' command).
|
|
10
|
+
*
|
|
11
|
+
* The exact on-disk corruption is b-tree-state dependent and not portably synthesizable, so this
|
|
12
|
+
* test pins the *fix mechanism*: rebuildFtsIndexes() reconciles a deliberately mismatched FTS.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { openDb, rebuildFtsIndexes } from "./db.ts";
|
|
20
|
+
import { scan } from "./indexer.ts";
|
|
21
|
+
|
|
22
|
+
function tmpDir(): string {
|
|
23
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "cw-heal-"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function matchCount(db: ReturnType<typeof openDb>, table: string, term: string): number {
|
|
27
|
+
return (db.prepare(`SELECT count(*) c FROM ${table} WHERE ${table} MATCH ?`).get(term) as { c: number }).c;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("rebuildFtsIndexes heals a stale/mismatched FTS index", () => {
|
|
31
|
+
it("makes content searchable and drops stale tokens for all three FTS tables", () => {
|
|
32
|
+
const dir = tmpDir();
|
|
33
|
+
const db = openDb(path.join(dir, "index.db"));
|
|
34
|
+
|
|
35
|
+
// Seed base-table rows directly via FTS-bypassing inserts would still fire triggers, so to
|
|
36
|
+
// simulate a *stale* index we insert the base row, then overwrite the FTS shadow with wrong
|
|
37
|
+
// tokens (as a pre-trigger build would have left it).
|
|
38
|
+
db.prepare(
|
|
39
|
+
"INSERT INTO symbols (name,kind,file_path,line_start,line_end,signature,doc,summary,card_path) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
40
|
+
).run("realSymbol", "function", "/a.ts", 1, 2, "sig", "doc", "", "/c.md");
|
|
41
|
+
db.prepare(
|
|
42
|
+
"INSERT INTO lib_symbols (lib,version,name,kind,signature,doc,summary,card_path) VALUES (?,?,?,?,?,?,?,?)",
|
|
43
|
+
).run("hono", "1.0.0", "realLibSymbol", "function", "sig", "doc", "", "/c.md");
|
|
44
|
+
db.prepare(
|
|
45
|
+
"INSERT INTO notes (note_kind,title,body,tags,related,card_path,created_at) VALUES (?,?,?,?,?,?,?)",
|
|
46
|
+
).run("glossary", "realTerm", "body", "", "", "/c.md", "now");
|
|
47
|
+
|
|
48
|
+
// Corrupt the shadow indexes: replace the synced tokens with bogus ones.
|
|
49
|
+
db.exec("INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all')");
|
|
50
|
+
db.exec("INSERT INTO lib_symbols_fts(lib_symbols_fts) VALUES('delete-all')");
|
|
51
|
+
db.exec("INSERT INTO notes_fts(notes_fts) VALUES('delete-all')");
|
|
52
|
+
db.prepare("INSERT INTO symbols_fts(rowid,name,signature,doc,summary) VALUES (1,?,?,?,?)").run("WRONG", "x", "y", "z");
|
|
53
|
+
db.prepare("INSERT INTO lib_symbols_fts(rowid,name,signature,doc,summary) VALUES (1,?,?,?,?)").run("WRONG", "x", "y", "z");
|
|
54
|
+
db.prepare("INSERT INTO notes_fts(rowid,title,body,tags) VALUES (1,?,?,?)").run("WRONG", "y", "z");
|
|
55
|
+
|
|
56
|
+
// Stale precondition: real content not findable, bogus token is.
|
|
57
|
+
expect(matchCount(db, "symbols_fts", "realSymbol")).toBe(0);
|
|
58
|
+
expect(matchCount(db, "symbols_fts", "WRONG")).toBe(1);
|
|
59
|
+
|
|
60
|
+
rebuildFtsIndexes(db);
|
|
61
|
+
|
|
62
|
+
// After heal: real content searchable, bogus tokens gone — across all three tables.
|
|
63
|
+
expect(matchCount(db, "symbols_fts", "realSymbol")).toBe(1);
|
|
64
|
+
expect(matchCount(db, "symbols_fts", "WRONG")).toBe(0);
|
|
65
|
+
expect(matchCount(db, "lib_symbols_fts", "realLibSymbol")).toBe(1);
|
|
66
|
+
expect(matchCount(db, "lib_symbols_fts", "WRONG")).toBe(0);
|
|
67
|
+
expect(matchCount(db, "notes_fts", "realTerm")).toBe(1);
|
|
68
|
+
expect(matchCount(db, "notes_fts", "WRONG")).toBe(0);
|
|
69
|
+
|
|
70
|
+
db.close();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("scan() runs the heal and leaves symbols_fts consistent with symbols", async () => {
|
|
74
|
+
const dir = tmpDir();
|
|
75
|
+
const dbPath = path.join(dir, "index.db");
|
|
76
|
+
const entriesDir = path.join(dir, "entries");
|
|
77
|
+
const symbolsDir = path.join(entriesDir, "symbols");
|
|
78
|
+
fs.mkdirSync(symbolsDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
await scan({ projectRoot: process.cwd(), globalCodewalkerDir: dir, dbPath, entriesDir, symbolsDir });
|
|
81
|
+
|
|
82
|
+
const db = openDb(dbPath);
|
|
83
|
+
const symbols = (db.prepare("SELECT count(*) c FROM symbols").get() as { c: number }).c;
|
|
84
|
+
const fts = (db.prepare("SELECT count(*) c FROM symbols_fts").get() as { c: number }).c;
|
|
85
|
+
db.close();
|
|
86
|
+
|
|
87
|
+
expect(symbols).toBeGreaterThan(0);
|
|
88
|
+
expect(fts).toBe(symbols);
|
|
89
|
+
});
|
|
90
|
+
});
|