@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/src/notes.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Note orchestration for codewalker v1.3.
3
+ *
4
+ * Provides:
5
+ * - `addNote()`: render card → atomic write → upsertNote
6
+ * - `rebuildNotesDbFromCards()`: disposable-index rebuild from card files
7
+ *
8
+ * Mirrors the style of src/libs/indexer.ts (atomic write, tmp + rename, mode 0o600).
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { openDb, upsertNote } from "./db.ts";
14
+ import { renderNoteCard, parseNoteCard } from "./notes-cards.ts";
15
+ import type { Note, NoteKind } from "./types.ts";
16
+
17
+ /**
18
+ * Write a note: render card → atomic write → upsert DB row.
19
+ *
20
+ * The note's note_kind determines which directory to use.
21
+ * When `notesDir` is provided, it is used directly (backward compat).
22
+ * When both `glossaryDir` and `decisionsDir` are available, pass them via an options bag.
23
+ */
24
+ export function addNote(
25
+ dbPath: string,
26
+ note: Note,
27
+ notesDir?: string,
28
+ ): void {
29
+ const dir = notesDir;
30
+ if (!dir) throw new Error("notesDir is required");
31
+
32
+ // Ensure directory exists
33
+ fs.mkdirSync(dir, { recursive: true });
34
+
35
+ // Generate slug from title
36
+ const slug = slugifyNoteTitle(note.title);
37
+ const cardPath = path.join(dir, `${slug}.md`);
38
+
39
+ // Render card
40
+ const card = renderNoteCard(note);
41
+
42
+ // Atomic write
43
+ const tmpPath = cardPath + ".tmp";
44
+ fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
45
+ fs.renameSync(tmpPath, cardPath);
46
+
47
+ // Upsert DB row with the actual card_path
48
+ const db = openDb(dbPath);
49
+ try {
50
+ upsertNote(db, {
51
+ ...note,
52
+ card_path: cardPath,
53
+ });
54
+ } finally {
55
+ db.close();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Rebuild the notes DB tables from glossary + decisions cards alone.
61
+ * Demonstrates the disposable-index property: cards are the source of truth.
62
+ */
63
+ export function rebuildNotesDbFromCards(
64
+ dbPath: string,
65
+ glossaryDir: string,
66
+ decisionsDir: string,
67
+ ): void {
68
+ const db = openDb(dbPath);
69
+
70
+ try {
71
+ db.exec("BEGIN TRANSACTION");
72
+
73
+ // Clear existing notes
74
+ db.prepare("DELETE FROM notes").run();
75
+
76
+ // Process glossary cards
77
+ if (fs.existsSync(glossaryDir)) {
78
+ processCardsInDir(db, glossaryDir, "glossary");
79
+ }
80
+
81
+ // Process decisions cards
82
+ if (fs.existsSync(decisionsDir)) {
83
+ processCardsInDir(db, decisionsDir, "decision");
84
+ }
85
+
86
+ db.exec("COMMIT");
87
+ } catch (e) {
88
+ db.exec("ROLLBACK");
89
+ throw e;
90
+ } finally {
91
+ db.close();
92
+ }
93
+ }
94
+
95
+ // ── Internal helpers ───────────────────────────────────────────
96
+
97
+ function processCardsInDir(
98
+ db: ReturnType<typeof openDb>,
99
+ dir: string,
100
+ expectedKind: NoteKind,
101
+ ): void {
102
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
103
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
104
+
105
+ const cardPath = path.join(dir, entry.name);
106
+ const cardText = fs.readFileSync(cardPath, "utf-8");
107
+ const parsed = parseNoteCard(cardText);
108
+ if (!parsed) continue;
109
+ if (parsed.note_kind !== expectedKind) continue;
110
+
111
+ // Extract body from card
112
+ const body = extractNoteBody(cardText);
113
+
114
+ upsertNote(db, {
115
+ note_kind: parsed.note_kind,
116
+ title: parsed.title,
117
+ body,
118
+ tags: parsed.tags,
119
+ related: parsed.related,
120
+ card_path: cardPath,
121
+ });
122
+ }
123
+ }
124
+
125
+ /** Extract the body (after frontmatter) from a note card, stripping the # title header. */
126
+ function extractNoteBody(cardText: string): string {
127
+ const trimmed = cardText.trim();
128
+ if (!trimmed.startsWith("---")) return "";
129
+
130
+ const endOfFm = trimmed.indexOf("\n---", 3);
131
+ if (endOfFm === -1) return "";
132
+
133
+ return trimmed.slice(endOfFm + 4).trim();
134
+ }
135
+
136
+ function slugifyNoteTitle(title: string): string {
137
+ return title
138
+ .toLowerCase()
139
+ .replace(/[^a-z0-9]+/g, "-")
140
+ .replace(/^-+|-+$/g, "")
141
+ .slice(0, 60) || "untitled";
142
+ }
143
+
144
+ // Re-export for convenience
145
+ export { slugifyNoteTitle };
@@ -80,6 +80,14 @@ describe('project.ts', () => {
80
80
  expect(p.metaFile).toBe(path.join(p.codewalkerDir, 'meta.json'));
81
81
  expect(p.entriesDir).toBe(path.join(p.codewalkerDir, 'entries'));
82
82
  expect(p.symbolsDir).toBe(path.join(p.codewalkerDir, 'entries', 'symbols'));
83
+ expect(p.libsDir).toBe(path.join(p.codewalkerDir, 'entries', 'libs'));
84
+ });
85
+
86
+ it('exposes glossaryDir and decisionsDir paths', async () => {
87
+ const mod = await import('./project.ts');
88
+ const p = mod.resolveProject(tmpDir);
89
+ expect(p.glossaryDir).toBe(path.join(p.codewalkerDir, 'entries', 'glossary'));
90
+ expect(p.decisionsDir).toBe(path.join(p.codewalkerDir, 'entries', 'decisions'));
83
91
  });
84
92
  });
85
93
 
@@ -89,10 +97,13 @@ describe('project.ts', () => {
89
97
  const p = await mod.ensureProject(tmpDir);
90
98
  // marker file exists
91
99
  expect(fs.existsSync(p.markerPath)).toBe(true);
92
- // codewalker dir is created
100
+ // codewalker dir + subdirs are created
93
101
  expect(fs.existsSync(p.codewalkerDir)).toBe(true);
94
102
  expect(fs.existsSync(p.entriesDir)).toBe(true);
95
103
  expect(fs.existsSync(p.symbolsDir)).toBe(true);
104
+ expect(fs.existsSync(p.libsDir)).toBe(true);
105
+ expect(fs.existsSync(p.glossaryDir)).toBe(true);
106
+ expect(fs.existsSync(p.decisionsDir)).toBe(true);
96
107
  // meta.json written
97
108
  expect(fs.existsSync(p.metaFile)).toBe(true);
98
109
  // meta.json has correct shape
package/src/project.ts CHANGED
@@ -30,6 +30,9 @@ export interface ProjectPaths {
30
30
  metaFile: string;
31
31
  entriesDir: string;
32
32
  symbolsDir: string;
33
+ libsDir: string;
34
+ glossaryDir: string;
35
+ decisionsDir: string;
33
36
  }
34
37
 
35
38
  function piHome(): string {
@@ -123,6 +126,9 @@ function pathsForId(id: string, root: string, configDir: string, markerPath: str
123
126
  metaFile: path.join(globalDir, "codewalker", "meta.json"),
124
127
  entriesDir: path.join(globalDir, "codewalker", "entries"),
125
128
  symbolsDir: path.join(globalDir, "codewalker", "entries", "symbols"),
129
+ libsDir: path.join(globalDir, "codewalker", "entries", "libs"),
130
+ glossaryDir: path.join(globalDir, "codewalker", "entries", "glossary"),
131
+ decisionsDir: path.join(globalDir, "codewalker", "entries", "decisions"),
126
132
  };
127
133
  }
128
134
 
@@ -164,7 +170,7 @@ export async function ensureProject(cwd: string): Promise<ProjectPaths> {
164
170
  const p = resolveProject(cwd);
165
171
  const nowISO = new Date().toISOString();
166
172
 
167
- for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir]) {
173
+ for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir, p.glossaryDir, p.decisionsDir]) {
168
174
  fs.mkdirSync(dir, { recursive: true });
169
175
  }
170
176
 
package/src/query.test.ts CHANGED
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
- import { openDb, upsertSymbol, setMeta, getMeta, searchSymbols } from './db.ts';
5
+ import { openDb, upsertSymbol, setMeta, getMeta, upsertLibSymbol, upsertNote, searchLibSymbols } from './db.ts';
6
6
  import { runQuery } from './query.ts';
7
7
 
8
8
  describe('query.ts', () => {
@@ -95,4 +95,151 @@ describe('query.ts', () => {
95
95
  // In a non-git dir, staleness should be null
96
96
  expect(result.staleness).toBeNull();
97
97
  });
98
+
99
+ // ── source filter ───────────────────────────────────────────
100
+ it('source="code" returns only code symbols (default)', () => {
101
+ const db = openDb(dbPath);
102
+ upsertSymbol(db, {
103
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
104
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
105
+ });
106
+ upsertLibSymbol(db, {
107
+ lib: 'hono', version: '4.6.3', name: 'honoFunc',
108
+ kind: 'function', signature: '', doc: '', summary: '', card_path: '',
109
+ });
110
+ db.close();
111
+
112
+ const result = runQuery(dbPath, { query: '', source: 'code' });
113
+ expect(result.rows).toHaveLength(1);
114
+ expect(result.rows[0]!.name).toBe('myFunc');
115
+ expect(result.rows[0]!.source).toBeUndefined();
116
+ });
117
+
118
+ it('source="libs" returns only lib symbols', () => {
119
+ const db = openDb(dbPath);
120
+ upsertSymbol(db, {
121
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
122
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
123
+ });
124
+ upsertLibSymbol(db, {
125
+ lib: 'hono', version: '4.6.3', name: 'honoFunc',
126
+ kind: 'function', signature: '', doc: '', summary: '', card_path: '',
127
+ });
128
+ db.close();
129
+
130
+ const result = runQuery(dbPath, { query: '', source: 'libs' });
131
+ expect(result.rows).toHaveLength(1);
132
+ expect(result.rows[0]!.name).toBe('honoFunc');
133
+ expect(result.rows[0]!.source).toBe('lib');
134
+ expect(result.rows[0]!.lib).toBe('hono');
135
+ expect(result.rows[0]!.version).toBe('4.6.3');
136
+ });
137
+
138
+ it('source="all" returns merged code and lib symbols', () => {
139
+ const db = openDb(dbPath);
140
+ upsertSymbol(db, {
141
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
142
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
143
+ });
144
+ upsertLibSymbol(db, {
145
+ lib: 'hono', version: '4.6.3', name: 'honoFunc',
146
+ kind: 'function', signature: '', doc: '', summary: '', card_path: '',
147
+ });
148
+ db.close();
149
+
150
+ const result = runQuery(dbPath, { query: '', source: 'all' });
151
+ expect(result.rows).toHaveLength(2);
152
+ });
153
+
154
+ it('source=all respects limit across merged set', () => {
155
+ const db = openDb(dbPath);
156
+ upsertSymbol(db, {
157
+ name: 'aCode', kind: 'function', file_path: 'src/a.ts',
158
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
159
+ });
160
+ upsertLibSymbol(db, {
161
+ lib: 'pkg', version: '1.0.0', name: 'bLib',
162
+ kind: 'function', signature: '', doc: '', summary: '', card_path: '',
163
+ });
164
+ db.close();
165
+
166
+ const result = runQuery(dbPath, { query: '', source: 'all', limit: 1 });
167
+ expect(result.rows).toHaveLength(1);
168
+ });
169
+
170
+ // ── notes source ───────────────────────────────────────────
171
+ it('source="notes" returns only notes', () => {
172
+ const db = openDb(dbPath);
173
+ upsertSymbol(db, {
174
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
175
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
176
+ });
177
+ upsertNote(db, {
178
+ note_kind: 'glossary', title: 'Glossary Term', body: 'A term', tags: '', related: '', card_path: '',
179
+ });
180
+ db.close();
181
+
182
+ const result = runQuery(dbPath, { query: '', source: 'notes' });
183
+ expect(result.rows).toHaveLength(1);
184
+ expect(result.rows[0]!.name).toBe('Glossary Term');
185
+ expect(result.rows[0]!.source).toBe('note');
186
+ });
187
+
188
+ it('source="all" includes notes interleaved with code and libs', () => {
189
+ const db = openDb(dbPath);
190
+ upsertSymbol(db, {
191
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
192
+ line_start: 1, line_end: 1, signature: '', doc: 'refresh token', summary: '', card_path: '',
193
+ });
194
+ upsertLibSymbol(db, {
195
+ lib: 'hono', version: '4.6.3', name: 'honoFunc',
196
+ kind: 'function', signature: '', doc: 'refresh token', summary: '', card_path: '',
197
+ });
198
+ upsertNote(db, {
199
+ note_kind: 'glossary', title: 'Refresh Token', body: 'A token used to refresh auth without re-login.', tags: 'auth', related: 'myFunc', card_path: '',
200
+ });
201
+ db.close();
202
+
203
+ const result = runQuery(dbPath, { query: 'refresh', source: 'all' });
204
+ expect(result.rows.length).toBeGreaterThanOrEqual(2);
205
+ // Should have note + lib + code (code rows have source undefined)
206
+ const sources = result.rows.map(r => r.source);
207
+ expect(sources).toContain('note');
208
+ expect(sources).toContain('lib');
209
+ expect(sources).toContain(undefined); // code rows have no source
210
+ });
211
+
212
+ it('source kind filter works for notes source', () => {
213
+ const db = openDb(dbPath);
214
+ upsertNote(db, {
215
+ note_kind: 'glossary', title: 'Term', body: 'A glossary term', tags: '', related: '', card_path: '',
216
+ });
217
+ upsertNote(db, {
218
+ note_kind: 'decision', title: 'Decision', body: 'A decision note', tags: '', related: '', card_path: '',
219
+ });
220
+ db.close();
221
+
222
+ const result = runQuery(dbPath, { query: '', source: 'notes', kind: 'glossary' });
223
+ expect(result.rows).toHaveLength(1);
224
+ expect(result.rows[0]!.name).toBe('Term');
225
+ });
226
+
227
+ it('source=all with note in query works (note matches alongside code)', () => {
228
+ const db = openDb(dbPath);
229
+ upsertSymbol(db, {
230
+ name: 'myFunc', kind: 'function', file_path: 'src/payments.ts',
231
+ line_start: 1, line_end: 10, signature: '', doc: 'processes charges', summary: '', card_path: '',
232
+ });
233
+ upsertNote(db, {
234
+ note_kind: 'glossary', title: 'charge', body: 'A payment processing concept.',
235
+ tags: 'payments', related: 'myFunc', card_path: '',
236
+ });
237
+ db.close();
238
+
239
+ const result = runQuery(dbPath, { query: 'charge', source: 'all' });
240
+ expect(result.rows.length).toBeGreaterThanOrEqual(1);
241
+ const sources = result.rows.map(r => r.source);
242
+ expect(sources).toContain('note');
243
+ });
244
+
98
245
  });
package/src/query.ts CHANGED
@@ -2,14 +2,16 @@
2
2
  * Query orchestration: wraps DB search with staleness detection.
3
3
  */
4
4
 
5
- import type { QueryResult, QueryResultRow, StalenessInfo } from "./types.ts";
6
- import { openDb, searchSymbols, getMeta } from "./db.ts";
5
+ import type { QueryResult, QueryResultRow, StalenessInfo, NoteKind } from "./types.ts";
6
+ import { openDb, searchSymbols, searchLibSymbols, searchNotes, getMeta } from "./db.ts";
7
7
  import { getHeadSha, changedFilesSince } from "./git.ts";
8
8
 
9
9
  export interface QueryParams {
10
10
  query: string;
11
11
  kind?: string;
12
12
  limit?: number;
13
+ /** Source scope: "code" (default, only code symbols), "libs" (only lib symbols), "notes" (only notes), or "all" (all three). */
14
+ source?: "code" | "libs" | "notes" | "all";
13
15
  }
14
16
 
15
17
  /**
@@ -27,12 +29,30 @@ export function runQuery(
27
29
  const db = openDb(dbPath);
28
30
 
29
31
  try {
30
- const rows = searchSymbols(
31
- db,
32
- params.query,
33
- params.kind,
34
- params.limit ?? 10,
35
- );
32
+ const source = params.source ?? "code";
33
+ const limit = params.limit ?? 10;
34
+
35
+ let rows: QueryResultRow[];
36
+
37
+ if (source === "libs") {
38
+ rows = searchLibSymbols(db, params.query, params.kind, limit) as unknown as QueryResultRow[];
39
+ } else if (source === "notes") {
40
+ const noteRows = searchNotes(db, params.query, params.kind as NoteKind | undefined, limit);
41
+ rows = noteRows as unknown as QueryResultRow[];
42
+ } else if (source === "all") {
43
+ // Run code + lib + note searches, merge, sort by score, apply limit
44
+ const codeRows = searchSymbols(db, params.query, params.kind, limit);
45
+ const libRows = searchLibSymbols(db, params.query, params.kind, limit) as unknown as QueryResultRow[];
46
+ const noteRows = searchNotes(db, params.query, params.kind as NoteKind | undefined, limit * 2) as unknown as QueryResultRow[];
47
+
48
+ // Merge and sort by score ascending (lower bm25 = better match)
49
+ const merged: QueryResultRow[] = [...codeRows, ...libRows, ...noteRows];
50
+ merged.sort((a, b) => a.score - b.score);
51
+ rows = merged.slice(0, limit);
52
+ } else {
53
+ // "code" — default, existing behavior
54
+ rows = searchSymbols(db, params.query, params.kind, limit);
55
+ }
36
56
 
37
57
  const staleness = detectStaleness(db, repoDir);
38
58
 
package/src/types.ts CHANGED
@@ -4,6 +4,18 @@
4
4
  * These types are shared across all layers (extraction, cards, DB, query).
5
5
  */
6
6
 
7
+ /** Library symbol kind — extends SymbolKind with reexport + namespace. */
8
+ export type LibSymbolKind =
9
+ | "function"
10
+ | "const"
11
+ | "class"
12
+ | "interface"
13
+ | "type"
14
+ | "enum"
15
+ | "namespace"
16
+ | "reexport"
17
+ | "module";
18
+
7
19
  /** The kind of a code symbol. */
8
20
  export type SymbolKind =
9
21
  | "function"
@@ -17,6 +29,18 @@ export type SymbolKind =
17
29
  | "namespace"
18
30
  | "module";
19
31
 
32
+ /** A single symbol extracted from a library's .d.ts file. */
33
+ export interface LibSymbol {
34
+ lib: string;
35
+ version: string;
36
+ name: string;
37
+ kind: LibSymbolKind;
38
+ signature: string;
39
+ doc: string;
40
+ summary: string;
41
+ card_path: string;
42
+ }
43
+
20
44
  /** A single symbol extracted from source code. */
21
45
  export interface Symbol {
22
46
  name: string;
@@ -40,6 +64,19 @@ export interface CardHead {
40
64
  summary: string;
41
65
  }
42
66
 
67
+ /** Note kind discriminator for bridge cards. */
68
+ export type NoteKind = "glossary" | "decision";
69
+
70
+ /** A glossary/decision note (bridge card) for conceptual knowledge. */
71
+ export interface Note {
72
+ note_kind: NoteKind;
73
+ title: string;
74
+ body: string;
75
+ tags: string;
76
+ related: string;
77
+ card_path: string;
78
+ }
79
+
43
80
  /** A single row returned from a query. */
44
81
  export interface QueryResultRow {
45
82
  name: string;
@@ -51,6 +88,13 @@ export interface QueryResultRow {
51
88
  summary: string;
52
89
  score: number;
53
90
  id: number;
91
+ /** Origin fields — code rows omit these; lib / note rows set them. */
92
+ source?: "code" | "lib" | "note";
93
+ lib?: string;
94
+ version?: string;
95
+ /** Note-specific fields — only for source === "note" rows. */
96
+ note_kind?: NoteKind;
97
+ tags?: string;
54
98
  }
55
99
 
56
100
  /** The full result of a query, including staleness info. */