chapterhouse 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/korg.js +34 -0
  3. package/dist/api/korg.test.js +42 -0
  4. package/dist/api/server.js +238 -2
  5. package/dist/api/server.test.js +199 -0
  6. package/dist/config.js +28 -0
  7. package/dist/config.test.js +20 -0
  8. package/dist/copilot/agents.js +3 -4
  9. package/dist/copilot/agents.test.js +12 -1
  10. package/dist/copilot/orchestrator.js +12 -1
  11. package/dist/copilot/orchestrator.test.js +3 -7
  12. package/dist/copilot/system-message.js +12 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +193 -375
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +80 -59
  17. package/dist/copilot/turn-event-log-env.test.js +11 -15
  18. package/dist/daemon.js +19 -0
  19. package/dist/memory/decisions.js +6 -5
  20. package/dist/memory/entities.js +20 -9
  21. package/dist/memory/eot.js +30 -8
  22. package/dist/memory/eot.test.js +220 -6
  23. package/dist/memory/hooks.js +151 -0
  24. package/dist/memory/hooks.test.js +325 -0
  25. package/dist/memory/hot-tier.js +37 -0
  26. package/dist/memory/hot-tier.test.js +30 -0
  27. package/dist/memory/housekeeping-scheduler.js +35 -0
  28. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  29. package/dist/memory/inbox.js +10 -0
  30. package/dist/memory/index.js +3 -1
  31. package/dist/memory/migration.js +244 -0
  32. package/dist/memory/migration.test.js +108 -0
  33. package/dist/memory/reflect.js +273 -0
  34. package/dist/memory/reflect.test.js +254 -0
  35. package/dist/paths.js +31 -11
  36. package/dist/store/db.js +187 -4
  37. package/dist/store/db.test.js +66 -2
  38. package/dist/test/helpers/reset-singletons.js +8 -0
  39. package/dist/test/helpers/reset-singletons.test.js +37 -0
  40. package/dist/test/setup-env.js +9 -1
  41. package/dist/wiki/consolidation.js +641 -0
  42. package/dist/wiki/consolidation.test.js +143 -0
  43. package/dist/wiki/frontmatter.js +48 -0
  44. package/dist/wiki/frontmatter.test.js +42 -0
  45. package/dist/wiki/fs.js +22 -13
  46. package/dist/wiki/index-manager.js +305 -330
  47. package/dist/wiki/index-manager.test.js +265 -144
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/log-manager.js +8 -5
  53. package/dist/wiki/log-manager.test.js +4 -0
  54. package/dist/wiki/migrate-topics.test.js +16 -6
  55. package/dist/wiki/scheduler.js +118 -0
  56. package/dist/wiki/scheduler.test.js +64 -0
  57. package/dist/wiki/timeline.js +51 -0
  58. package/dist/wiki/timeline.test.js +65 -0
  59. package/dist/wiki/topic-structure.js +1 -1
  60. package/package.json +1 -1
  61. package/skills/pkb-ideas/SKILL.md +78 -0
  62. package/skills/pkb-ideas/_meta.json +4 -0
  63. package/skills/pkb-org/SKILL.md +82 -0
  64. package/skills/pkb-org/_meta.json +4 -0
  65. package/skills/pkb-people/SKILL.md +74 -0
  66. package/skills/pkb-people/_meta.json +4 -0
  67. package/skills/pkb-research/SKILL.md +83 -0
  68. package/skills/pkb-research/_meta.json +4 -0
  69. package/skills/pkb-source/SKILL.md +38 -0
  70. package/skills/pkb-source/_meta.json +4 -0
  71. package/skills/wiki-conventions/SKILL.md +5 -5
  72. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  73. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  74. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  75. package/web/dist/index.html +2 -2
  76. package/dist/wiki/context.js +0 -138
  77. package/dist/wiki/fix.js +0 -335
  78. package/dist/wiki/fix.test.js +0 -350
  79. package/dist/wiki/lint.js +0 -451
  80. package/dist/wiki/lint.test.js +0 -329
  81. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -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,78 @@ 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("wiki_reindex rebuilds wiki_pages from disk on demand", async () => {
153
150
  const toolsModule = await loadToolsModule();
154
151
  const tools = toolsModule.createTools({
155
152
  client: { async listModels() { return []; } },
156
153
  onAgentTaskComplete: () => { },
157
154
  });
158
- 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
155
+ const wikiReindex = tools.find((entry) => entry.name === "wiki_reindex");
156
+ assert.ok(wikiReindex);
157
+ const { wikiFs, indexManager } = await readWikiArtifacts();
158
+ wikiFs.ensureWikiStructure();
159
+ wikiFs.writePage("pages/topics/rust/index.md", `---
160
+ title: Rust
161
+ summary: Systems programming
162
+ updated: 2026-05-12
163
+ ---
164
+
165
+ # Rust
163
166
  `);
164
- wikiFs.writePage("pages/projects/chapterhouse/index.md", `---
167
+ for (const entry of indexManager.parseIndex()) {
168
+ indexManager.removeFromIndex(entry.path);
169
+ }
170
+ assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
171
+ const result = await wikiReindex.handler({});
172
+ assert.match(result, /^Reindexed \d+ wiki page\(s\) from disk\.$/);
173
+ assert.ok(indexManager.parseIndex().some((entry) => entry.path === "pages/topics/rust/index.md"));
174
+ assert.match(wikiFs.readLogFile(), /update \| wiki_reindex: rebuilt \d+ page\(s\) \| tools-test-agent/);
175
+ });
176
+ test("removed legacy wiki tools return helpful stub messages without mutating wiki pages", async () => {
177
+ const toolsModule = await loadToolsModule();
178
+ const tools = toolsModule.createTools({
179
+ client: { async listModels() { return []; } },
180
+ onAgentTaskComplete: () => { },
181
+ });
182
+ const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
183
+ const remember = tools.find((entry) => entry.name === "remember");
184
+ const recall = tools.find((entry) => entry.name === "recall");
185
+ const forget = tools.find((entry) => entry.name === "forget");
186
+ const wikiIngest = tools.find((entry) => entry.name === "wiki_ingest");
187
+ const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
188
+ const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
189
+ const wikiFix = tools.find((entry) => entry.name === "wiki_fix");
190
+ assert.ok(wikiUpdate && remember && recall && forget && wikiIngest && wikiLint && wikiRebuildIndex && wikiFix);
191
+ await wikiUpdate.handler({
192
+ path: "pages/shared/chapterhouse.md",
193
+ title: "Chapterhouse",
194
+ summary: "Runtime notes",
195
+ content: `---
165
196
  title: Chapterhouse
166
197
  summary: Runtime notes
167
198
  updated: 2026-05-12
168
- tags: [Run Book]
199
+ tags: [engineering]
169
200
  ---
170
201
 
171
202
  # Chapterhouse
172
203
 
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/);
204
+ Runtime notes.
205
+ `,
206
+ });
207
+ 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.");
208
+ 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.");
209
+ assert.equal(await forget.handler({ page_path: "pages/shared/chapterhouse.md" }), "This tool has been removed. Use wiki_update to modify wiki pages.");
210
+ assert.equal(await wikiRebuildIndex.handler({}), "This tool has been removed. The wiki index is now maintained automatically via SQLite FTS5.");
211
+ assert.equal(await wikiLint.handler({}), "This tool has been removed. Wiki health checks are no longer needed with SQLite-backed storage.");
212
+ assert.equal(await wikiFix.handler({}), "This tool has been removed. Use wiki_update to correct wiki pages.");
213
+ assert.equal(await wikiIngest.handler({ source: "hello" }), "This tool has been removed. Use wiki_ingest_source instead.");
214
+ const { wikiFs } = await readWikiArtifacts();
215
+ assert.equal(wikiFs.readPage("pages/facts.md"), undefined);
216
+ assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /Runtime notes\./);
217
+ assert.doesNotMatch(wikiFs.readLogFile(), /forget: deleted page|wiki_rebuild_index|wiki_lint|wiki_fix|wiki_ingest:/);
197
218
  });
198
219
  //# sourceMappingURL=tools.wiki.test.js.map
@@ -1,19 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- test("SESSION_BUFFER_CAPACITY respects CHAPTERHOUSE_SSE_BUFFER_CAPACITY", async () => {
4
- const previous = process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
5
- process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = "3";
6
- try {
7
- const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
8
- assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
9
- }
10
- finally {
11
- if (previous === undefined) {
12
- delete process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
13
- }
14
- else {
15
- process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = previous;
16
- }
17
- }
3
+ test("SESSION_BUFFER_CAPACITY respects config.sseBufferCapacity", async (t) => {
4
+ t.mock.module("../config.js", {
5
+ namedExports: {
6
+ config: {
7
+ sseBufferCapacity: 3,
8
+ sseReplayLimit: 50,
9
+ },
10
+ },
11
+ });
12
+ const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
13
+ assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
18
14
  });
19
15
  //# sourceMappingURL=turn-event-log-env.test.js.map
package/dist/daemon.js CHANGED
@@ -21,9 +21,13 @@ 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";
26
+ import { ensureWikiIndexPopulated } from "./wiki/index-manager.js";
24
27
  const log = logger.child({ module: "daemon" });
25
28
  const modeContext = new ModeContext(config);
26
29
  let memoryHousekeepingScheduler;
30
+ let wikiConsolidationScheduler;
27
31
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
28
32
  /**
29
33
  * How long the daemon waits for in-flight work to finish before forcing an exit.
@@ -126,6 +130,17 @@ async function main() {
126
130
  const moved = enforceTopicStructure();
127
131
  log.info({ moved }, "Topic-structure migration complete");
128
132
  }
133
+ const p6Migration = await runP6Migration(getDb());
134
+ log.info({ p6Migration }, "P6 wiki seed migration complete");
135
+ try {
136
+ const wikiReindex = ensureWikiIndexPopulated();
137
+ if (wikiReindex.reindexed) {
138
+ log.info(wikiReindex, "Rebuilt wiki index from disk on startup");
139
+ }
140
+ }
141
+ catch (err) {
142
+ log.error({ err: err instanceof Error ? err.message : err }, "Startup wiki reindex check failed");
143
+ }
129
144
  // Prune orphaned session folders older than 7 days
130
145
  pruneOldSessions();
131
146
  // One-time deprecation note for legacy Telegram users (v1 → v2)
@@ -158,6 +173,8 @@ async function main() {
158
173
  await startApiServer();
159
174
  memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
160
175
  memoryHousekeepingScheduler.start();
176
+ wikiConsolidationScheduler = new WikiConsolidationScheduler();
177
+ wikiConsolidationScheduler.start();
161
178
  if (modeContext.canLogToAdo() && (config.adoPat || config.teamChapterhouseUrl)) {
162
179
  new StandupScheduler().schedule();
163
180
  }
@@ -212,6 +229,7 @@ async function shutdown() {
212
229
  // Destroy all active agent sessions
213
230
  await shutdownAgents();
214
231
  await memoryHousekeepingScheduler?.stop();
232
+ await wikiConsolidationScheduler?.stop();
215
233
  try {
216
234
  stopEpisodeWriter();
217
235
  }
@@ -234,6 +252,7 @@ export async function restartDaemon() {
234
252
  // Destroy all active agent sessions
235
253
  await shutdownAgents();
236
254
  await memoryHousekeepingScheduler?.stop();
255
+ await wikiConsolidationScheduler?.stop();
237
256
  try {
238
257
  stopEpisodeWriter();
239
258
  }
@@ -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 = ?)
@@ -127,6 +127,16 @@ function isNonEmptyString(value) {
127
127
  function isIsoTimestamp(value) {
128
128
  return !Number.isNaN(Date.parse(value));
129
129
  }
130
+ function validateObservationPayload(payload) {
131
+ const observation = payload;
132
+ if (!isNonEmptyString(observation.content)) {
133
+ return undefined;
134
+ }
135
+ return {
136
+ ...observation,
137
+ content: observation.content.trim(),
138
+ };
139
+ }
130
140
  function validateActionItemPayload(payload) {
131
141
  const actionItem = payload;
132
142
  if (!isNonEmptyString(actionItem.title)) {
@@ -195,15 +205,20 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
195
205
  throw new Error(`Unknown memory scope '${scopeSlug}'.`);
196
206
  }
197
207
  if (kind === "observation") {
198
- const observation = payload;
208
+ const observation = validateObservationPayload(payload);
209
+ if (!observation) {
210
+ log.warn({ scopeSlug, source, sourceAgent }, "Skipping accepted observation proposal with empty content");
211
+ return false;
212
+ }
213
+ const content = observation.content;
199
214
  recordObservation({
200
215
  scope_id: scope.id,
201
216
  entity_id: observation.entity_id,
202
- content: observation.content,
217
+ content,
203
218
  source: observation.source ?? source,
204
219
  confidence,
205
220
  });
206
- return;
221
+ return true;
207
222
  }
208
223
  if (kind === "decision") {
209
224
  const decision = payload;
@@ -213,7 +228,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
213
228
  rationale: decision.rationale ?? decision.title,
214
229
  decided_at: decision.decided_at,
215
230
  });
216
- return;
231
+ return true;
217
232
  }
218
233
  if (kind === "action_item") {
219
234
  const actionItem = validateActionItemPayload(payload);
@@ -233,7 +248,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
233
248
  due_at: actionItem.due_at,
234
249
  source: sourceAgent ? `subagent_proposal:${sourceAgent}` : actionItem.source ?? source,
235
250
  });
236
- return;
251
+ return true;
237
252
  }
238
253
  const entity = payload;
239
254
  const entityKind = entity.entity_kind ?? entity.kind;
@@ -247,6 +262,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
247
262
  summary: entity.summary,
248
263
  confidence,
249
264
  });
265
+ return true;
250
266
  }
251
267
  export async function runEndOfTaskMemoryHook(input) {
252
268
  const autoAcceptEnabled = isMemoryAutoAcceptEnabled();
@@ -293,7 +309,12 @@ export async function runEndOfTaskMemoryHook(input) {
293
309
  else {
294
310
  const envelope = parseEnvelope(proposal.payload);
295
311
  try {
296
- rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
312
+ const accepted = rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
313
+ if (!accepted) {
314
+ resolveInboxItem(proposal.id, "accepted", decision.reason);
315
+ summary.accepted++;
316
+ continue;
317
+ }
297
318
  resolveInboxItem(proposal.id, "accepted", decision.reason);
298
319
  summary.accepted++;
299
320
  }
@@ -321,8 +342,9 @@ export async function runEndOfTaskMemoryHook(input) {
321
342
  }
322
343
  if (autoAcceptEnabled) {
323
344
  for (const implicitMemory of review.implicit_memories) {
324
- rememberAcceptedMemory(implicitMemory.kind, implicitMemory.scope_slug, implicitMemory.payload, "agent:eot", implicitMemory.confidence);
325
- summary.implicit_extracted++;
345
+ if (rememberAcceptedMemory(implicitMemory.kind, implicitMemory.scope_slug, implicitMemory.payload, "agent:eot", implicitMemory.confidence)) {
346
+ summary.implicit_extracted++;
347
+ }
326
348
  }
327
349
  }
328
350
  log.info(summary, "memory.eot.processed");