chapterhouse 0.7.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 (72) 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 +11 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +184 -376
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +53 -59
  17. package/dist/daemon.js +9 -0
  18. package/dist/memory/decisions.js +6 -5
  19. package/dist/memory/entities.js +20 -9
  20. package/dist/memory/hooks.js +151 -0
  21. package/dist/memory/hooks.test.js +325 -0
  22. package/dist/memory/hot-tier.js +37 -0
  23. package/dist/memory/hot-tier.test.js +30 -0
  24. package/dist/memory/housekeeping-scheduler.js +35 -0
  25. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  26. package/dist/memory/inbox.js +10 -0
  27. package/dist/memory/index.js +3 -1
  28. package/dist/memory/migration.js +244 -0
  29. package/dist/memory/migration.test.js +100 -0
  30. package/dist/memory/reflect.js +273 -0
  31. package/dist/memory/reflect.test.js +254 -0
  32. package/dist/store/db.js +119 -4
  33. package/dist/store/db.test.js +19 -1
  34. package/dist/test/setup-env.js +1 -0
  35. package/dist/wiki/consolidation.js +641 -0
  36. package/dist/wiki/consolidation.test.js +140 -0
  37. package/dist/wiki/frontmatter.js +48 -0
  38. package/dist/wiki/frontmatter.test.js +42 -0
  39. package/dist/wiki/index-manager.js +246 -330
  40. package/dist/wiki/index-manager.test.js +138 -145
  41. package/dist/wiki/ingest.js +347 -0
  42. package/dist/wiki/ingest.test.js +111 -0
  43. package/dist/wiki/links.js +151 -0
  44. package/dist/wiki/links.test.js +176 -0
  45. package/dist/wiki/migrate-topics.test.js +16 -6
  46. package/dist/wiki/scheduler.js +118 -0
  47. package/dist/wiki/scheduler.test.js +64 -0
  48. package/dist/wiki/timeline.js +51 -0
  49. package/dist/wiki/timeline.test.js +65 -0
  50. package/dist/wiki/topic-structure.js +1 -1
  51. package/package.json +1 -1
  52. package/skills/pkb-ideas/SKILL.md +78 -0
  53. package/skills/pkb-ideas/_meta.json +4 -0
  54. package/skills/pkb-org/SKILL.md +82 -0
  55. package/skills/pkb-org/_meta.json +4 -0
  56. package/skills/pkb-people/SKILL.md +74 -0
  57. package/skills/pkb-people/_meta.json +4 -0
  58. package/skills/pkb-research/SKILL.md +83 -0
  59. package/skills/pkb-research/_meta.json +4 -0
  60. package/skills/pkb-source/SKILL.md +38 -0
  61. package/skills/pkb-source/_meta.json +4 -0
  62. package/skills/wiki-conventions/SKILL.md +5 -5
  63. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  64. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  65. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  66. package/web/dist/index.html +2 -2
  67. package/dist/wiki/context.js +0 -138
  68. package/dist/wiki/fix.js +0 -335
  69. package/dist/wiki/fix.test.js +0 -350
  70. package/dist/wiki/lint.js +0 -451
  71. package/dist/wiki/lint.test.js +0 -329
  72. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -555,4 +555,36 @@ test("memory_promote and memory_demote are orchestrator-only manual tier control
555
555
  assert.equal(demoted.ok, true);
556
556
  assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "cold");
557
557
  });
558
+ test("memory_remember dual-writes decisions to the wiki timeline when an entity is provided", async () => {
559
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
560
+ const tools = toolsModule.createTools({
561
+ client: { async listModels() { return []; } },
562
+ onAgentTaskComplete: () => { },
563
+ });
564
+ const wikiFs = await import(new URL("../wiki/fs.js", import.meta.url).href);
565
+ wikiFs.ensureWikiStructure();
566
+ wikiFs.writePage("pages/projects/project-x/index.md", "---\ntitle: Project X\nsummary: Project X summary\nupdated: 2026-05-14\ntags: []\n---\n\n# Project X\n\n## Summary\n\nCurrent state.\n");
567
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
568
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
569
+ const memoryRemember = findTool(chapterhouseTools, "memory_remember");
570
+ const result = await memoryRemember.handler({
571
+ scope: "chapterhouse",
572
+ kind: "decision",
573
+ entity_name: "project-x",
574
+ entity_kind: "project",
575
+ title: "Ship append-only decisions",
576
+ content: "Store the decision in SQLite and mirror it into the project timeline.",
577
+ }, {});
578
+ assert.equal(result.ok, true);
579
+ const db = dbModule.getDb();
580
+ const decision = db.prepare(`SELECT title, rationale FROM mem_decisions WHERE title = ?`).get("Ship append-only decisions");
581
+ assert.deepEqual(decision, {
582
+ title: "Ship append-only decisions",
583
+ rationale: "Store the decision in SQLite and mirror it into the project timeline.",
584
+ });
585
+ const page = wikiFs.readPage("pages/projects/project-x/index.md") ?? "";
586
+ assert.match(page, /## Timeline/);
587
+ assert.match(page, /Ship append-only decisions/);
588
+ assert.match(page, /Store the decision in SQLite and mirror it into the project timeline\./);
589
+ });
558
590
  //# sourceMappingURL=tools.memory.test.js.map
@@ -1,7 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
4
  import test from "node:test";
6
5
  async function loadToolsModule() {
7
6
  return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
@@ -9,10 +8,14 @@ async function loadToolsModule() {
9
8
  async function readWikiArtifacts() {
10
9
  const nonce = `${Date.now()}-${Math.random()}`;
11
10
  const wikiFs = await import(new URL(`../wiki/fs.js?case=${nonce}`, import.meta.url).href);
12
- return wikiFs;
11
+ const indexManager = await import(new URL(`../wiki/index-manager.js?case=${nonce}`, import.meta.url).href);
12
+ return { wikiFs, indexManager };
13
13
  }
14
+ test.before(() => {
15
+ mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
16
+ });
14
17
  test.beforeEach(() => {
15
- process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-wiki-"));
18
+ process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(process.cwd(), ".test-work", "chapterhouse-tools-wiki-"));
16
19
  process.env.CHAPTERHOUSE_AGENT_NAME = "tools-test-agent";
17
20
  });
18
21
  test.afterEach(async () => {
@@ -38,9 +41,9 @@ test("wiki_update returns validation errors instead of throwing for invalid fron
38
41
  content: "# Chapterhouse\n\nRuntime notes.\n",
39
42
  });
40
43
  assert.deepEqual(result, {
41
- error: "Wiki page frontmatter violates the required shape: missing YAML frontmatter. Use:\n---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---",
44
+ error: "Wiki page frontmatter violates the required shape: missing 'summary'. Use:\n---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---",
42
45
  });
43
- const wikiFs = await readWikiArtifacts();
46
+ const { wikiFs } = await readWikiArtifacts();
44
47
  assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
45
48
  });
46
49
  test("wiki_update returns descriptive validation errors for malformed summaries and unknown tags", async () => {
@@ -99,22 +102,28 @@ Runtime notes.
99
102
  `,
100
103
  });
101
104
  assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
102
- const wikiFs = await readWikiArtifacts();
105
+ const { wikiFs, indexManager } = await readWikiArtifacts();
106
+ const today = new Date().toISOString().slice(0, 10);
103
107
  assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /summary: Runtime notes/);
104
- assert.match(wikiFs.readIndexFile(), /\[Chapterhouse\]\(pages\/shared\/chapterhouse\.md\) — Runtime notes/);
108
+ assert.deepEqual(indexManager.parseIndex(), [
109
+ {
110
+ path: "pages/shared/chapterhouse.md",
111
+ title: "Chapterhouse",
112
+ summary: "Runtime notes",
113
+ section: "shared",
114
+ tags: ["engineering"],
115
+ updated: today,
116
+ },
117
+ ]);
105
118
  });
106
- test("wiki tools append audit entries to pages/_meta/log.md", async () => {
119
+ test("retained wiki tools append audit entries to pages/_meta/log.md", async () => {
107
120
  const toolsModule = await loadToolsModule();
108
121
  const tools = toolsModule.createTools({
109
122
  client: { async listModels() { return []; } },
110
123
  onAgentTaskComplete: () => { },
111
124
  });
112
125
  const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
113
- const wikiIngest = tools.find((entry) => entry.name === "wiki_ingest");
114
- const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
115
- const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
116
- const forget = tools.find((entry) => entry.name === "forget");
117
- assert.ok(wikiUpdate && wikiIngest && wikiLint && wikiRebuildIndex && forget);
126
+ assert.ok(wikiUpdate);
118
127
  await wikiUpdate.handler({
119
128
  path: "pages/shared/chapterhouse.md",
120
129
  title: "Chapterhouse",
@@ -133,66 +142,51 @@ tags: [engineering]
133
142
  Runtime notes with enough content to avoid incidental lint noise in the audit-log test.
134
143
  `,
135
144
  });
136
- await wikiIngest.handler({
137
- type: "text",
138
- source: "Source content for the wiki ingest audit log test.",
139
- name: "audit-log-source",
140
- });
141
- await wikiLint.handler({});
142
- await wikiRebuildIndex.handler({});
143
- await forget.handler({ page_path: "pages/shared/chapterhouse.md" });
144
- const wikiFs = await readWikiArtifacts();
145
+ const { wikiFs } = await readWikiArtifacts();
145
146
  const log = wikiFs.readLogFile();
146
147
  assert.match(log, /update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| tools-test-agent/);
147
- assert.match(log, /ingest \| Ingested text: audit-log-source \(\d+ chars\) \| tools-test-agent/);
148
- assert.match(log, /lint \| .* \| tools-test-agent/);
149
- assert.match(log, /rebuild-index \| wiki_rebuild_index: rebuilt \d+ entries from pages on disk \| tools-test-agent/);
150
- assert.match(log, /delete \| forget: deleted page pages\/shared\/chapterhouse\.md \| tools-test-agent/);
151
148
  });
152
- test("wiki_fix defaults to dry-run previews and logs applied fixes in write mode", async () => {
149
+ test("removed legacy wiki tools return helpful stub messages without mutating wiki pages", async () => {
153
150
  const toolsModule = await loadToolsModule();
154
151
  const tools = toolsModule.createTools({
155
152
  client: { async listModels() { return []; } },
156
153
  onAgentTaskComplete: () => { },
157
154
  });
155
+ const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
156
+ const remember = tools.find((entry) => entry.name === "remember");
157
+ const recall = tools.find((entry) => entry.name === "recall");
158
+ const forget = tools.find((entry) => entry.name === "forget");
159
+ const wikiIngest = tools.find((entry) => entry.name === "wiki_ingest");
160
+ const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
161
+ const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
158
162
  const wikiFix = tools.find((entry) => entry.name === "wiki_fix");
159
- assert.ok(wikiFix);
160
- const wikiFs = await readWikiArtifacts();
161
- wikiFs.writePage("pages/_meta/taxonomy.md", `## Process
162
- - runbook
163
- `);
164
- wikiFs.writePage("pages/projects/chapterhouse/index.md", `---
163
+ assert.ok(wikiUpdate && remember && recall && forget && wikiIngest && wikiLint && wikiRebuildIndex && wikiFix);
164
+ await wikiUpdate.handler({
165
+ path: "pages/shared/chapterhouse.md",
166
+ title: "Chapterhouse",
167
+ summary: "Runtime notes",
168
+ content: `---
165
169
  title: Chapterhouse
166
170
  summary: Runtime notes
167
171
  updated: 2026-05-12
168
- tags: [Run Book]
172
+ tags: [engineering]
169
173
  ---
170
174
 
171
175
  # Chapterhouse
172
176
 
173
- Runtime notes with enough content to avoid incidental stub handling.
174
-
175
- ## Details
176
-
177
- One
178
- Two
179
- Three
180
- Four
181
- Five
182
- Six
183
- Seven
184
- Eight
185
- `);
186
- const before = wikiFs.readPage("pages/projects/chapterhouse/index.md");
187
- const preview = await wikiFix.handler({});
188
- assert.equal(typeof preview, "object");
189
- assert.equal(preview.dryRun, true);
190
- assert.equal(preview.changedFiles, 1);
191
- assert.match(preview.diff, /--- a\/pages\/projects\/chapterhouse\/index\.md/);
192
- assert.equal(wikiFs.readPage("pages/projects/chapterhouse/index.md"), before);
193
- const applied = await wikiFix.handler({ dry_run: false });
194
- assert.equal(applied.dryRun, false);
195
- assert.match(wikiFs.readPage("pages/projects/chapterhouse/index.md") ?? "", /tags: \[runbook\]/);
196
- assert.match(wikiFs.readLogFile(), /fix-tags \| pages\/projects\/chapterhouse\/index\.md \| tools-test-agent/);
177
+ Runtime notes.
178
+ `,
179
+ });
180
+ assert.equal(await remember.handler({ category: "fact", content: "The sky is blue" }), "This tool has been removed. Use wiki_update to write to wiki pages, or memory_remember for agent memory.");
181
+ assert.equal(await recall.handler({ keyword: "sky" }), "This tool has been removed. Use wiki_search to search wiki pages, or memory_recall for agent memory.");
182
+ assert.equal(await forget.handler({ page_path: "pages/shared/chapterhouse.md" }), "This tool has been removed. Use wiki_update to modify wiki pages.");
183
+ assert.equal(await wikiRebuildIndex.handler({}), "This tool has been removed. The wiki index is now maintained automatically via SQLite FTS5.");
184
+ assert.equal(await wikiLint.handler({}), "This tool has been removed. Wiki health checks are no longer needed with SQLite-backed storage.");
185
+ assert.equal(await wikiFix.handler({}), "This tool has been removed. Use wiki_update to correct wiki pages.");
186
+ assert.equal(await wikiIngest.handler({ source: "hello" }), "This tool has been removed. Use wiki_ingest_source instead.");
187
+ const { wikiFs } = await readWikiArtifacts();
188
+ assert.equal(wikiFs.readPage("pages/facts.md"), undefined);
189
+ assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /Runtime notes\./);
190
+ assert.doesNotMatch(wikiFs.readLogFile(), /forget: deleted page|wiki_rebuild_index|wiki_lint|wiki_fix|wiki_ingest:/);
197
191
  });
198
192
  //# sourceMappingURL=tools.wiki.test.js.map
package/dist/daemon.js CHANGED
@@ -21,9 +21,12 @@ import { logger } from "./util/logger.js";
21
21
  import { CHAPTERHOUSE_VERSION } from "./version.js";
22
22
  import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
23
23
  import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
24
+ import { runP6Migration } from "./memory/migration.js";
25
+ import { WikiConsolidationScheduler } from "./wiki/scheduler.js";
24
26
  const log = logger.child({ module: "daemon" });
25
27
  const modeContext = new ModeContext(config);
26
28
  let memoryHousekeepingScheduler;
29
+ let wikiConsolidationScheduler;
27
30
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
28
31
  /**
29
32
  * How long the daemon waits for in-flight work to finish before forcing an exit.
@@ -126,6 +129,8 @@ async function main() {
126
129
  const moved = enforceTopicStructure();
127
130
  log.info({ moved }, "Topic-structure migration complete");
128
131
  }
132
+ const p6Migration = await runP6Migration(getDb());
133
+ log.info({ p6Migration }, "P6 wiki seed migration complete");
129
134
  // Prune orphaned session folders older than 7 days
130
135
  pruneOldSessions();
131
136
  // One-time deprecation note for legacy Telegram users (v1 → v2)
@@ -158,6 +163,8 @@ async function main() {
158
163
  await startApiServer();
159
164
  memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
160
165
  memoryHousekeepingScheduler.start();
166
+ wikiConsolidationScheduler = new WikiConsolidationScheduler();
167
+ wikiConsolidationScheduler.start();
161
168
  if (modeContext.canLogToAdo() && (config.adoPat || config.teamChapterhouseUrl)) {
162
169
  new StandupScheduler().schedule();
163
170
  }
@@ -212,6 +219,7 @@ async function shutdown() {
212
219
  // Destroy all active agent sessions
213
220
  await shutdownAgents();
214
221
  await memoryHousekeepingScheduler?.stop();
222
+ await wikiConsolidationScheduler?.stop();
215
223
  try {
216
224
  stopEpisodeWriter();
217
225
  }
@@ -234,6 +242,7 @@ export async function restartDaemon() {
234
242
  // Destroy all active agent sessions
235
243
  await shutdownAgents();
236
244
  await memoryHousekeepingScheduler?.stop();
245
+ await wikiConsolidationScheduler?.stop();
237
246
  try {
238
247
  stopEpisodeWriter();
239
248
  }
@@ -7,6 +7,7 @@ function toDecision(row) {
7
7
  title: row.title,
8
8
  rationale: row.rationale,
9
9
  decidedAt: row.decided_at,
10
+ source: row.source ?? undefined,
10
11
  tier: row.tier,
11
12
  supersededBy: row.superseded_by ?? undefined,
12
13
  archivedAt: row.archived_at ?? undefined,
@@ -15,14 +16,14 @@ function toDecision(row) {
15
16
  }
16
17
  export function recordDecision(input) {
17
18
  const result = getDb().prepare(`
18
- INSERT INTO mem_decisions (scope_id, entity_id, title, rationale, decided_at, tier, created_at)
19
- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
20
- `).run(input.scope_id, input.entity_id ?? null, input.title, input.rationale, input.decided_at ?? new Date().toISOString().slice(0, 10), input.tier ?? "warm");
19
+ INSERT INTO mem_decisions (scope_id, entity_id, title, rationale, decided_at, source, tier, created_at)
20
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
21
+ `).run(input.scope_id, input.entity_id ?? null, input.title, input.rationale, input.decided_at ?? new Date().toISOString().slice(0, 10), input.source ?? null, input.tier ?? "warm");
21
22
  return getDecision(Number(result.lastInsertRowid));
22
23
  }
23
24
  export function getDecision(id) {
24
25
  const row = getDb().prepare(`
25
- SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
26
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, source, tier, superseded_by, archived_at, created_at
26
27
  FROM mem_decisions
27
28
  WHERE id = ?
28
29
  `).get(id);
@@ -30,7 +31,7 @@ export function getDecision(id) {
30
31
  }
31
32
  export function listDecisions(input = {}) {
32
33
  const rows = getDb().prepare(`
33
- SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
34
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, source, tier, superseded_by, archived_at, created_at
34
35
  FROM mem_decisions
35
36
  WHERE (? IS NULL OR scope_id = ?)
36
37
  AND (? IS NULL OR entity_id = ?)
@@ -3,6 +3,7 @@ function toEntity(row) {
3
3
  return {
4
4
  id: row.id,
5
5
  scopeId: row.scope_id,
6
+ slug: row.slug ?? undefined,
6
7
  kind: row.kind,
7
8
  name: row.name,
8
9
  summary: row.summary ?? undefined,
@@ -14,7 +15,7 @@ function toEntity(row) {
14
15
  }
15
16
  export function getEntity(id) {
16
17
  const row = getDb().prepare(`
17
- SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
18
+ SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
18
19
  FROM mem_entities
19
20
  WHERE id = ?
20
21
  `).get(id);
@@ -22,32 +23,42 @@ export function getEntity(id) {
22
23
  }
23
24
  export function findEntityByName(scopeId, kind, name) {
24
25
  const row = getDb().prepare(`
25
- SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
26
+ SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
26
27
  FROM mem_entities
27
28
  WHERE scope_id = ? AND kind = ? AND name = ?
28
29
  `).get(scopeId, kind, name);
29
30
  return row ? toEntity(row) : undefined;
30
31
  }
32
+ export function findEntityBySlug(scopeId, slug) {
33
+ const row = getDb().prepare(`
34
+ SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
35
+ FROM mem_entities
36
+ WHERE scope_id = ? AND slug = ?
37
+ `).get(scopeId, slug);
38
+ return row ? toEntity(row) : undefined;
39
+ }
31
40
  export function upsertEntity(input) {
32
41
  const db = getDb();
33
- const existing = findEntityByName(input.scope_id, input.kind, input.name);
42
+ const existing = input.slug
43
+ ? findEntityBySlug(input.scope_id, input.slug) ?? findEntityByName(input.scope_id, input.kind, input.name)
44
+ : findEntityByName(input.scope_id, input.kind, input.name);
34
45
  if (existing) {
35
46
  db.prepare(`
36
47
  UPDATE mem_entities
37
- SET summary = ?, tier = ?, confidence = ?, updated_at = CURRENT_TIMESTAMP
48
+ SET slug = COALESCE(?, slug), summary = ?, tier = ?, confidence = ?, updated_at = CURRENT_TIMESTAMP
38
49
  WHERE id = ?
39
- `).run(input.summary ?? existing.summary ?? null, input.tier ?? existing.tier, input.confidence ?? existing.confidence, existing.id);
50
+ `).run(input.slug ?? null, input.summary ?? existing.summary ?? null, input.tier ?? existing.tier, input.confidence ?? existing.confidence, existing.id);
40
51
  return getEntity(existing.id);
41
52
  }
42
53
  const result = db.prepare(`
43
- INSERT INTO mem_entities (scope_id, kind, name, summary, tier, confidence, created_at, updated_at)
44
- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
45
- `).run(input.scope_id, input.kind, input.name, input.summary ?? null, input.tier ?? "warm", input.confidence ?? 1.0);
54
+ INSERT INTO mem_entities (scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at)
55
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
56
+ `).run(input.scope_id, input.slug ?? null, input.kind, input.name, input.summary ?? null, input.tier ?? "warm", input.confidence ?? 1.0);
46
57
  return getEntity(Number(result.lastInsertRowid));
47
58
  }
48
59
  export function listEntities(input = {}) {
49
60
  const rows = getDb().prepare(`
50
- SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
61
+ SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
51
62
  FROM mem_entities
52
63
  WHERE (? IS NULL OR scope_id = ?)
53
64
  AND (? IS NULL OR kind = ?)
@@ -0,0 +1,151 @@
1
+ import { childLogger } from "../util/logger.js";
2
+ import { getActiveScope } from "./active-scope.js";
3
+ import { recordObservation } from "./observations.js";
4
+ import { getScope } from "./scopes.js";
5
+ const log = childLogger("memory.hooks");
6
+ // ---------------------------------------------------------------------------
7
+ // Env knob
8
+ // ---------------------------------------------------------------------------
9
+ function isHooksEnabled() {
10
+ const raw = process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED?.trim();
11
+ if (raw === "false" || raw === "0")
12
+ return false;
13
+ return true;
14
+ }
15
+ // ---------------------------------------------------------------------------
16
+ // Dispatcher
17
+ // ---------------------------------------------------------------------------
18
+ export class MemoryHookDispatcher {
19
+ handlers = new Map();
20
+ register(event, handler) {
21
+ const bucket = this.handlers.get(event) ?? [];
22
+ bucket.push(handler);
23
+ this.handlers.set(event, bucket);
24
+ }
25
+ async dispatch(event, payload) {
26
+ if (!isHooksEnabled()) {
27
+ log.debug({ event }, "memory.hooks.skip.disabled");
28
+ return;
29
+ }
30
+ const bucket = this.handlers.get(event) ?? [];
31
+ if (bucket.length === 0) {
32
+ log.debug({ event }, "memory.hooks.no_handlers");
33
+ return;
34
+ }
35
+ log.info({ event, handlerCount: bucket.length }, "memory.hooks.dispatch");
36
+ for (const handler of bucket) {
37
+ try {
38
+ await handler(payload);
39
+ }
40
+ catch (err) {
41
+ log.error({ err, event }, "memory.hooks.handler_error");
42
+ }
43
+ }
44
+ }
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Singleton dispatcher — shared across the process
48
+ // ---------------------------------------------------------------------------
49
+ export const hookDispatcher = new MemoryHookDispatcher();
50
+ // ---------------------------------------------------------------------------
51
+ // Built-in handler: git:commit → observation in active scope
52
+ // ---------------------------------------------------------------------------
53
+ function buildGitCommitContent(payload) {
54
+ const lines = [`Git commit: ${payload.message}`];
55
+ if (payload.stat?.trim()) {
56
+ lines.push("", "Changed files:", payload.stat.trim());
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+ function resolveGlobalOrActiveScope() {
61
+ const active = getActiveScope();
62
+ if (active)
63
+ return active;
64
+ return getScope("global") ?? null;
65
+ }
66
+ export async function handleGitCommitHook(payload) {
67
+ if (!isHooksEnabled()) {
68
+ log.debug("memory.hooks.git_commit.disabled");
69
+ return { observation_id: "disabled" };
70
+ }
71
+ const scope = resolveGlobalOrActiveScope();
72
+ if (!scope) {
73
+ throw new Error("No active or global memory scope found. Create a scope before using memory hooks.");
74
+ }
75
+ const content = buildGitCommitContent(payload);
76
+ const observation = recordObservation({
77
+ scope_id: scope.id,
78
+ content,
79
+ source: "git:commit",
80
+ tier: "warm",
81
+ confidence: 1.0,
82
+ });
83
+ log.info({ scope: scope.slug, observation_id: observation.id }, "memory.hooks.git_commit.recorded");
84
+ return { observation_id: String(observation.id) };
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // Built-in handler: pr:merge → observation
88
+ // ---------------------------------------------------------------------------
89
+ function buildPrMergeContent(payload) {
90
+ const lines = [`PR #${payload.number} merged: ${payload.title}`];
91
+ if (payload.body?.trim()) {
92
+ lines.push("", payload.body.trim());
93
+ }
94
+ if (payload.files_changed && payload.files_changed.length > 0) {
95
+ lines.push("", "Files changed:");
96
+ for (const file of payload.files_changed) {
97
+ lines.push(` ${file}`);
98
+ }
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+ export async function handlePrMergeHook(payload) {
103
+ if (!isHooksEnabled()) {
104
+ log.debug("memory.hooks.pr_merge.disabled");
105
+ return { observation_id: "disabled" };
106
+ }
107
+ const scope = resolveGlobalOrActiveScope();
108
+ if (!scope) {
109
+ throw new Error("No active or global memory scope found. Create a scope before using memory hooks.");
110
+ }
111
+ const content = buildPrMergeContent(payload);
112
+ const observation = recordObservation({
113
+ scope_id: scope.id,
114
+ content,
115
+ source: "pr:merge",
116
+ tier: "warm",
117
+ confidence: 1.0,
118
+ });
119
+ log.info({ scope: scope.slug, observation_id: observation.id, pr: payload.number }, "memory.hooks.pr_merge.recorded");
120
+ return { observation_id: String(observation.id) };
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // Built-in handler: scope:created → observation in global scope
124
+ // ---------------------------------------------------------------------------
125
+ hookDispatcher.register("scope:created", async (payload) => {
126
+ const globalScope = getScope("global");
127
+ if (!globalScope) {
128
+ log.debug("memory.hooks.scope_created.no_global_scope");
129
+ return;
130
+ }
131
+ const content = `Scope '${payload.slug}' created: ${payload.description || payload.title}`;
132
+ const observation = recordObservation({
133
+ scope_id: globalScope.id,
134
+ content,
135
+ source: "scope:created",
136
+ tier: "warm",
137
+ confidence: 1.0,
138
+ });
139
+ log.info({ scope: payload.slug, observation_id: observation.id }, "memory.hooks.scope_created.recorded");
140
+ });
141
+ // ---------------------------------------------------------------------------
142
+ // Built-in handler: memory:decision → structured log event
143
+ // ---------------------------------------------------------------------------
144
+ hookDispatcher.register("memory:decision", async (payload) => {
145
+ log.info({
146
+ decision_id: payload.id,
147
+ scope_id: payload.scope_id,
148
+ title: payload.title,
149
+ }, "memory.hooks.decision.recorded");
150
+ });
151
+ //# sourceMappingURL=hooks.js.map