chapterhouse 0.1.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 (119) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +363 -0
  3. package/agents/chapterhouse.agent.md +40 -0
  4. package/agents/coder.agent.md +38 -0
  5. package/agents/designer.agent.md +43 -0
  6. package/agents/general-purpose.agent.md +30 -0
  7. package/dist/api/auth.js +159 -0
  8. package/dist/api/auth.test.js +463 -0
  9. package/dist/api/errors.js +95 -0
  10. package/dist/api/errors.test.js +89 -0
  11. package/dist/api/rate-limit.js +85 -0
  12. package/dist/api/server-runtime.js +62 -0
  13. package/dist/api/server.js +651 -0
  14. package/dist/api/server.test.js +385 -0
  15. package/dist/api/sse.integration.test.js +270 -0
  16. package/dist/api/sse.js +7 -0
  17. package/dist/api/team.js +196 -0
  18. package/dist/api/team.test.js +466 -0
  19. package/dist/cli.js +102 -0
  20. package/dist/config.js +299 -0
  21. package/dist/config.phase3.test.js +20 -0
  22. package/dist/config.test.js +148 -0
  23. package/dist/copilot/agents.js +447 -0
  24. package/dist/copilot/agents.squad.test.js +72 -0
  25. package/dist/copilot/classifier.js +72 -0
  26. package/dist/copilot/client.js +32 -0
  27. package/dist/copilot/client.test.js +100 -0
  28. package/dist/copilot/episode-writer.js +219 -0
  29. package/dist/copilot/episode-writer.test.js +41 -0
  30. package/dist/copilot/mcp-config.js +22 -0
  31. package/dist/copilot/okr-mapper.js +196 -0
  32. package/dist/copilot/okr-mapper.test.js +114 -0
  33. package/dist/copilot/orchestrator.js +685 -0
  34. package/dist/copilot/orchestrator.test.js +523 -0
  35. package/dist/copilot/router.js +142 -0
  36. package/dist/copilot/router.test.js +119 -0
  37. package/dist/copilot/skills.js +125 -0
  38. package/dist/copilot/standup.js +138 -0
  39. package/dist/copilot/standup.test.js +132 -0
  40. package/dist/copilot/system-message.js +143 -0
  41. package/dist/copilot/system-message.test.js +17 -0
  42. package/dist/copilot/tools.js +1212 -0
  43. package/dist/copilot/tools.okr.test.js +260 -0
  44. package/dist/copilot/tools.squad.test.js +168 -0
  45. package/dist/daemon.js +235 -0
  46. package/dist/home-path.js +12 -0
  47. package/dist/home-path.test.js +11 -0
  48. package/dist/integrations/ado-analytics.js +178 -0
  49. package/dist/integrations/ado-analytics.test.js +284 -0
  50. package/dist/integrations/ado-client.js +227 -0
  51. package/dist/integrations/ado-client.test.js +176 -0
  52. package/dist/integrations/ado-schema.js +25 -0
  53. package/dist/integrations/ado-schema.test.js +39 -0
  54. package/dist/integrations/ado-skill.js +55 -0
  55. package/dist/integrations/report-generator.js +114 -0
  56. package/dist/integrations/report-generator.test.js +62 -0
  57. package/dist/integrations/team-push.js +144 -0
  58. package/dist/integrations/team-push.test.js +178 -0
  59. package/dist/integrations/teams-notify.js +108 -0
  60. package/dist/integrations/teams-notify.test.js +135 -0
  61. package/dist/paths.js +41 -0
  62. package/dist/setup.js +149 -0
  63. package/dist/shutdown-signals.js +13 -0
  64. package/dist/shutdown-signals.test.js +33 -0
  65. package/dist/squad/charter.js +108 -0
  66. package/dist/squad/charter.test.js +89 -0
  67. package/dist/squad/context.js +48 -0
  68. package/dist/squad/context.test.js +59 -0
  69. package/dist/squad/discovery.js +280 -0
  70. package/dist/squad/discovery.test.js +93 -0
  71. package/dist/squad/index.js +7 -0
  72. package/dist/squad/mirror.js +81 -0
  73. package/dist/squad/mirror.scheduler.js +78 -0
  74. package/dist/squad/mirror.scheduler.test.js +197 -0
  75. package/dist/squad/mirror.test.js +172 -0
  76. package/dist/squad/registry.js +162 -0
  77. package/dist/squad/registry.test.js +31 -0
  78. package/dist/squad/squad-coordinator-system-message.test.js +190 -0
  79. package/dist/squad/squad-session-routing.test.js +260 -0
  80. package/dist/squad/types.js +4 -0
  81. package/dist/status.js +25 -0
  82. package/dist/status.test.js +22 -0
  83. package/dist/store/db.js +290 -0
  84. package/dist/store/db.test.js +126 -0
  85. package/dist/store/squad-sessions.test.js +341 -0
  86. package/dist/test/setup-env.js +3 -0
  87. package/dist/update.js +112 -0
  88. package/dist/update.test.js +25 -0
  89. package/dist/wiki/context.js +138 -0
  90. package/dist/wiki/fs.js +195 -0
  91. package/dist/wiki/fs.test.js +39 -0
  92. package/dist/wiki/index-manager.js +359 -0
  93. package/dist/wiki/index-manager.test.js +129 -0
  94. package/dist/wiki/lock.js +26 -0
  95. package/dist/wiki/lock.test.js +30 -0
  96. package/dist/wiki/log-manager.js +20 -0
  97. package/dist/wiki/migrate.js +306 -0
  98. package/dist/wiki/okr.test.js +101 -0
  99. package/dist/wiki/path-utils.js +4 -0
  100. package/dist/wiki/path-utils.test.js +8 -0
  101. package/dist/wiki/seed-team-wiki.js +296 -0
  102. package/dist/wiki/seed-team-wiki.test.js +69 -0
  103. package/dist/wiki/team-sync.js +212 -0
  104. package/dist/wiki/team-sync.test.js +185 -0
  105. package/dist/wiki/templates/okr.js +98 -0
  106. package/package.json +72 -0
  107. package/skills/.gitkeep +0 -0
  108. package/skills/find-skills/SKILL.md +161 -0
  109. package/skills/find-skills/_meta.json +4 -0
  110. package/skills/frontend-design/LICENSE.txt +177 -0
  111. package/skills/frontend-design/SKILL.md +42 -0
  112. package/skills/squad/SKILL.md +76 -0
  113. package/web/dist/assets/index-D-e7K-fT.css +10 -0
  114. package/web/dist/assets/index-DAg9IrpO.js +142 -0
  115. package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
  116. package/web/dist/chapterhouse-icon.png +0 -0
  117. package/web/dist/chapterhouse-icon.svg +42 -0
  118. package/web/dist/chapterhouse-logo.svg +46 -0
  119. package/web/dist/index.html +15 -0
@@ -0,0 +1,195 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Wiki file system primitives
3
+ // ---------------------------------------------------------------------------
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, statSync, renameSync, openSync, fsyncSync, closeSync } from "fs";
5
+ import { join, dirname, relative, resolve, sep } from "path";
6
+ import { WIKI_DIR, WIKI_PAGES_DIR, WIKI_SOURCES_DIR } from "../paths.js";
7
+ import { normalizeWikiPath } from "./path-utils.js";
8
+ const INDEX_PATH = join(WIKI_DIR, "index.md");
9
+ const LOG_PATH = join(WIKI_DIR, "log.md");
10
+ /**
11
+ * Write a file atomically: write to a temp file in the same directory, fsync,
12
+ * then rename over the destination. Prevents partial writes on crash and
13
+ * gives readers an all-or-nothing view.
14
+ */
15
+ export function writeFileAtomic(fullPath, content) {
16
+ mkdirSync(dirname(fullPath), { recursive: true });
17
+ const tmp = `${fullPath}.${process.pid}.${Date.now()}.tmp`;
18
+ const fd = openSync(tmp, "w");
19
+ try {
20
+ writeFileSync(fd, content, "utf-8");
21
+ try {
22
+ fsyncSync(fd);
23
+ }
24
+ catch { /* fsync may fail on some FSes; non-fatal */ }
25
+ }
26
+ finally {
27
+ closeSync(fd);
28
+ }
29
+ renameSync(tmp, fullPath);
30
+ }
31
+ /** Throw if the given relative path is not safely under pages/. Used by mutation tools. */
32
+ export function assertPagePath(relativePath) {
33
+ if (!relativePath || typeof relativePath !== "string") {
34
+ throw new Error("Wiki path is required");
35
+ }
36
+ const normalizedPath = normalizeWikiPath(relativePath);
37
+ if (normalizedPath.includes("\0") || normalizedPath.includes("..")) {
38
+ throw new Error(`Refused unsafe wiki path: ${relativePath}`);
39
+ }
40
+ if (!normalizedPath.startsWith("pages/")) {
41
+ throw new Error(`Refused: only pages under pages/ may be modified by tools. Got: ${relativePath}`);
42
+ }
43
+ if (!normalizedPath.endsWith(".md")) {
44
+ throw new Error(`Wiki page paths must end in .md: ${relativePath}`);
45
+ }
46
+ // resolvePath also enforces the wiki-root containment check.
47
+ resolvePath(normalizedPath);
48
+ }
49
+ function getInitialIndex() {
50
+ return `# Wiki Index
51
+
52
+ _Max's knowledge base. This file is maintained automatically._
53
+
54
+ Last updated: ${new Date().toISOString().slice(0, 10)}
55
+
56
+ ## Pages
57
+
58
+ _(No pages yet.)_
59
+ `;
60
+ }
61
+ const INITIAL_LOG = `# Wiki Log
62
+
63
+ _Chronological record of wiki operations._
64
+
65
+ `;
66
+ /**
67
+ * Create the wiki directory structure if it doesn't exist.
68
+ * Returns true if the wiki was just created (first run).
69
+ */
70
+ export function ensureWikiStructure() {
71
+ const isNew = !existsSync(WIKI_DIR);
72
+ mkdirSync(WIKI_PAGES_DIR, { recursive: true });
73
+ mkdirSync(WIKI_SOURCES_DIR, { recursive: true });
74
+ if (!existsSync(INDEX_PATH)) {
75
+ writeFileAtomic(INDEX_PATH, getInitialIndex());
76
+ }
77
+ if (!existsSync(LOG_PATH)) {
78
+ writeFileAtomic(LOG_PATH, INITIAL_LOG);
79
+ }
80
+ return isNew;
81
+ }
82
+ /** Read a wiki page by path relative to the wiki root. Returns undefined if not found. */
83
+ export function readPage(relativePath) {
84
+ const fullPath = resolvePath(relativePath);
85
+ if (!existsSync(fullPath))
86
+ return undefined;
87
+ return readFileSync(fullPath, "utf-8");
88
+ }
89
+ /** Write a wiki page atomically. Creates parent directories automatically. */
90
+ export function writePage(relativePath, content) {
91
+ const fullPath = resolvePath(relativePath);
92
+ writeFileAtomic(fullPath, content);
93
+ }
94
+ /** Delete a wiki page. Returns true if the file existed and was removed. */
95
+ export function deletePage(relativePath) {
96
+ const fullPath = resolvePath(relativePath);
97
+ if (!existsSync(fullPath))
98
+ return false;
99
+ unlinkSync(fullPath);
100
+ return true;
101
+ }
102
+ /** Check if a wiki page exists. */
103
+ export function pageExists(relativePath) {
104
+ return existsSync(resolvePath(relativePath));
105
+ }
106
+ /** List all .md files under pages/, returning paths relative to the wiki root. */
107
+ export function listPages() {
108
+ if (!existsSync(WIKI_PAGES_DIR))
109
+ return [];
110
+ return walkDir(WIKI_PAGES_DIR)
111
+ .filter((f) => f.endsWith(".md"))
112
+ .map((f) => normalizeWikiPath(relative(WIKI_DIR, f)));
113
+ }
114
+ /** Save a raw source document (immutable). */
115
+ export function writeRawSource(name, content) {
116
+ const safeName = name.replace(/[^a-zA-Z0-9._/-]/g, "-").replace(/\.\.+/g, "-");
117
+ const fullPath = resolve(WIKI_SOURCES_DIR, safeName);
118
+ if (!fullPath.startsWith(WIKI_SOURCES_DIR + sep)) {
119
+ throw new Error(`Source path escapes sources dir: ${name}`);
120
+ }
121
+ writeFileAtomic(fullPath, content);
122
+ }
123
+ /** Read a raw source document. */
124
+ export function readRawSource(name) {
125
+ const safeName = name.replace(/[^a-zA-Z0-9._-]/g, "-");
126
+ const fullPath = join(WIKI_SOURCES_DIR, safeName);
127
+ if (!existsSync(fullPath))
128
+ return undefined;
129
+ return readFileSync(fullPath, "utf-8");
130
+ }
131
+ /** List all source files. */
132
+ export function listSources() {
133
+ if (!existsSync(WIKI_SOURCES_DIR))
134
+ return [];
135
+ return readdirSync(WIKI_SOURCES_DIR).filter((f) => {
136
+ const full = join(WIKI_SOURCES_DIR, f);
137
+ return statSync(full).isFile();
138
+ });
139
+ }
140
+ /** Read index.md raw content. */
141
+ export function readIndexFile() {
142
+ ensureWikiStructure();
143
+ return readFileSync(INDEX_PATH, "utf-8");
144
+ }
145
+ /** Write index.md content atomically. */
146
+ export function writeIndexFile(content) {
147
+ writeFileAtomic(INDEX_PATH, content);
148
+ }
149
+ /** Read log.md raw content. */
150
+ export function readLogFile() {
151
+ ensureWikiStructure();
152
+ return readFileSync(LOG_PATH, "utf-8");
153
+ }
154
+ /** Write log.md content atomically. */
155
+ export function writeLogFile(content) {
156
+ writeFileAtomic(LOG_PATH, content);
157
+ }
158
+ /** Get the full wiki directory path (for external tools that need it). */
159
+ export function getWikiDir() {
160
+ return WIKI_DIR;
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Internal helpers
164
+ // ---------------------------------------------------------------------------
165
+ function resolvePath(relativePath) {
166
+ const normalizedPath = normalizeWikiPath(relativePath);
167
+ let base;
168
+ if (normalizedPath.startsWith("pages/") || normalizedPath.startsWith("sources/") ||
169
+ normalizedPath === "index.md" || normalizedPath === "log.md") {
170
+ base = WIKI_DIR;
171
+ }
172
+ else {
173
+ base = WIKI_PAGES_DIR;
174
+ }
175
+ const resolved = resolve(base, normalizedPath);
176
+ // Prevent path traversal outside the wiki directory
177
+ if (!resolved.startsWith(WIKI_DIR + sep) && resolved !== WIKI_DIR) {
178
+ throw new Error(`Path escapes wiki directory: ${relativePath}`);
179
+ }
180
+ return resolved;
181
+ }
182
+ function walkDir(dir) {
183
+ const results = [];
184
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
185
+ const full = join(dir, entry.name);
186
+ if (entry.isDirectory()) {
187
+ results.push(...walkDir(full));
188
+ }
189
+ else {
190
+ results.push(full);
191
+ }
192
+ }
193
+ return results;
194
+ }
195
+ //# sourceMappingURL=fs.js.map
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `wiki-fs-${process.pid}`);
7
+ const actualHome = join(sandboxRoot, ".chapterhouse");
8
+ const wikiDir = join(actualHome, "wiki");
9
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
10
+ async function loadFsModule() {
11
+ return await import(new URL(`./fs.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
12
+ }
13
+ test.after(() => {
14
+ rmSync(sandboxRoot, { recursive: true, force: true });
15
+ });
16
+ test("wiki fs creates the wiki structure and supports page CRUD", async () => {
17
+ rmSync(sandboxRoot, { recursive: true, force: true });
18
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
19
+ const wiki = await loadFsModule();
20
+ assert.equal(wiki.ensureWikiStructure(), true);
21
+ assert.equal(wiki.ensureWikiStructure(), false);
22
+ assert.equal(existsSync(join(wikiDir, "index.md")), true);
23
+ assert.equal(existsSync(join(wikiDir, "log.md")), true);
24
+ wiki.writePage("pages/shared/runbooks/deploy.md", "# Deploy\n");
25
+ assert.equal(wiki.pageExists("pages/shared/runbooks/deploy.md"), true);
26
+ assert.equal(wiki.readPage("pages/shared/runbooks/deploy.md"), "# Deploy\n");
27
+ assert.deepEqual(wiki.listPages(), ["pages/shared/runbooks/deploy.md"]);
28
+ assert.equal(wiki.deletePage("pages/shared/runbooks/deploy.md"), true);
29
+ assert.equal(wiki.deletePage("pages/shared/runbooks/deploy.md"), false);
30
+ assert.equal(wiki.readPage("pages/shared/runbooks/deploy.md"), undefined);
31
+ });
32
+ test("wiki fs rejects unsafe page paths", async () => {
33
+ const wiki = await loadFsModule();
34
+ assert.doesNotThrow(() => wiki.assertPagePath("pages/shared/runbooks/deploy.md"));
35
+ assert.throws(() => wiki.assertPagePath("../secrets.md"), /unsafe wiki path|escapes wiki directory/);
36
+ assert.throws(() => wiki.assertPagePath("sources/raw.md"), /only pages under pages\//);
37
+ assert.throws(() => wiki.assertPagePath("pages/shared/runbooks/deploy.txt"), /must end in \.md/);
38
+ });
39
+ //# sourceMappingURL=fs.test.js.map
@@ -0,0 +1,359 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Wiki index.md manager — parse, update, and search the page catalog
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";
8
+ import { normalizeWikiPath } from "./path-utils.js";
9
+ const INDEX_PATH = join(WIKI_DIR, "index.md");
10
+ // mtime-based cache so per-message context injection doesn't re-parse on every turn.
11
+ let cache;
12
+ function invalidateCache() {
13
+ cache = undefined;
14
+ }
15
+ /**
16
+ * Parse index.md into structured entries.
17
+ * Expected format (new):
18
+ * ## Section Name
19
+ * - [Title](path) — Summary text | tags: tag1, tag2 | updated: 2026-04-17
20
+ * Also supports legacy format without tags/updated.
21
+ */
22
+ export function parseIndex() {
23
+ let mtimeMs = 0, size = 0;
24
+ if (existsSync(INDEX_PATH)) {
25
+ const st = statSync(INDEX_PATH);
26
+ mtimeMs = st.mtimeMs;
27
+ size = st.size;
28
+ if (cache && cache.mtimeMs === mtimeMs && cache.size === size) {
29
+ return cache.entries;
30
+ }
31
+ }
32
+ const content = readIndexFile();
33
+ const entries = [];
34
+ let currentSection = "Uncategorized";
35
+ for (const line of content.split("\n")) {
36
+ // Section headers
37
+ const sectionMatch = line.match(/^##\s+(.+)/);
38
+ if (sectionMatch) {
39
+ currentSection = sectionMatch[1].trim();
40
+ continue;
41
+ }
42
+ // Entry lines: - [Title](path) — Summary | tags: t1, t2 | updated: YYYY-MM-DD
43
+ const entryMatch = line.match(/^-\s+\[(.+?)\]\((.+?)\)\s*[—–-]\s*(.+)/);
44
+ if (entryMatch) {
45
+ const rawSummary = entryMatch[3].trim();
46
+ // Parse optional | tags: ... | updated: ... suffixes
47
+ let summary = rawSummary;
48
+ let tags = [];
49
+ let updated = "";
50
+ const tagsMatch = rawSummary.match(/\|\s*tags:\s*([^|]+)/);
51
+ if (tagsMatch) {
52
+ tags = tagsMatch[1].split(",").map((t) => t.trim()).filter(Boolean);
53
+ summary = summary.replace(tagsMatch[0], "").trim();
54
+ }
55
+ const updatedMatch = rawSummary.match(/\|\s*updated:\s*(\S+)/);
56
+ if (updatedMatch) {
57
+ updated = updatedMatch[1].trim();
58
+ summary = summary.replace(updatedMatch[0], "").trim();
59
+ }
60
+ // Clean trailing pipe if any
61
+ summary = summary.replace(/\|?\s*$/, "").trim();
62
+ entries.push({
63
+ title: entryMatch[1].trim(),
64
+ path: normalizeWikiPath(entryMatch[2].trim()),
65
+ summary,
66
+ section: currentSection,
67
+ tags: tags.length > 0 ? tags : undefined,
68
+ updated: updated || undefined,
69
+ });
70
+ }
71
+ }
72
+ // Self-heal: if index is empty/corrupted but pages exist on disk, rebuild from disk.
73
+ if (entries.length === 0) {
74
+ const pages = listPages();
75
+ if (pages.length > 0) {
76
+ const rebuilt = rebuildIndexFromPages();
77
+ cache = { mtimeMs, size, entries: rebuilt };
78
+ return rebuilt;
79
+ }
80
+ }
81
+ cache = { mtimeMs, size, entries };
82
+ return entries;
83
+ }
84
+ /** Parse YAML frontmatter (very simple — supports key: value and key: [a, b]). */
85
+ function parseFrontmatter(content) {
86
+ const m = content.match(/^---\s*\n([\s\S]*?)\n---/);
87
+ if (!m)
88
+ return {};
89
+ const out = {};
90
+ for (const line of m[1].split("\n")) {
91
+ const idx = line.indexOf(":");
92
+ if (idx <= 0)
93
+ continue;
94
+ const key = line.slice(0, idx).trim();
95
+ let value = line.slice(idx + 1).trim();
96
+ if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
97
+ value = value.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
98
+ }
99
+ else if (typeof value === "string") {
100
+ value = value.replace(/^['"]|['"]$/g, "");
101
+ }
102
+ out[key] = value;
103
+ }
104
+ return out;
105
+ }
106
+ /** Build (or refresh) an IndexEntry by reading the page from disk. */
107
+ export function buildIndexEntryForPage(path, fallback) {
108
+ const normalizedPath = normalizeWikiPath(path);
109
+ const content = readPage(normalizedPath);
110
+ if (!content)
111
+ return undefined;
112
+ const fm = parseFrontmatter(content);
113
+ const title = (typeof fm.title === "string" && fm.title) || fallback?.title || basenameTitle(normalizedPath);
114
+ const tags = Array.isArray(fm.tags) ? fm.tags : (fallback?.tags ?? []);
115
+ const updated = (typeof fm.updated === "string" && fm.updated) || fallback?.updated;
116
+ // Summary heuristic: existing summary if provided, else first non-frontmatter
117
+ // non-heading content line trimmed to 160 chars.
118
+ let summary = fallback?.summary?.trim() || "";
119
+ if (!summary) {
120
+ const body = content.replace(/^---[\s\S]*?---\s*/, "");
121
+ for (const raw of body.split("\n")) {
122
+ const line = raw.trim();
123
+ if (!line || line.startsWith("#"))
124
+ continue;
125
+ summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
126
+ break;
127
+ }
128
+ }
129
+ if (summary.length > 160)
130
+ summary = summary.slice(0, 157) + "…";
131
+ return {
132
+ path: normalizedPath,
133
+ title,
134
+ summary: summary || title,
135
+ section: fallback?.section || "Knowledge",
136
+ tags: tags.length ? tags : undefined,
137
+ updated,
138
+ };
139
+ }
140
+ function basenameTitle(path) {
141
+ const normalizedPath = normalizeWikiPath(path);
142
+ const file = normalizedPath.split("/").pop() || normalizedPath;
143
+ const base = file.replace(/\.md$/, "");
144
+ return base.split(/[-_]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
145
+ }
146
+ /** Rebuild every index entry from on-disk pages. Preserves section if known. */
147
+ export function rebuildIndexFromPages() {
148
+ const pages = listPages();
149
+ const previous = new Map();
150
+ // Try to keep section assignments by re-parsing the (possibly-corrupted) index without recursion.
151
+ try {
152
+ const raw = readIndexFile();
153
+ let section = "Knowledge";
154
+ for (const line of raw.split("\n")) {
155
+ const sm = line.match(/^##\s+(.+)/);
156
+ if (sm) {
157
+ section = sm[1].trim();
158
+ continue;
159
+ }
160
+ const em = line.match(/^-\s+\[.+?\]\((.+?)\)/);
161
+ if (em) {
162
+ const normalizedPath = normalizeWikiPath(em[1].trim());
163
+ previous.set(normalizedPath, { path: normalizedPath, title: "", summary: "", section });
164
+ }
165
+ }
166
+ }
167
+ catch { /* ignore */ }
168
+ const entries = [];
169
+ for (const p of pages) {
170
+ const entry = buildIndexEntryForPage(p, previous.get(p));
171
+ if (entry)
172
+ entries.push(entry);
173
+ }
174
+ // Write directly without recursion through addToIndex.
175
+ writeIndexInternal(entries);
176
+ invalidateCache();
177
+ return entries;
178
+ }
179
+ /** Regenerate index.md from a list of entries, grouped by section. */
180
+ export function writeIndex(entries) {
181
+ writeIndexInternal(entries);
182
+ invalidateCache();
183
+ }
184
+ function writeIndexInternal(entries) {
185
+ const sections = new Map();
186
+ for (const entry of entries) {
187
+ const list = sections.get(entry.section) || [];
188
+ list.push(entry);
189
+ sections.set(entry.section, list);
190
+ }
191
+ const lines = [
192
+ "# Wiki Index",
193
+ "",
194
+ "_Max's knowledge base. This file is maintained automatically._",
195
+ "",
196
+ `Last updated: ${new Date().toISOString().slice(0, 10)}`,
197
+ "",
198
+ ];
199
+ for (const [section, items] of sections) {
200
+ lines.push(`## ${section}`, "");
201
+ for (const item of items) {
202
+ let line = `- [${item.title}](${item.path}) — ${item.summary}`;
203
+ if (item.tags?.length)
204
+ line += ` | tags: ${item.tags.join(", ")}`;
205
+ if (item.updated)
206
+ line += ` | updated: ${item.updated}`;
207
+ lines.push(line);
208
+ }
209
+ lines.push("");
210
+ }
211
+ if (sections.size === 0) {
212
+ lines.push("## Pages", "", "_(No pages yet.)_", "");
213
+ }
214
+ writeIndexFile(lines.join("\n"));
215
+ }
216
+ /** Add or update an entry in the index. Upserts by path. */
217
+ export function addToIndex(entry) {
218
+ const normalizedEntry = { ...entry, path: normalizeWikiPath(entry.path) };
219
+ const entries = parseIndex();
220
+ const existing = entries.findIndex((e) => e.path === normalizedEntry.path);
221
+ if (existing >= 0) {
222
+ entries[existing] = normalizedEntry;
223
+ }
224
+ else {
225
+ entries.push(normalizedEntry);
226
+ }
227
+ writeIndex(entries);
228
+ }
229
+ /** Remove an entry from the index by path. */
230
+ export function removeFromIndex(path) {
231
+ const normalizedPath = normalizeWikiPath(path);
232
+ const entries = parseIndex();
233
+ const filtered = entries.filter((e) => e.path !== normalizedPath);
234
+ if (filtered.length === entries.length)
235
+ return false;
236
+ writeIndex(filtered);
237
+ return true;
238
+ }
239
+ /**
240
+ * Search the index for entries matching a query.
241
+ * Matches against title, summary, section, path, and tags using keyword overlap.
242
+ * Boosts recently updated pages as a tiebreaker.
243
+ *
244
+ * - Short tokens (>=2 chars) are kept so acronyms like "AI"/"UI"/"JS" work.
245
+ * - Single-letter tokens are dropped to avoid noise.
246
+ * - Tag/title exact matches and prefix matches get a strong score boost.
247
+ * - Falls back to scanning page bodies when index search returns nothing.
248
+ */
249
+ export function searchIndex(query, limit = 10) {
250
+ const entries = parseIndex();
251
+ if (entries.length === 0)
252
+ return [];
253
+ const queryWords = new Set(query.toLowerCase().split(/\s+/).filter((w) => w.length >= 2));
254
+ if (queryWords.size === 0) {
255
+ return entries.slice(0, limit);
256
+ }
257
+ const now = Date.now();
258
+ const scored = entries.map((entry) => {
259
+ const titleLc = entry.title.toLowerCase();
260
+ const summaryLc = entry.summary.toLowerCase();
261
+ const sectionLc = entry.section.toLowerCase();
262
+ const pathLc = entry.path.toLowerCase();
263
+ const tagSet = new Set((entry.tags || []).map((t) => t.toLowerCase()));
264
+ let hits = 0;
265
+ for (const q of queryWords) {
266
+ // Strongest signals: exact tag or exact title
267
+ if (tagSet.has(q)) {
268
+ hits += 5;
269
+ continue;
270
+ }
271
+ if (titleLc === q) {
272
+ hits += 5;
273
+ continue;
274
+ }
275
+ // Strong: title starts with token, or path basename equals token
276
+ if (titleLc.startsWith(q)) {
277
+ hits += 3;
278
+ continue;
279
+ }
280
+ const base = pathLc.split("/").pop()?.replace(/\.md$/, "") || "";
281
+ if (base === q) {
282
+ hits += 3;
283
+ continue;
284
+ }
285
+ // Medium: substring in title/summary/section
286
+ if (titleLc.includes(q) || summaryLc.includes(q) || sectionLc.includes(q)) {
287
+ hits += 2;
288
+ continue;
289
+ }
290
+ // Weak: substring in path or any tag
291
+ if (pathLc.includes(q)) {
292
+ hits += 1;
293
+ continue;
294
+ }
295
+ for (const tag of tagSet) {
296
+ if (tag.includes(q)) {
297
+ hits += 1;
298
+ break;
299
+ }
300
+ }
301
+ }
302
+ let recencyBoost = 0;
303
+ if (entry.updated) {
304
+ const daysSince = (now - new Date(entry.updated).getTime()) / (1000 * 60 * 60 * 24);
305
+ if (daysSince < 7)
306
+ recencyBoost = 0.5;
307
+ else if (daysSince < 30)
308
+ recencyBoost = 0.2;
309
+ }
310
+ return { entry, score: hits + recencyBoost };
311
+ })
312
+ .filter((s) => s.score > 0)
313
+ .sort((a, b) => b.score - a.score)
314
+ .slice(0, limit);
315
+ if (scored.length > 0) {
316
+ return scored.map((s) => s.entry);
317
+ }
318
+ // Fallback: scan page bodies (bounded to avoid O(N*size) blowup).
319
+ const MAX_BODY_SCAN = 50;
320
+ const bodyHits = [];
321
+ for (const entry of entries.slice(0, MAX_BODY_SCAN)) {
322
+ const body = readPage(entry.path);
323
+ if (!body)
324
+ continue;
325
+ const bodyLc = body.toLowerCase();
326
+ let bodyScore = 0;
327
+ for (const q of queryWords) {
328
+ if (bodyLc.includes(q))
329
+ bodyScore += 1;
330
+ }
331
+ if (bodyScore > 0)
332
+ bodyHits.push({ entry, score: bodyScore });
333
+ }
334
+ bodyHits.sort((a, b) => b.score - a.score);
335
+ return bodyHits.slice(0, limit).map((s) => s.entry);
336
+ }
337
+ /** Get a compact text summary of the index for injection into context. */
338
+ export function getIndexSummary() {
339
+ const entries = parseIndex();
340
+ if (entries.length === 0)
341
+ return "";
342
+ const sections = new Map();
343
+ for (const e of entries) {
344
+ const list = sections.get(e.section) || [];
345
+ let item = `${e.title}: ${e.summary}`;
346
+ if (e.tags?.length)
347
+ item += ` [${e.tags.join(", ")}]`;
348
+ if (e.updated)
349
+ item += ` (${e.updated})`;
350
+ list.push(item);
351
+ sections.set(e.section, list);
352
+ }
353
+ const parts = [];
354
+ for (const [section, items] of sections) {
355
+ parts.push(`**${section}**: ${items.join("; ")}`);
356
+ }
357
+ return parts.join("\n");
358
+ }
359
+ //# sourceMappingURL=index-manager.js.map