chapterhouse 0.7.0 → 0.8.1

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.
Files changed (81) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/korg.js +34 -0
  3. package/dist/api/korg.test.js +42 -0
  4. package/dist/api/server.js +238 -2
  5. package/dist/api/server.test.js +199 -0
  6. package/dist/config.js +28 -0
  7. package/dist/config.test.js +20 -0
  8. package/dist/copilot/agents.js +3 -4
  9. package/dist/copilot/agents.test.js +12 -1
  10. package/dist/copilot/orchestrator.js +12 -1
  11. package/dist/copilot/orchestrator.test.js +3 -7
  12. package/dist/copilot/system-message.js +12 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +193 -375
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +80 -59
  17. package/dist/copilot/turn-event-log-env.test.js +11 -15
  18. package/dist/daemon.js +19 -0
  19. package/dist/memory/decisions.js +6 -5
  20. package/dist/memory/entities.js +20 -9
  21. package/dist/memory/eot.js +30 -8
  22. package/dist/memory/eot.test.js +220 -6
  23. package/dist/memory/hooks.js +151 -0
  24. package/dist/memory/hooks.test.js +325 -0
  25. package/dist/memory/hot-tier.js +37 -0
  26. package/dist/memory/hot-tier.test.js +30 -0
  27. package/dist/memory/housekeeping-scheduler.js +35 -0
  28. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  29. package/dist/memory/inbox.js +10 -0
  30. package/dist/memory/index.js +3 -1
  31. package/dist/memory/migration.js +244 -0
  32. package/dist/memory/migration.test.js +108 -0
  33. package/dist/memory/reflect.js +273 -0
  34. package/dist/memory/reflect.test.js +254 -0
  35. package/dist/paths.js +31 -11
  36. package/dist/store/db.js +187 -4
  37. package/dist/store/db.test.js +66 -2
  38. package/dist/test/helpers/reset-singletons.js +8 -0
  39. package/dist/test/helpers/reset-singletons.test.js +37 -0
  40. package/dist/test/setup-env.js +9 -1
  41. package/dist/wiki/consolidation.js +641 -0
  42. package/dist/wiki/consolidation.test.js +143 -0
  43. package/dist/wiki/frontmatter.js +48 -0
  44. package/dist/wiki/frontmatter.test.js +42 -0
  45. package/dist/wiki/fs.js +22 -13
  46. package/dist/wiki/index-manager.js +305 -330
  47. package/dist/wiki/index-manager.test.js +265 -144
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/log-manager.js +8 -5
  53. package/dist/wiki/log-manager.test.js +4 -0
  54. package/dist/wiki/migrate-topics.test.js +16 -6
  55. package/dist/wiki/scheduler.js +118 -0
  56. package/dist/wiki/scheduler.test.js +64 -0
  57. package/dist/wiki/timeline.js +51 -0
  58. package/dist/wiki/timeline.test.js +65 -0
  59. package/dist/wiki/topic-structure.js +1 -1
  60. package/package.json +1 -1
  61. package/skills/pkb-ideas/SKILL.md +78 -0
  62. package/skills/pkb-ideas/_meta.json +4 -0
  63. package/skills/pkb-org/SKILL.md +82 -0
  64. package/skills/pkb-org/_meta.json +4 -0
  65. package/skills/pkb-people/SKILL.md +74 -0
  66. package/skills/pkb-people/_meta.json +4 -0
  67. package/skills/pkb-research/SKILL.md +83 -0
  68. package/skills/pkb-research/_meta.json +4 -0
  69. package/skills/pkb-source/SKILL.md +38 -0
  70. package/skills/pkb-source/_meta.json +4 -0
  71. package/skills/wiki-conventions/SKILL.md +5 -5
  72. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  73. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  74. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  75. package/web/dist/index.html +2 -2
  76. package/dist/wiki/context.js +0 -138
  77. package/dist/wiki/fix.js +0 -335
  78. package/dist/wiki/fix.test.js +0 -350
  79. package/dist/wiki/lint.js +0 -451
  80. package/dist/wiki/lint.test.js +0 -329
  81. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -1,90 +1,266 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Wiki index.md manager — parse, update, and search the page catalog
2
+ // Wiki index manager — SQLite FTS5-backed wiki page catalog
3
3
  // ---------------------------------------------------------------------------
4
- import { existsSync, statSync } from "fs";
5
- import { join } from "path";
6
- import { WIKI_DIR } from "../paths.js";
7
- import { readIndexFile, writeIndexFile, listPages, readPage } from "./fs.js";
4
+ import { getDb, isFts5Available } from "../store/db.js";
5
+ import { childLogger } from "../util/logger.js";
6
+ import { listPages, readPage } from "./fs.js";
8
7
  import { parseWikiFrontmatter } from "./frontmatter.js";
9
8
  import { normalizeWikiPath } from "./path-utils.js";
10
- import { entityCategories, FLAT_CATEGORIES } from "./topic-structure.js";
11
- const INDEX_PATH = join(WIKI_DIR, "index.md");
9
+ import { updateLinks } from "./links.js";
12
10
  const ACTION_LOG_PAGE_RE = /^pages\/_meta\/log(?:-\d{4})?\.md$/;
13
- // mtime-based cache so per-message context injection doesn't re-parse on every turn.
14
- let cache;
15
- function invalidateCache() {
16
- cache = undefined;
11
+ const LEGACY_INDEX_PAGE = "pages/index.md";
12
+ const log = childLogger("wiki-index");
13
+ function isIgnoredIndexPage(path) {
14
+ return path === LEGACY_INDEX_PAGE || ACTION_LOG_PAGE_RE.test(path);
17
15
  }
16
+ function getIndexablePages() {
17
+ return listPages().filter((p) => !isIgnoredIndexPage(p));
18
+ }
19
+ function getWikiPageCount() {
20
+ const db = getDb();
21
+ return db.prepare(`SELECT COUNT(*) as c FROM wiki_pages`).get().c;
22
+ }
23
+ function getErrorMessage(err) {
24
+ return err instanceof Error ? err.message : String(err);
25
+ }
26
+ function buildPageSummary(summary, body) {
27
+ let nextSummary = summary?.trim() ?? "";
28
+ if (!nextSummary) {
29
+ for (const raw of body.split("\n")) {
30
+ const line = raw.trim();
31
+ if (!line || line.startsWith("#") || line.startsWith("<!--"))
32
+ continue;
33
+ nextSummary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
34
+ if (!nextSummary)
35
+ continue;
36
+ break;
37
+ }
38
+ }
39
+ if (nextSummary.length > 160)
40
+ nextSummary = nextSummary.slice(0, 157) + "…";
41
+ return nextSummary;
42
+ }
43
+ function categoryOfPath(path) {
44
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
45
+ const segs = rest.split("/").filter(Boolean);
46
+ if (segs.length <= 1)
47
+ return (segs[0] || "pages").replace(/\.md$/i, "");
48
+ return segs[0];
49
+ }
50
+ function basenameTitle(path) {
51
+ const normalizedPath = normalizeWikiPath(path);
52
+ const segs = normalizedPath.split("/").filter(Boolean);
53
+ const file = segs[segs.length - 1] || normalizedPath;
54
+ const base = file.replace(/\.md$/, "");
55
+ const titleBase = base === "index" && segs.length >= 2 ? segs[segs.length - 2] : base;
56
+ return titleBase.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
57
+ }
58
+ function quoteFts5Terms(query) {
59
+ return query
60
+ .trim()
61
+ .split(/\s+/)
62
+ .filter((term) => term.length > 0)
63
+ .map((term) => {
64
+ const unquoted = term.replace(/^["']|["']$/g, "");
65
+ return `"${unquoted.replace(/"/g, '""')}"`;
66
+ })
67
+ .join(" ");
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // New SQLite-backed API
71
+ // ---------------------------------------------------------------------------
18
72
  /**
19
- * Parse index.md into structured entries.
20
- * Expected format (new):
21
- * ## Section Name
22
- * - [Title](path) — Summary text | tags: tag1, tag2 | updated: 2026-04-17
23
- * Also supports legacy format without tags/updated.
73
+ * Upsert a single wiki page into wiki_pages (and FTS via trigger).
74
+ * entity_type is read from frontmatter.metadata.entity_type if present.
24
75
  */
25
- export function parseIndex() {
26
- let mtimeMs = 0, size = 0;
27
- if (existsSync(INDEX_PATH)) {
28
- const st = statSync(INDEX_PATH);
29
- mtimeMs = st.mtimeMs;
30
- size = st.size;
31
- if (cache && cache.mtimeMs === mtimeMs && cache.size === size) {
32
- return cache.entries;
76
+ export function upsertWikiPage(path, frontmatter, summary) {
77
+ const db = getDb();
78
+ const normalizedPath = normalizeWikiPath(path);
79
+ const title = frontmatter.title ?? basenameTitle(normalizedPath);
80
+ const entityType = frontmatter.metadata?.["entity_type"] ?? null;
81
+ const tags = JSON.stringify(frontmatter.tags ?? []);
82
+ const lastUpdated = frontmatter.updated ?? new Date().toISOString();
83
+ db.prepare(`
84
+ INSERT INTO wiki_pages (path, title, entity_type, tags, summary, last_updated)
85
+ VALUES (?, ?, ?, ?, ?, ?)
86
+ ON CONFLICT(path) DO UPDATE SET
87
+ title = excluded.title,
88
+ entity_type = excluded.entity_type,
89
+ tags = excluded.tags,
90
+ summary = excluded.summary,
91
+ last_updated = excluded.last_updated,
92
+ version = wiki_pages.version + 1
93
+ `).run(normalizedPath, title, entityType, tags, summary || null, lastUpdated);
94
+ }
95
+ /** Remove a page from wiki_pages (and FTS via trigger). */
96
+ export function removeWikiPage(path) {
97
+ const db = getDb();
98
+ db.prepare(`DELETE FROM wiki_pages WHERE path = ?`).run(normalizeWikiPath(path));
99
+ }
100
+ /**
101
+ * Rebuild the wiki_pages SQLite index from the filesystem.
102
+ * Filesystem is the source of truth: insert missing pages, remove stale entries.
103
+ */
104
+ function runWikiReindex() {
105
+ const pages = getIndexablePages();
106
+ const db = getDb();
107
+ const onDisk = new Set(pages.map(normalizeWikiPath));
108
+ db.transaction(() => {
109
+ const inDb = db.prepare(`SELECT path FROM wiki_pages`).all();
110
+ for (const { path } of inDb) {
111
+ if (!onDisk.has(path)) {
112
+ db.prepare(`DELETE FROM wiki_pages WHERE path = ?`).run(path);
113
+ db.prepare(`DELETE FROM wiki_links WHERE from_page = ? OR to_page = ?`).run(path, path);
114
+ }
33
115
  }
34
- }
35
- const content = readIndexFile();
36
- const entries = [];
37
- let currentSection = "Uncategorized";
38
- for (const line of content.split("\n")) {
39
- // Section headers
40
- const sectionMatch = line.match(/^##\s+(.+)/);
41
- if (sectionMatch) {
42
- currentSection = sectionMatch[1].trim();
116
+ })();
117
+ let indexedPageCount = 0;
118
+ let skippedPageCount = 0;
119
+ for (const p of pages) {
120
+ try {
121
+ const content = readPage(p);
122
+ if (!content)
123
+ continue;
124
+ const { parsed: fm, body } = parseWikiFrontmatter(content);
125
+ const summary = buildPageSummary(fm.summary, body);
126
+ upsertWikiPage(p, fm, summary);
127
+ updateLinks(p);
128
+ indexedPageCount++;
129
+ }
130
+ catch (err) {
131
+ skippedPageCount++;
132
+ log.warn({ path: p, err: getErrorMessage(err) }, "Skipping wiki page during reindex");
43
133
  continue;
44
134
  }
45
- // Entry lines (possibly indented sub-bullets):
46
- // - [Title](path) Summary | tags: t1, t2 | updated: YYYY-MM-DD
47
- const entryMatch = line.match(/^\s*-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/);
48
- if (entryMatch) {
49
- const rawSummary = entryMatch[3].trim();
50
- // Parse optional | tags: ... | updated: ... suffixes
51
- let summary = rawSummary;
52
- let tags = [];
53
- let updated = "";
54
- const tagsMatch = rawSummary.match(/\|\s*tags:\s*([^|]+)/);
55
- if (tagsMatch) {
56
- tags = tagsMatch[1].split(",").map((t) => t.trim()).filter(Boolean);
57
- summary = summary.replace(tagsMatch[0], "").trim();
58
- }
59
- const updatedMatch = rawSummary.match(/\|\s*updated:\s*(\S+)/);
60
- if (updatedMatch) {
61
- updated = updatedMatch[1].trim();
62
- summary = summary.replace(updatedMatch[0], "").trim();
135
+ }
136
+ log.info({ indexedPageCount, skippedPageCount }, `Reindexed ${indexedPageCount} pages, skipped ${skippedPageCount} pages (errors)`);
137
+ return {
138
+ diskPageCount: pages.length,
139
+ indexedPageCount,
140
+ skippedPageCount,
141
+ };
142
+ }
143
+ export function rebuildWikiIndex() {
144
+ runWikiReindex();
145
+ }
146
+ /**
147
+ * Search wiki pages using FTS5 BM25 ranking.
148
+ * Falls back to LIKE query if FTS5 unavailable.
149
+ * Empty query returns most-recently-updated pages.
150
+ * Each direct result includes 1-hop neighbors from wiki_links in `related`.
151
+ * Neighbor pages not already in direct results are appended (deduplicated).
152
+ */
153
+ export function ensureWikiIndexPopulated() {
154
+ const pages = getIndexablePages();
155
+ const indexedPageCount = getWikiPageCount();
156
+ if (indexedPageCount === 0 && pages.length > 0) {
157
+ log.warn({ diskPageCount: pages.length }, "wiki_pages empty while wiki files exist; rebuilding from disk");
158
+ const result = runWikiReindex();
159
+ return {
160
+ reindexed: true,
161
+ diskPageCount: result.diskPageCount,
162
+ indexedPageCount: result.indexedPageCount,
163
+ skippedPageCount: result.skippedPageCount,
164
+ };
165
+ }
166
+ return {
167
+ reindexed: false,
168
+ diskPageCount: pages.length,
169
+ indexedPageCount,
170
+ skippedPageCount: 0,
171
+ };
172
+ }
173
+ export function reindexWikiPages() {
174
+ const result = runWikiReindex();
175
+ return {
176
+ reindexed: true,
177
+ diskPageCount: result.diskPageCount,
178
+ indexedPageCount: result.indexedPageCount,
179
+ skippedPageCount: result.skippedPageCount,
180
+ };
181
+ }
182
+ export function wikiSearch(query, limit = 20) {
183
+ const db = getDb();
184
+ const trimmed = query.trim();
185
+ let directRows;
186
+ if (!trimmed) {
187
+ directRows = db.prepare(`
188
+ SELECT path, title, entity_type, summary, 0 as rank
189
+ FROM wiki_pages
190
+ ORDER BY last_updated DESC
191
+ LIMIT ?
192
+ `).all(limit);
193
+ }
194
+ else if (isFts5Available()) {
195
+ const ftsQuery = quoteFts5Terms(trimmed);
196
+ try {
197
+ directRows = db.prepare(`
198
+ SELECT w.path, w.title, w.entity_type, w.summary, bm25(wiki_pages_fts) as rank
199
+ FROM wiki_pages_fts
200
+ JOIN wiki_pages w ON wiki_pages_fts.rowid = w.rowid
201
+ WHERE wiki_pages_fts MATCH ?
202
+ ORDER BY bm25(wiki_pages_fts)
203
+ LIMIT ?
204
+ `).all(ftsQuery, limit);
205
+ }
206
+ catch {
207
+ directRows = [];
208
+ }
209
+ }
210
+ else {
211
+ directRows = [];
212
+ }
213
+ // LIKE fallback when no FTS5 results
214
+ if (directRows.length === 0 && trimmed) {
215
+ const pattern = `%${trimmed}%`;
216
+ directRows = db.prepare(`
217
+ SELECT path, title, entity_type, summary, 0 as rank
218
+ FROM wiki_pages
219
+ WHERE title LIKE ? OR summary LIKE ? OR tags LIKE ?
220
+ ORDER BY last_updated DESC
221
+ LIMIT ?
222
+ `).all(pattern, pattern, pattern, limit);
223
+ }
224
+ // Enrich direct results with 1-hop neighbors and collect neighbor paths
225
+ const seenPaths = new Set(directRows.map((r) => r.path));
226
+ const neighborPaths = new Map();
227
+ for (const row of directRows) {
228
+ const outbound = db.prepare(`SELECT to_page, link_type FROM wiki_links WHERE from_page = ?`).all(row.path);
229
+ const inbound = db.prepare(`SELECT from_page, link_type FROM wiki_links WHERE to_page = ?`).all(row.path);
230
+ const related = [
231
+ ...outbound.map((r) => ({ page: r.to_page, link_type: r.link_type, direction: "outbound" })),
232
+ ...inbound.map((r) => ({ page: r.from_page, link_type: r.link_type, direction: "inbound" })),
233
+ ];
234
+ row.related = related;
235
+ for (const rel of related) {
236
+ if (!seenPaths.has(rel.page)) {
237
+ seenPaths.add(rel.page);
238
+ if (!neighborPaths.has(rel.page)) {
239
+ neighborPaths.set(rel.page, []);
240
+ }
241
+ neighborPaths.get(rel.page).push({ page: row.path, link_type: rel.link_type, direction: rel.direction === "outbound" ? "inbound" : "outbound" });
63
242
  }
64
- // Clean trailing pipe if any
65
- summary = summary.replace(/\|?\s*$/, "").trim();
66
- entries.push({
67
- title: entryMatch[1].trim(),
68
- path: normalizeWikiPath(entryMatch[2].trim()),
69
- summary,
70
- section: currentSection,
71
- tags: tags.length > 0 ? tags : undefined,
72
- updated: updated || undefined,
73
- });
74
243
  }
75
244
  }
76
- // Self-heal: if index is empty/corrupted but pages exist on disk, rebuild from disk.
77
- if (entries.length === 0) {
78
- const pages = listPages().filter((path) => !isActionLogPage(path));
79
- if (pages.length > 0) {
80
- const rebuilt = rebuildIndexFromPages();
81
- cache = { mtimeMs, size, entries: rebuilt };
82
- return rebuilt;
245
+ // Append neighbor pages as extra results (ranked after direct matches)
246
+ if (neighborPaths.size > 0) {
247
+ const neighborPathList = [...neighborPaths.keys()];
248
+ const placeholders = neighborPathList.map(() => "?").join(",");
249
+ const neighborRows = db.prepare(`
250
+ SELECT path, title, entity_type, summary, 0 as rank
251
+ FROM wiki_pages
252
+ WHERE path IN (${placeholders})
253
+ `).all(...neighborPathList);
254
+ for (const row of neighborRows) {
255
+ row.related = neighborPaths.get(row.path) ?? [];
256
+ directRows.push(row);
83
257
  }
84
258
  }
85
- cache = { mtimeMs, size, entries };
86
- return entries;
259
+ return directRows;
87
260
  }
261
+ // ---------------------------------------------------------------------------
262
+ // Backward-compatible API (delegates to SQLite)
263
+ // ---------------------------------------------------------------------------
88
264
  /** Build (or refresh) an IndexEntry by reading the page from disk. */
89
265
  export function buildIndexEntryForPage(path, fallback) {
90
266
  const normalizedPath = normalizeWikiPath(path);
@@ -95,8 +271,6 @@ export function buildIndexEntryForPage(path, fallback) {
95
271
  const title = fm.title || fallback?.title || basenameTitle(normalizedPath);
96
272
  const tags = fm.tags ?? fallback?.tags ?? [];
97
273
  const updated = fm.updated || fallback?.updated;
98
- // Compliant pages treat frontmatter summary as canonical. Legacy pages fall back
99
- // to the first non-frontmatter, non-heading content line.
100
274
  let summary = fm.summary?.trim() || fallback?.summary?.trim() || "";
101
275
  if (!summary) {
102
276
  for (const raw of body.split("\n")) {
@@ -115,283 +289,84 @@ export function buildIndexEntryForPage(path, fallback) {
115
289
  path: normalizedPath,
116
290
  title,
117
291
  summary: summary || title,
118
- section: fallback?.section || "Knowledge",
292
+ section: fallback?.section || categoryOfPath(normalizedPath) || "Knowledge",
119
293
  tags: tags.length ? tags : undefined,
120
294
  updated,
121
295
  };
122
296
  }
123
- function basenameTitle(path) {
124
- const normalizedPath = normalizeWikiPath(path);
125
- const file = normalizedPath.split("/").pop() || normalizedPath;
126
- const base = file.replace(/\.md$/, "");
127
- return base.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
128
- }
129
- /** Rebuild every index entry from on-disk pages. Preserves section if known. */
130
- export function rebuildIndexFromPages() {
131
- const pages = listPages().filter((path) => !isActionLogPage(path));
132
- const previous = new Map();
133
- // Try to keep section assignments by re-parsing the (possibly-corrupted) index without recursion.
134
- try {
135
- const raw = readIndexFile();
136
- let section = "Knowledge";
137
- for (const line of raw.split("\n")) {
138
- const sm = line.match(/^##\s+(.+)/);
139
- if (sm) {
140
- section = sm[1].trim();
141
- continue;
142
- }
143
- const em = line.match(/^\s*-\s+\[.+?\]\((.+?)\)/);
144
- if (em) {
145
- const normalizedPath = normalizeWikiPath(em[1].trim());
146
- previous.set(normalizedPath, { path: normalizedPath, title: "", summary: "", section });
147
- }
148
- }
149
- }
150
- catch { /* ignore */ }
151
- const entries = [];
152
- for (const p of pages) {
153
- const entry = buildIndexEntryForPage(p, previous.get(p));
154
- if (entry)
155
- entries.push(entry);
156
- }
157
- // Write directly without recursion through addToIndex.
158
- writeIndexInternal(entries);
159
- invalidateCache();
160
- return entries;
161
- }
162
- /** Regenerate index.md from a list of entries, grouped by section. */
163
- export function writeIndex(entries) {
164
- writeIndexInternal(entries);
165
- invalidateCache();
166
- }
167
- /** Derive the top-level category of a page from its path under pages/. */
168
- function categoryOfPath(path) {
169
- const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
170
- const segs = rest.split("/").filter(Boolean);
171
- if (segs.length <= 1)
172
- return (segs[0] || "pages").replace(/\.md$/i, "");
173
- return segs[0];
174
- }
175
- /** Derive the topic slug of an entity-category page (pages/<cat>/<topic>/<file>), if any. */
176
- function topicOfPath(path) {
177
- const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
178
- const segs = rest.split("/").filter(Boolean);
179
- return segs.length >= 3 ? segs[1] : undefined;
180
- }
181
- function isTopicIndexFile(path) {
182
- return /\/index\.md$/i.test(path);
183
- }
184
- function humanize(slug) {
185
- return slug.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
186
- }
187
- function renderEntryLine(item, indent = "") {
188
- let line = `${indent}- [${item.title}](${item.path}) — ${item.summary}`;
189
- if (item.tags?.length)
190
- line += ` | tags: ${item.tags.join(", ")}`;
191
- if (item.updated)
192
- line += ` | updated: ${item.updated}`;
193
- return line;
194
- }
195
- function writeIndexInternal(entries) {
196
- // Group by top-level category derived from the page path (not the stored
197
- // `section`, which is no longer authoritative).
198
- const byCategory = new Map();
199
- for (const entry of entries) {
200
- const cat = categoryOfPath(entry.path);
201
- const list = byCategory.get(cat) || [];
202
- list.push(entry);
203
- byCategory.set(cat, list);
204
- }
205
- const entityCats = entityCategories();
206
- const entityCatSet = new Set(entityCats);
207
- const known = [...entityCats, ...FLAT_CATEGORIES];
208
- const orderedCategories = [
209
- ...known.filter((c) => byCategory.has(c)),
210
- ...[...byCategory.keys()].filter((c) => !known.includes(c)).sort(),
211
- ];
212
- const lines = [
213
- "# Wiki Index",
214
- "",
215
- "_Max's knowledge base. This file is maintained automatically._",
216
- "",
217
- `Last updated: ${new Date().toISOString().slice(0, 10)}`,
218
- "",
219
- ];
220
- for (const cat of orderedCategories) {
221
- const items = byCategory.get(cat);
222
- lines.push(`## ${humanize(cat)}`, "");
223
- if (entityCatSet.has(cat)) {
224
- // Two-level layout: topic directory -> overview (index.md) + facet pages.
225
- // Pages not yet in canonical <category>/<topic>/<file> shape are listed as
226
- // plain bullets (they'll be relocated by the topic-structure migration).
227
- const byTopic = new Map();
228
- const ungrouped = [];
229
- for (const entry of items) {
230
- const topic = topicOfPath(entry.path);
231
- if (!topic) {
232
- ungrouped.push(entry);
233
- continue;
234
- }
235
- const list = byTopic.get(topic) || [];
236
- list.push(entry);
237
- byTopic.set(topic, list);
238
- }
239
- for (const topic of [...byTopic.keys()].sort()) {
240
- const topicItems = byTopic.get(topic);
241
- const overview = topicItems.find((e) => isTopicIndexFile(e.path));
242
- const facets = topicItems
243
- .filter((e) => !isTopicIndexFile(e.path))
244
- .sort((a, b) => a.path.localeCompare(b.path));
245
- if (overview) {
246
- lines.push(renderEntryLine(overview));
247
- }
248
- else {
249
- lines.push(`- **${humanize(topic)}** _(no overview page)_`);
250
- }
251
- for (const facet of facets)
252
- lines.push(renderEntryLine(facet, " "));
253
- }
254
- for (const entry of ungrouped.sort((a, b) => a.path.localeCompare(b.path))) {
255
- lines.push(renderEntryLine(entry));
256
- }
297
+ /** Parse the wiki_pages DB into IndexEntry array (replaces reading index.md). */
298
+ export function parseIndex() {
299
+ const db = getDb();
300
+ const rows = db.prepare(`
301
+ SELECT path, title, tags, summary, last_updated
302
+ FROM wiki_pages
303
+ ORDER BY path
304
+ `).all();
305
+ return rows.map((row) => {
306
+ let tags;
307
+ try {
308
+ const parsed = JSON.parse(row.tags || "[]");
309
+ tags = Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
257
310
  }
258
- else {
259
- for (const entry of [...items].sort((a, b) => a.path.localeCompare(b.path))) {
260
- lines.push(renderEntryLine(entry));
261
- }
311
+ catch {
312
+ tags = undefined;
262
313
  }
263
- lines.push("");
264
- }
265
- if (orderedCategories.length === 0) {
266
- lines.push("## Pages", "", "_(No pages yet.)_", "");
267
- }
268
- writeIndexFile(lines.join("\n"));
269
- }
270
- function isActionLogPage(path) {
271
- return ACTION_LOG_PAGE_RE.test(path);
314
+ return {
315
+ path: row.path,
316
+ title: row.title,
317
+ summary: row.summary || row.title,
318
+ section: categoryOfPath(row.path),
319
+ tags,
320
+ updated: row.last_updated?.slice(0, 10) || undefined,
321
+ };
322
+ });
272
323
  }
273
- /** Add or update an entry in the index. Upserts by path. */
324
+ /** Add or update a page in the index. Delegates to upsertWikiPage. */
274
325
  export function addToIndex(entry) {
275
- const normalizedEntry = { ...entry, path: normalizeWikiPath(entry.path) };
276
- const entries = parseIndex();
277
- const existing = entries.findIndex((e) => e.path === normalizedEntry.path);
278
- if (existing >= 0) {
279
- entries[existing] = normalizedEntry;
280
- }
281
- else {
282
- entries.push(normalizedEntry);
283
- }
284
- writeIndex(entries);
326
+ const fm = {
327
+ title: entry.title,
328
+ summary: entry.summary,
329
+ updated: entry.updated,
330
+ tags: entry.tags ?? [],
331
+ metadata: {},
332
+ };
333
+ upsertWikiPage(entry.path, fm, entry.summary);
285
334
  }
286
- /** Remove an entry from the index by path. */
335
+ /** Remove an entry from the index by path. Returns true if found. */
287
336
  export function removeFromIndex(path) {
337
+ const db = getDb();
288
338
  const normalizedPath = normalizeWikiPath(path);
289
- const entries = parseIndex();
290
- const filtered = entries.filter((e) => e.path !== normalizedPath);
291
- if (filtered.length === entries.length)
339
+ const existing = db.prepare(`SELECT 1 FROM wiki_pages WHERE path = ?`).get(normalizedPath);
340
+ if (!existing)
292
341
  return false;
293
- writeIndex(filtered);
342
+ removeWikiPage(normalizedPath);
294
343
  return true;
295
344
  }
296
345
  /**
297
346
  * Search the index for entries matching a query.
298
- * Matches against title, summary, section, path, and tags using keyword overlap.
299
- * Boosts recently updated pages as a tiebreaker.
300
- *
301
- * - Short tokens (>=2 chars) are kept so acronyms like "AI"/"UI"/"JS" work.
302
- * - Single-letter tokens are dropped to avoid noise.
303
- * - Tag/title exact matches and prefix matches get a strong score boost.
304
- * - Falls back to scanning page bodies when index search returns nothing.
347
+ * Delegates to wikiSearch and maps back to IndexEntry shape.
305
348
  */
306
349
  export function searchIndex(query, limit = 10) {
307
- const entries = parseIndex();
308
- if (entries.length === 0)
309
- return [];
310
- const queryWords = new Set(query.toLowerCase().split(/\s+/).filter((w) => w.length >= 2));
311
- if (queryWords.size === 0) {
312
- return entries.slice(0, limit);
313
- }
314
- const now = Date.now();
315
- const scored = entries.map((entry) => {
316
- const titleLc = entry.title.toLowerCase();
317
- const summaryLc = entry.summary.toLowerCase();
318
- const sectionLc = entry.section.toLowerCase();
319
- const pathLc = entry.path.toLowerCase();
320
- const tagSet = new Set((entry.tags || []).map((t) => t.toLowerCase()));
321
- let hits = 0;
322
- for (const q of queryWords) {
323
- // Strongest signals: exact tag or exact title
324
- if (tagSet.has(q)) {
325
- hits += 5;
326
- continue;
327
- }
328
- if (titleLc === q) {
329
- hits += 5;
330
- continue;
331
- }
332
- // Strong: title starts with token, or path basename equals token
333
- if (titleLc.startsWith(q)) {
334
- hits += 3;
335
- continue;
336
- }
337
- const base = pathLc.split("/").pop()?.replace(/\.md$/, "") || "";
338
- if (base === q) {
339
- hits += 3;
340
- continue;
341
- }
342
- // Medium: substring in title/summary/section
343
- if (titleLc.includes(q) || summaryLc.includes(q) || sectionLc.includes(q)) {
344
- hits += 2;
345
- continue;
346
- }
347
- // Weak: substring in path or any tag
348
- if (pathLc.includes(q)) {
349
- hits += 1;
350
- continue;
351
- }
352
- for (const tag of tagSet) {
353
- if (tag.includes(q)) {
354
- hits += 1;
355
- break;
356
- }
357
- }
358
- }
359
- let recencyBoost = 0;
360
- if (entry.updated) {
361
- const daysSince = (now - new Date(entry.updated).getTime()) / (1000 * 60 * 60 * 24);
362
- if (daysSince < 7)
363
- recencyBoost = 0.5;
364
- else if (daysSince < 30)
365
- recencyBoost = 0.2;
366
- }
367
- return { entry, score: hits + recencyBoost };
368
- })
369
- .filter((s) => s.score > 0)
370
- .sort((a, b) => b.score - a.score)
371
- .slice(0, limit);
372
- if (scored.length > 0) {
373
- return scored.map((s) => s.entry);
374
- }
375
- // Fallback: scan page bodies (bounded to avoid O(N*size) blowup).
376
- const MAX_BODY_SCAN = 50;
377
- const bodyHits = [];
378
- for (const entry of entries.slice(0, MAX_BODY_SCAN)) {
379
- const body = readPage(entry.path);
380
- if (!body)
381
- continue;
382
- const bodyLc = body.toLowerCase();
383
- let bodyScore = 0;
384
- for (const q of queryWords) {
385
- if (bodyLc.includes(q))
386
- bodyScore += 1;
387
- }
388
- if (bodyScore > 0)
389
- bodyHits.push({ entry, score: bodyScore });
390
- }
391
- bodyHits.sort((a, b) => b.score - a.score);
392
- return bodyHits.slice(0, limit).map((s) => s.entry);
350
+ const results = wikiSearch(query, limit);
351
+ return results.map((r) => ({
352
+ path: r.path,
353
+ title: r.title,
354
+ summary: r.summary || r.title,
355
+ section: categoryOfPath(r.path),
356
+ updated: undefined,
357
+ }));
358
+ }
359
+ /** Rebuild wiki_pages from disk. Delegates to rebuildWikiIndex. Returns entries. */
360
+ export function rebuildIndexFromPages() {
361
+ rebuildWikiIndex();
362
+ return parseIndex();
363
+ }
364
+ /** No-op: pages/index.md is no longer written. Kept for backward compat. */
365
+ export function writeIndex(_entries) {
366
+ // File-based index.md writes removed in Sprint 1 Track B (PKB Foundation).
367
+ // The SQLite wiki_pages table is the authoritative index.
393
368
  }
394
- /** Get a compact text summary of the index for injection into context. */
369
+ /** Get a compact text summary of the index for context injection. */
395
370
  export function getIndexSummary() {
396
371
  const entries = parseIndex();
397
372
  if (entries.length === 0)