chapterhouse 0.6.0 → 0.8.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.
Files changed (80) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/agent-edit-access.js +11 -0
  3. package/dist/api/agents.api.test.js +48 -0
  4. package/dist/api/korg.js +34 -0
  5. package/dist/api/korg.test.js +42 -0
  6. package/dist/api/server.js +420 -13
  7. package/dist/api/server.test.js +533 -3
  8. package/dist/config.js +28 -0
  9. package/dist/config.test.js +20 -0
  10. package/dist/copilot/agent-event-bus.js +1 -0
  11. package/dist/copilot/agents.js +117 -50
  12. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  13. package/dist/copilot/agents.parse.test.js +69 -0
  14. package/dist/copilot/agents.test.js +137 -2
  15. package/dist/copilot/orchestrator.js +62 -13
  16. package/dist/copilot/orchestrator.test.js +130 -8
  17. package/dist/copilot/session-manager.js +34 -0
  18. package/dist/copilot/system-message.js +11 -10
  19. package/dist/copilot/system-message.test.js +6 -1
  20. package/dist/copilot/tools.js +184 -376
  21. package/dist/copilot/tools.memory.test.js +32 -0
  22. package/dist/copilot/tools.wiki.test.js +53 -59
  23. package/dist/daemon.js +9 -0
  24. package/dist/memory/decisions.js +6 -5
  25. package/dist/memory/entities.js +20 -9
  26. package/dist/memory/hooks.js +151 -0
  27. package/dist/memory/hooks.test.js +325 -0
  28. package/dist/memory/hot-tier.js +37 -0
  29. package/dist/memory/hot-tier.test.js +30 -0
  30. package/dist/memory/housekeeping-scheduler.js +35 -0
  31. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  32. package/dist/memory/inbox.js +10 -0
  33. package/dist/memory/index.js +3 -1
  34. package/dist/memory/migration.js +244 -0
  35. package/dist/memory/migration.test.js +100 -0
  36. package/dist/memory/reflect.js +273 -0
  37. package/dist/memory/reflect.test.js +254 -0
  38. package/dist/store/db.js +119 -4
  39. package/dist/store/db.test.js +19 -1
  40. package/dist/test/setup-env.js +3 -1
  41. package/dist/test/setup-env.test.js +8 -1
  42. package/dist/wiki/consolidation.js +641 -0
  43. package/dist/wiki/consolidation.test.js +140 -0
  44. package/dist/wiki/frontmatter.js +48 -0
  45. package/dist/wiki/frontmatter.test.js +42 -0
  46. package/dist/wiki/index-manager.js +246 -330
  47. package/dist/wiki/index-manager.test.js +138 -145
  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/migrate-topics.test.js +16 -6
  53. package/dist/wiki/scheduler.js +118 -0
  54. package/dist/wiki/scheduler.test.js +64 -0
  55. package/dist/wiki/timeline.js +51 -0
  56. package/dist/wiki/timeline.test.js +65 -0
  57. package/dist/wiki/topic-structure.js +1 -1
  58. package/package.json +3 -1
  59. package/skills/pkb-ideas/SKILL.md +78 -0
  60. package/skills/pkb-ideas/_meta.json +4 -0
  61. package/skills/pkb-org/SKILL.md +82 -0
  62. package/skills/pkb-org/_meta.json +4 -0
  63. package/skills/pkb-people/SKILL.md +74 -0
  64. package/skills/pkb-people/_meta.json +4 -0
  65. package/skills/pkb-research/SKILL.md +83 -0
  66. package/skills/pkb-research/_meta.json +4 -0
  67. package/skills/pkb-source/SKILL.md +38 -0
  68. package/skills/pkb-source/_meta.json +4 -0
  69. package/skills/wiki-conventions/SKILL.md +5 -5
  70. package/web/dist/assets/index-5kz9aRU9.css +10 -0
  71. package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
  72. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  73. package/web/dist/index.html +2 -2
  74. package/dist/wiki/context.js +0 -138
  75. package/dist/wiki/fix.js +0 -335
  76. package/dist/wiki/fix.test.js +0 -350
  77. package/dist/wiki/lint.js +0 -451
  78. package/dist/wiki/lint.test.js +0 -329
  79. package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
  80. package/web/dist/assets/index-DknKAtDS.css +0 -10
@@ -1,90 +1,207 @@
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 { listPages, readPage } from "./fs.js";
8
6
  import { parseWikiFrontmatter } from "./frontmatter.js";
9
7
  import { normalizeWikiPath } from "./path-utils.js";
10
- import { entityCategories, FLAT_CATEGORIES } from "./topic-structure.js";
11
- const INDEX_PATH = join(WIKI_DIR, "index.md");
8
+ import { updateLinks } from "./links.js";
12
9
  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;
10
+ const LEGACY_INDEX_PAGE = "pages/index.md";
11
+ function isIgnoredIndexPage(path) {
12
+ return path === LEGACY_INDEX_PAGE || ACTION_LOG_PAGE_RE.test(path);
17
13
  }
14
+ function categoryOfPath(path) {
15
+ const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
16
+ const segs = rest.split("/").filter(Boolean);
17
+ if (segs.length <= 1)
18
+ return (segs[0] || "pages").replace(/\.md$/i, "");
19
+ return segs[0];
20
+ }
21
+ function basenameTitle(path) {
22
+ const normalizedPath = normalizeWikiPath(path);
23
+ const segs = normalizedPath.split("/").filter(Boolean);
24
+ const file = segs[segs.length - 1] || normalizedPath;
25
+ const base = file.replace(/\.md$/, "");
26
+ const titleBase = base === "index" && segs.length >= 2 ? segs[segs.length - 2] : base;
27
+ return titleBase.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
28
+ }
29
+ function quoteFts5Terms(query) {
30
+ return query
31
+ .trim()
32
+ .split(/\s+/)
33
+ .filter((term) => term.length > 0)
34
+ .map((term) => {
35
+ const unquoted = term.replace(/^["']|["']$/g, "");
36
+ return `"${unquoted.replace(/"/g, '""')}"`;
37
+ })
38
+ .join(" ");
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // New SQLite-backed API
42
+ // ---------------------------------------------------------------------------
18
43
  /**
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.
44
+ * Upsert a single wiki page into wiki_pages (and FTS via trigger).
45
+ * entity_type is read from frontmatter.metadata.entity_type if present.
24
46
  */
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;
47
+ export function upsertWikiPage(path, frontmatter, summary) {
48
+ const db = getDb();
49
+ const normalizedPath = normalizeWikiPath(path);
50
+ const title = frontmatter.title ?? basenameTitle(normalizedPath);
51
+ const entityType = frontmatter.metadata?.["entity_type"] ?? null;
52
+ const tags = JSON.stringify(frontmatter.tags ?? []);
53
+ const lastUpdated = frontmatter.updated ?? new Date().toISOString();
54
+ db.prepare(`
55
+ INSERT INTO wiki_pages (path, title, entity_type, tags, summary, last_updated)
56
+ VALUES (?, ?, ?, ?, ?, ?)
57
+ ON CONFLICT(path) DO UPDATE SET
58
+ title = excluded.title,
59
+ entity_type = excluded.entity_type,
60
+ tags = excluded.tags,
61
+ summary = excluded.summary,
62
+ last_updated = excluded.last_updated,
63
+ version = wiki_pages.version + 1
64
+ `).run(normalizedPath, title, entityType, tags, summary || null, lastUpdated);
65
+ }
66
+ /** Remove a page from wiki_pages (and FTS via trigger). */
67
+ export function removeWikiPage(path) {
68
+ const db = getDb();
69
+ db.prepare(`DELETE FROM wiki_pages WHERE path = ?`).run(normalizeWikiPath(path));
70
+ }
71
+ /**
72
+ * Rebuild the wiki_pages SQLite index from the filesystem.
73
+ * Filesystem is the source of truth: insert missing pages, remove stale entries.
74
+ */
75
+ export function rebuildWikiIndex() {
76
+ const pages = listPages().filter((p) => !isIgnoredIndexPage(p));
77
+ const db = getDb();
78
+ const onDisk = new Set(pages.map(normalizeWikiPath));
79
+ db.transaction(() => {
80
+ // Remove DB entries not on disk; also clean their wiki_links
81
+ const inDb = db.prepare(`SELECT path FROM wiki_pages`).all();
82
+ for (const { path } of inDb) {
83
+ if (!onDisk.has(path)) {
84
+ db.prepare(`DELETE FROM wiki_pages WHERE path = ?`).run(path);
85
+ db.prepare(`DELETE FROM wiki_links WHERE from_page = ? OR to_page = ?`).run(path, path);
86
+ }
33
87
  }
88
+ // Upsert all on-disk pages
89
+ for (const p of pages) {
90
+ const content = readPage(p);
91
+ if (!content)
92
+ continue;
93
+ const { parsed: fm, body } = parseWikiFrontmatter(content);
94
+ let summary = fm.summary?.trim() ?? "";
95
+ if (!summary) {
96
+ for (const raw of body.split("\n")) {
97
+ const line = raw.trim();
98
+ if (!line || line.startsWith("#") || line.startsWith("<!--"))
99
+ continue;
100
+ summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
101
+ if (!summary)
102
+ continue;
103
+ break;
104
+ }
105
+ }
106
+ if (summary.length > 160)
107
+ summary = summary.slice(0, 157) + "…";
108
+ upsertWikiPage(p, fm, summary);
109
+ }
110
+ })();
111
+ // Backfill wiki_links for all on-disk pages (outside transaction — each call is self-contained)
112
+ for (const p of pages) {
113
+ updateLinks(p);
34
114
  }
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();
43
- continue;
115
+ }
116
+ /**
117
+ * Search wiki pages using FTS5 BM25 ranking.
118
+ * Falls back to LIKE query if FTS5 unavailable.
119
+ * Empty query returns most-recently-updated pages.
120
+ * Each direct result includes 1-hop neighbors from wiki_links in `related`.
121
+ * Neighbor pages not already in direct results are appended (deduplicated).
122
+ */
123
+ export function wikiSearch(query, limit = 20) {
124
+ const db = getDb();
125
+ const trimmed = query.trim();
126
+ let directRows;
127
+ if (!trimmed) {
128
+ directRows = db.prepare(`
129
+ SELECT path, title, entity_type, summary, 0 as rank
130
+ FROM wiki_pages
131
+ ORDER BY last_updated DESC
132
+ LIMIT ?
133
+ `).all(limit);
134
+ }
135
+ else if (isFts5Available()) {
136
+ const ftsQuery = quoteFts5Terms(trimmed);
137
+ try {
138
+ directRows = db.prepare(`
139
+ SELECT w.path, w.title, w.entity_type, w.summary, bm25(wiki_pages_fts) as rank
140
+ FROM wiki_pages_fts
141
+ JOIN wiki_pages w ON wiki_pages_fts.rowid = w.rowid
142
+ WHERE wiki_pages_fts MATCH ?
143
+ ORDER BY bm25(wiki_pages_fts)
144
+ LIMIT ?
145
+ `).all(ftsQuery, limit);
44
146
  }
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();
147
+ catch {
148
+ directRows = [];
149
+ }
150
+ }
151
+ else {
152
+ directRows = [];
153
+ }
154
+ // LIKE fallback when no FTS5 results
155
+ if (directRows.length === 0 && trimmed) {
156
+ const pattern = `%${trimmed}%`;
157
+ directRows = db.prepare(`
158
+ SELECT path, title, entity_type, summary, 0 as rank
159
+ FROM wiki_pages
160
+ WHERE title LIKE ? OR summary LIKE ? OR tags LIKE ?
161
+ ORDER BY last_updated DESC
162
+ LIMIT ?
163
+ `).all(pattern, pattern, pattern, limit);
164
+ }
165
+ // Enrich direct results with 1-hop neighbors and collect neighbor paths
166
+ const seenPaths = new Set(directRows.map((r) => r.path));
167
+ const neighborPaths = new Map();
168
+ for (const row of directRows) {
169
+ const outbound = db.prepare(`SELECT to_page, link_type FROM wiki_links WHERE from_page = ?`).all(row.path);
170
+ const inbound = db.prepare(`SELECT from_page, link_type FROM wiki_links WHERE to_page = ?`).all(row.path);
171
+ const related = [
172
+ ...outbound.map((r) => ({ page: r.to_page, link_type: r.link_type, direction: "outbound" })),
173
+ ...inbound.map((r) => ({ page: r.from_page, link_type: r.link_type, direction: "inbound" })),
174
+ ];
175
+ row.related = related;
176
+ for (const rel of related) {
177
+ if (!seenPaths.has(rel.page)) {
178
+ seenPaths.add(rel.page);
179
+ if (!neighborPaths.has(rel.page)) {
180
+ neighborPaths.set(rel.page, []);
181
+ }
182
+ neighborPaths.get(rel.page).push({ page: row.path, link_type: rel.link_type, direction: rel.direction === "outbound" ? "inbound" : "outbound" });
63
183
  }
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
184
  }
75
185
  }
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;
186
+ // Append neighbor pages as extra results (ranked after direct matches)
187
+ if (neighborPaths.size > 0) {
188
+ const neighborPathList = [...neighborPaths.keys()];
189
+ const placeholders = neighborPathList.map(() => "?").join(",");
190
+ const neighborRows = db.prepare(`
191
+ SELECT path, title, entity_type, summary, 0 as rank
192
+ FROM wiki_pages
193
+ WHERE path IN (${placeholders})
194
+ `).all(...neighborPathList);
195
+ for (const row of neighborRows) {
196
+ row.related = neighborPaths.get(row.path) ?? [];
197
+ directRows.push(row);
83
198
  }
84
199
  }
85
- cache = { mtimeMs, size, entries };
86
- return entries;
200
+ return directRows;
87
201
  }
202
+ // ---------------------------------------------------------------------------
203
+ // Backward-compatible API (delegates to SQLite)
204
+ // ---------------------------------------------------------------------------
88
205
  /** Build (or refresh) an IndexEntry by reading the page from disk. */
89
206
  export function buildIndexEntryForPage(path, fallback) {
90
207
  const normalizedPath = normalizeWikiPath(path);
@@ -95,8 +212,6 @@ export function buildIndexEntryForPage(path, fallback) {
95
212
  const title = fm.title || fallback?.title || basenameTitle(normalizedPath);
96
213
  const tags = fm.tags ?? fallback?.tags ?? [];
97
214
  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
215
  let summary = fm.summary?.trim() || fallback?.summary?.trim() || "";
101
216
  if (!summary) {
102
217
  for (const raw of body.split("\n")) {
@@ -115,283 +230,84 @@ export function buildIndexEntryForPage(path, fallback) {
115
230
  path: normalizedPath,
116
231
  title,
117
232
  summary: summary || title,
118
- section: fallback?.section || "Knowledge",
233
+ section: fallback?.section || categoryOfPath(normalizedPath) || "Knowledge",
119
234
  tags: tags.length ? tags : undefined,
120
235
  updated,
121
236
  };
122
237
  }
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
- }
238
+ /** Parse the wiki_pages DB into IndexEntry array (replaces reading index.md). */
239
+ export function parseIndex() {
240
+ const db = getDb();
241
+ const rows = db.prepare(`
242
+ SELECT path, title, tags, summary, last_updated
243
+ FROM wiki_pages
244
+ ORDER BY path
245
+ `).all();
246
+ return rows.map((row) => {
247
+ let tags;
248
+ try {
249
+ const parsed = JSON.parse(row.tags || "[]");
250
+ tags = Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
257
251
  }
258
- else {
259
- for (const entry of [...items].sort((a, b) => a.path.localeCompare(b.path))) {
260
- lines.push(renderEntryLine(entry));
261
- }
252
+ catch {
253
+ tags = undefined;
262
254
  }
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);
255
+ return {
256
+ path: row.path,
257
+ title: row.title,
258
+ summary: row.summary || row.title,
259
+ section: categoryOfPath(row.path),
260
+ tags,
261
+ updated: row.last_updated?.slice(0, 10) || undefined,
262
+ };
263
+ });
272
264
  }
273
- /** Add or update an entry in the index. Upserts by path. */
265
+ /** Add or update a page in the index. Delegates to upsertWikiPage. */
274
266
  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);
267
+ const fm = {
268
+ title: entry.title,
269
+ summary: entry.summary,
270
+ updated: entry.updated,
271
+ tags: entry.tags ?? [],
272
+ metadata: {},
273
+ };
274
+ upsertWikiPage(entry.path, fm, entry.summary);
285
275
  }
286
- /** Remove an entry from the index by path. */
276
+ /** Remove an entry from the index by path. Returns true if found. */
287
277
  export function removeFromIndex(path) {
278
+ const db = getDb();
288
279
  const normalizedPath = normalizeWikiPath(path);
289
- const entries = parseIndex();
290
- const filtered = entries.filter((e) => e.path !== normalizedPath);
291
- if (filtered.length === entries.length)
280
+ const existing = db.prepare(`SELECT 1 FROM wiki_pages WHERE path = ?`).get(normalizedPath);
281
+ if (!existing)
292
282
  return false;
293
- writeIndex(filtered);
283
+ removeWikiPage(normalizedPath);
294
284
  return true;
295
285
  }
296
286
  /**
297
287
  * 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.
288
+ * Delegates to wikiSearch and maps back to IndexEntry shape.
305
289
  */
306
290
  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);
291
+ const results = wikiSearch(query, limit);
292
+ return results.map((r) => ({
293
+ path: r.path,
294
+ title: r.title,
295
+ summary: r.summary || r.title,
296
+ section: categoryOfPath(r.path),
297
+ updated: undefined,
298
+ }));
299
+ }
300
+ /** Rebuild wiki_pages from disk. Delegates to rebuildWikiIndex. Returns entries. */
301
+ export function rebuildIndexFromPages() {
302
+ rebuildWikiIndex();
303
+ return parseIndex();
304
+ }
305
+ /** No-op: pages/index.md is no longer written. Kept for backward compat. */
306
+ export function writeIndex(_entries) {
307
+ // File-based index.md writes removed in Sprint 1 Track B (PKB Foundation).
308
+ // The SQLite wiki_pages table is the authoritative index.
393
309
  }
394
- /** Get a compact text summary of the index for injection into context. */
310
+ /** Get a compact text summary of the index for context injection. */
395
311
  export function getIndexSummary() {
396
312
  const entries = parseIndex();
397
313
  if (entries.length === 0)