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.
- package/agents/korg.agent.md +65 -0
- package/dist/api/korg.js +34 -0
- package/dist/api/korg.test.js +42 -0
- package/dist/api/server.js +238 -2
- package/dist/api/server.test.js +199 -0
- package/dist/config.js +28 -0
- package/dist/config.test.js +20 -0
- package/dist/copilot/agents.js +3 -4
- package/dist/copilot/agents.test.js +12 -1
- package/dist/copilot/orchestrator.js +12 -1
- package/dist/copilot/orchestrator.test.js +3 -7
- package/dist/copilot/system-message.js +12 -10
- package/dist/copilot/system-message.test.js +6 -1
- package/dist/copilot/tools.js +193 -375
- package/dist/copilot/tools.memory.test.js +32 -0
- package/dist/copilot/tools.wiki.test.js +80 -59
- package/dist/copilot/turn-event-log-env.test.js +11 -15
- package/dist/daemon.js +19 -0
- package/dist/memory/decisions.js +6 -5
- package/dist/memory/entities.js +20 -9
- package/dist/memory/eot.js +30 -8
- package/dist/memory/eot.test.js +220 -6
- package/dist/memory/hooks.js +151 -0
- package/dist/memory/hooks.test.js +325 -0
- package/dist/memory/hot-tier.js +37 -0
- package/dist/memory/hot-tier.test.js +30 -0
- package/dist/memory/housekeeping-scheduler.js +35 -0
- package/dist/memory/housekeeping-scheduler.test.js +50 -0
- package/dist/memory/inbox.js +10 -0
- package/dist/memory/index.js +3 -1
- package/dist/memory/migration.js +244 -0
- package/dist/memory/migration.test.js +108 -0
- package/dist/memory/reflect.js +273 -0
- package/dist/memory/reflect.test.js +254 -0
- package/dist/paths.js +31 -11
- package/dist/store/db.js +187 -4
- package/dist/store/db.test.js +66 -2
- package/dist/test/helpers/reset-singletons.js +8 -0
- package/dist/test/helpers/reset-singletons.test.js +37 -0
- package/dist/test/setup-env.js +9 -1
- package/dist/wiki/consolidation.js +641 -0
- package/dist/wiki/consolidation.test.js +143 -0
- package/dist/wiki/frontmatter.js +48 -0
- package/dist/wiki/frontmatter.test.js +42 -0
- package/dist/wiki/fs.js +22 -13
- package/dist/wiki/index-manager.js +305 -330
- package/dist/wiki/index-manager.test.js +265 -144
- package/dist/wiki/ingest.js +347 -0
- package/dist/wiki/ingest.test.js +111 -0
- package/dist/wiki/links.js +151 -0
- package/dist/wiki/links.test.js +176 -0
- package/dist/wiki/log-manager.js +8 -5
- package/dist/wiki/log-manager.test.js +4 -0
- package/dist/wiki/migrate-topics.test.js +16 -6
- package/dist/wiki/scheduler.js +118 -0
- package/dist/wiki/scheduler.test.js +64 -0
- package/dist/wiki/timeline.js +51 -0
- package/dist/wiki/timeline.test.js +65 -0
- package/dist/wiki/topic-structure.js +1 -1
- package/package.json +1 -1
- package/skills/pkb-ideas/SKILL.md +78 -0
- package/skills/pkb-ideas/_meta.json +4 -0
- package/skills/pkb-org/SKILL.md +82 -0
- package/skills/pkb-org/_meta.json +4 -0
- package/skills/pkb-people/SKILL.md +74 -0
- package/skills/pkb-people/_meta.json +4 -0
- package/skills/pkb-research/SKILL.md +83 -0
- package/skills/pkb-research/_meta.json +4 -0
- package/skills/pkb-source/SKILL.md +38 -0
- package/skills/pkb-source/_meta.json +4 -0
- package/skills/wiki-conventions/SKILL.md +5 -5
- package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
- package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
- package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/dist/wiki/context.js +0 -138
- package/dist/wiki/fix.js +0 -335
- package/dist/wiki/fix.test.js +0 -350
- package/dist/wiki/lint.js +0 -451
- package/dist/wiki/lint.test.js +0 -329
- package/web/dist/assets/index-DytB69KC.js.map +0 -1
|
@@ -1,90 +1,266 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Wiki index
|
|
2
|
+
// Wiki index manager — SQLite FTS5-backed wiki page catalog
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
function
|
|
16
|
-
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
77
|
-
if (
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
lines.push(renderEntryLine(entry));
|
|
261
|
-
}
|
|
311
|
+
catch {
|
|
312
|
+
tags = undefined;
|
|
262
313
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
324
|
+
/** Add or update a page in the index. Delegates to upsertWikiPage. */
|
|
274
325
|
export function addToIndex(entry) {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
290
|
-
|
|
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
|
-
|
|
342
|
+
removeWikiPage(normalizedPath);
|
|
294
343
|
return true;
|
|
295
344
|
}
|
|
296
345
|
/**
|
|
297
346
|
* Search the index for entries matching a query.
|
|
298
|
-
*
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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)
|