chapterhouse 0.3.26 → 0.4.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 (53) hide show
  1. package/dist/api/server.js +12 -0
  2. package/dist/api/server.test.js +39 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.test.js +109 -0
  5. package/dist/copilot/agents.js +32 -6
  6. package/dist/copilot/agents.test.js +41 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +224 -3
  9. package/dist/copilot/orchestrator.test.js +380 -0
  10. package/dist/copilot/prompt-date.js +8 -0
  11. package/dist/copilot/system-message.js +8 -0
  12. package/dist/copilot/system-message.test.js +58 -0
  13. package/dist/copilot/tools.agent.test.js +24 -0
  14. package/dist/copilot/tools.js +351 -4
  15. package/dist/copilot/tools.memory.test.js +297 -0
  16. package/dist/copilot/turn-event-log-env.test.js +19 -0
  17. package/dist/copilot/turn-event-log.js +22 -23
  18. package/dist/copilot/turn-event-log.test.js +61 -2
  19. package/dist/memory/active-scope.js +69 -0
  20. package/dist/memory/active-scope.test.js +76 -0
  21. package/dist/memory/checkpoint-prompt.js +71 -0
  22. package/dist/memory/checkpoint.js +257 -0
  23. package/dist/memory/checkpoint.test.js +255 -0
  24. package/dist/memory/decisions.js +53 -0
  25. package/dist/memory/decisions.test.js +92 -0
  26. package/dist/memory/entities.js +59 -0
  27. package/dist/memory/entities.test.js +65 -0
  28. package/dist/memory/eot.js +219 -0
  29. package/dist/memory/eot.test.js +263 -0
  30. package/dist/memory/hot-tier.js +187 -0
  31. package/dist/memory/hot-tier.test.js +197 -0
  32. package/dist/memory/housekeeping.js +352 -0
  33. package/dist/memory/housekeeping.test.js +280 -0
  34. package/dist/memory/inbox.js +73 -0
  35. package/dist/memory/index.js +11 -0
  36. package/dist/memory/observations.js +46 -0
  37. package/dist/memory/observations.test.js +86 -0
  38. package/dist/memory/recall.js +210 -0
  39. package/dist/memory/recall.test.js +238 -0
  40. package/dist/memory/scopes.js +89 -0
  41. package/dist/memory/scopes.test.js +201 -0
  42. package/dist/memory/tiering.js +193 -0
  43. package/dist/memory/types.js +2 -0
  44. package/dist/paths.js +7 -1
  45. package/dist/store/db.js +412 -8
  46. package/dist/store/db.test.js +83 -0
  47. package/dist/test/setup-env.js +16 -0
  48. package/dist/test/setup-env.test.js +4 -0
  49. package/package.json +1 -1
  50. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  51. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -0,0 +1,92 @@
1
+ import assert from "node:assert/strict";
2
+ import { 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", `memory-decisions-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadModules() {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL("./index.js", import.meta.url).href);
17
+ return { dbModule, memoryModule };
18
+ }
19
+ function getFunction(module, name) {
20
+ const value = module[name];
21
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
22
+ return value;
23
+ }
24
+ test.beforeEach(async () => {
25
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
26
+ dbModule.closeDb();
27
+ resetSandbox();
28
+ });
29
+ test.after(async () => {
30
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
31
+ dbModule.closeDb();
32
+ rmSync(sandboxRoot, { recursive: true, force: true });
33
+ });
34
+ test("recordDecision supports CRUD, supersession, and keeps the decision FTS index in sync", async () => {
35
+ const { dbModule, memoryModule } = await loadModules();
36
+ const db = dbModule.getDb();
37
+ const getScope = getFunction(memoryModule, "getScope");
38
+ const recordDecision = getFunction(memoryModule, "recordDecision");
39
+ const getDecision = getFunction(memoryModule, "getDecision");
40
+ const listDecisions = getFunction(memoryModule, "listDecisions");
41
+ const supersedeDecision = getFunction(memoryModule, "supersedeDecision");
42
+ const chapterhouse = getScope("chapterhouse");
43
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
44
+ const original = recordDecision({
45
+ scope_id: chapterhouse.id,
46
+ title: "Use SQLite for scoped memory",
47
+ rationale: "SQLite FTS5 keeps memory recall local, fast, and dependency-free.",
48
+ decided_at: "2026-05-13",
49
+ });
50
+ const replacement = recordDecision({
51
+ scope_id: chapterhouse.id,
52
+ title: "Keep SQLite for agent memory v1",
53
+ rationale: "SQLite remains the only persistence layer for PR 2.",
54
+ decided_at: "2026-05-14",
55
+ });
56
+ assert.equal(listDecisions({ scope_id: chapterhouse.id }).length >= 2, true);
57
+ assert.deepEqual(getDecision(original.id)?.title, original.title);
58
+ const initialHits = db.prepare(`
59
+ SELECT rowid
60
+ FROM mem_decisions_fts
61
+ WHERE mem_decisions_fts MATCH 'dependency'
62
+ `).all();
63
+ assert.deepEqual(initialHits, [{ rowid: original.id }]);
64
+ db.prepare(`
65
+ UPDATE mem_decisions
66
+ SET rationale = ?
67
+ WHERE id = ?
68
+ `).run("FTS snippets should refresh when the rationale changes.", original.id);
69
+ const oldHits = db.prepare(`
70
+ SELECT rowid
71
+ FROM mem_decisions_fts
72
+ WHERE mem_decisions_fts MATCH 'dependency'
73
+ `).all();
74
+ const newHits = db.prepare(`
75
+ SELECT rowid
76
+ FROM mem_decisions_fts
77
+ WHERE mem_decisions_fts MATCH 'snippets'
78
+ `).all();
79
+ assert.deepEqual(oldHits, []);
80
+ assert.deepEqual(newHits, [{ rowid: original.id }]);
81
+ const superseded = supersedeDecision(original.id, replacement.id);
82
+ assert.equal(superseded.supersededBy, replacement.id);
83
+ assert.equal(getDecision(original.id)?.supersededBy, replacement.id);
84
+ db.prepare(`DELETE FROM mem_decisions WHERE id = ?`).run(original.id);
85
+ const deletedHits = db.prepare(`
86
+ SELECT rowid
87
+ FROM mem_decisions_fts
88
+ WHERE mem_decisions_fts MATCH 'snippets'
89
+ `).all();
90
+ assert.deepEqual(deletedHits, []);
91
+ });
92
+ //# sourceMappingURL=decisions.test.js.map
@@ -0,0 +1,59 @@
1
+ import { getDb } from "../store/db.js";
2
+ function toEntity(row) {
3
+ return {
4
+ id: row.id,
5
+ scopeId: row.scope_id,
6
+ kind: row.kind,
7
+ name: row.name,
8
+ summary: row.summary ?? undefined,
9
+ tier: row.tier,
10
+ confidence: row.confidence,
11
+ createdAt: row.created_at,
12
+ updatedAt: row.updated_at,
13
+ };
14
+ }
15
+ export function getEntity(id) {
16
+ const row = getDb().prepare(`
17
+ SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
18
+ FROM mem_entities
19
+ WHERE id = ?
20
+ `).get(id);
21
+ return row ? toEntity(row) : undefined;
22
+ }
23
+ export function findEntityByName(scopeId, kind, name) {
24
+ const row = getDb().prepare(`
25
+ SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
26
+ FROM mem_entities
27
+ WHERE scope_id = ? AND kind = ? AND name = ?
28
+ `).get(scopeId, kind, name);
29
+ return row ? toEntity(row) : undefined;
30
+ }
31
+ export function upsertEntity(input) {
32
+ const db = getDb();
33
+ const existing = findEntityByName(input.scope_id, input.kind, input.name);
34
+ if (existing) {
35
+ db.prepare(`
36
+ UPDATE mem_entities
37
+ SET summary = ?, tier = ?, confidence = ?, updated_at = CURRENT_TIMESTAMP
38
+ WHERE id = ?
39
+ `).run(input.summary ?? existing.summary ?? null, input.tier ?? existing.tier, input.confidence ?? existing.confidence, existing.id);
40
+ return getEntity(existing.id);
41
+ }
42
+ 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);
46
+ return getEntity(Number(result.lastInsertRowid));
47
+ }
48
+ export function listEntities(input = {}) {
49
+ const rows = getDb().prepare(`
50
+ SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
51
+ FROM mem_entities
52
+ WHERE (? IS NULL OR scope_id = ?)
53
+ AND (? IS NULL OR kind = ?)
54
+ ORDER BY updated_at DESC, id DESC
55
+ LIMIT ? OFFSET ?
56
+ `).all(input.scope_id ?? null, input.scope_id ?? null, input.kind ?? null, input.kind ?? null, input.limit ?? 50, input.offset ?? 0);
57
+ return rows.map(toEntity);
58
+ }
59
+ //# sourceMappingURL=entities.js.map
@@ -0,0 +1,65 @@
1
+ import assert from "node:assert/strict";
2
+ import { 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", `memory-entities-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadModules() {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL("./index.js", import.meta.url).href);
17
+ return { dbModule, memoryModule };
18
+ }
19
+ function getFunction(module, name) {
20
+ const value = module[name];
21
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
22
+ return value;
23
+ }
24
+ test.beforeEach(async () => {
25
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
26
+ dbModule.closeDb();
27
+ resetSandbox();
28
+ });
29
+ test.after(async () => {
30
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
31
+ dbModule.closeDb();
32
+ rmSync(sandboxRoot, { recursive: true, force: true });
33
+ });
34
+ test("upsertEntity is idempotent on scope, kind, and name", async () => {
35
+ const { dbModule, memoryModule } = await loadModules();
36
+ dbModule.getDb();
37
+ const getScope = getFunction(memoryModule, "getScope");
38
+ const upsertEntity = getFunction(memoryModule, "upsertEntity");
39
+ const getEntity = getFunction(memoryModule, "getEntity");
40
+ const findEntityByName = getFunction(memoryModule, "findEntityByName");
41
+ const listEntities = getFunction(memoryModule, "listEntities");
42
+ const chapterhouse = getScope("chapterhouse");
43
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
44
+ const first = upsertEntity({
45
+ scope_id: chapterhouse.id,
46
+ kind: "tool",
47
+ name: "better-sqlite3",
48
+ summary: "SQLite driver used for memory persistence.",
49
+ tier: "warm",
50
+ confidence: 0.7,
51
+ });
52
+ const second = upsertEntity({
53
+ scope_id: chapterhouse.id,
54
+ kind: "tool",
55
+ name: "better-sqlite3",
56
+ summary: "Sync SQLite driver used throughout the daemon.",
57
+ tier: "hot",
58
+ confidence: 0.95,
59
+ });
60
+ assert.equal(second.id, first.id);
61
+ assert.equal(listEntities({ scope_id: chapterhouse.id, kind: "tool" }).length, 1);
62
+ assert.equal(findEntityByName(chapterhouse.id, "tool", "better-sqlite3")?.id, first.id);
63
+ assert.deepEqual(getEntity(first.id), second);
64
+ });
65
+ //# sourceMappingURL=entities.test.js.map
@@ -0,0 +1,219 @@
1
+ import { config } from "../config.js";
2
+ import { runOneShotPrompt } from "../copilot/oneshot.js";
3
+ import { childLogger } from "../util/logger.js";
4
+ import { recordDecision } from "./decisions.js";
5
+ import { upsertEntity } from "./entities.js";
6
+ import { listPendingMemoryProposalsForTask, resolveInboxItem } from "./inbox.js";
7
+ import { recordObservation } from "./observations.js";
8
+ import { getScope } from "./scopes.js";
9
+ const log = childLogger("memory.eot");
10
+ function isEndOfTaskHookEnabled() {
11
+ const raw = process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED?.trim();
12
+ if (raw === "0")
13
+ return false;
14
+ if (raw === "1")
15
+ return true;
16
+ return true;
17
+ }
18
+ function isMemoryAutoAcceptEnabled() {
19
+ const raw = process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT?.trim();
20
+ if (raw === "0")
21
+ return false;
22
+ if (raw === "1")
23
+ return true;
24
+ return true;
25
+ }
26
+ function buildReviewerSystemPrompt() {
27
+ return [
28
+ "You review subagent memory proposals at end-of-task.",
29
+ "Decide accept or reject for each proposal id.",
30
+ "Optionally extract additional implicit durable memories from the task summary.",
31
+ "Return JSON only with keys: decisions, implicit_memories.",
32
+ "Each decision must include proposal_id, decision, reason.",
33
+ "Each implicit memory must include kind, scope_slug, payload, and may include confidence/reason.",
34
+ ].join("\n");
35
+ }
36
+ function buildReviewerUserPrompt(finalResult, proposals) {
37
+ return JSON.stringify({
38
+ final_result: finalResult,
39
+ proposals: proposals.map((proposal) => ({
40
+ id: proposal.id,
41
+ source_agent: proposal.sourceAgent,
42
+ payload: proposal.payload,
43
+ })),
44
+ }, null, 2);
45
+ }
46
+ function parseEnvelope(raw) {
47
+ const parsed = JSON.parse(raw);
48
+ if (!parsed || typeof parsed !== "object") {
49
+ throw new Error("Invalid memory proposal payload.");
50
+ }
51
+ if (parsed.kind !== "observation" && parsed.kind !== "decision" && parsed.kind !== "entity") {
52
+ throw new Error("Invalid proposal kind.");
53
+ }
54
+ if (!parsed.payload || typeof parsed.payload !== "object") {
55
+ throw new Error("Invalid proposal payload.");
56
+ }
57
+ return {
58
+ kind: parsed.kind,
59
+ scope_slug: typeof parsed.scope_slug === "string" ? parsed.scope_slug : undefined,
60
+ confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.5,
61
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
62
+ payload: parsed.payload,
63
+ };
64
+ }
65
+ function parseReviewerResponse(raw) {
66
+ const parsed = JSON.parse(raw);
67
+ return {
68
+ decisions: Array.isArray(parsed.decisions)
69
+ ? parsed.decisions.flatMap((entry) => {
70
+ if (!entry || typeof entry !== "object") {
71
+ return [];
72
+ }
73
+ const candidate = entry;
74
+ if (typeof candidate.proposal_id !== "number") {
75
+ return [];
76
+ }
77
+ if (candidate.decision !== "accept" && candidate.decision !== "reject") {
78
+ return [];
79
+ }
80
+ if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0) {
81
+ return [];
82
+ }
83
+ return [{
84
+ proposal_id: candidate.proposal_id,
85
+ decision: candidate.decision,
86
+ reason: candidate.reason.trim(),
87
+ }];
88
+ })
89
+ : [],
90
+ implicit_memories: Array.isArray(parsed.implicit_memories)
91
+ ? parsed.implicit_memories.flatMap((entry) => {
92
+ if (!entry || typeof entry !== "object") {
93
+ return [];
94
+ }
95
+ const candidate = entry;
96
+ if (candidate.kind !== "observation" && candidate.kind !== "decision" && candidate.kind !== "entity") {
97
+ return [];
98
+ }
99
+ if (typeof candidate.scope_slug !== "string" || !candidate.payload || typeof candidate.payload !== "object") {
100
+ return [];
101
+ }
102
+ return [{
103
+ kind: candidate.kind,
104
+ scope_slug: candidate.scope_slug,
105
+ payload: candidate.payload,
106
+ confidence: typeof candidate.confidence === "number" ? candidate.confidence : undefined,
107
+ reason: typeof candidate.reason === "string" ? candidate.reason : undefined,
108
+ }];
109
+ })
110
+ : [],
111
+ };
112
+ }
113
+ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
114
+ const scope = getScope(scopeSlug);
115
+ if (!scope) {
116
+ throw new Error(`Unknown memory scope '${scopeSlug}'.`);
117
+ }
118
+ if (kind === "observation") {
119
+ const observation = payload;
120
+ recordObservation({
121
+ scope_id: scope.id,
122
+ entity_id: observation.entity_id,
123
+ content: observation.content,
124
+ source: observation.source ?? source,
125
+ confidence,
126
+ });
127
+ return;
128
+ }
129
+ if (kind === "decision") {
130
+ const decision = payload;
131
+ recordDecision({
132
+ scope_id: scope.id,
133
+ title: decision.title,
134
+ rationale: decision.rationale ?? decision.title,
135
+ decided_at: decision.decided_at,
136
+ });
137
+ return;
138
+ }
139
+ const entity = payload;
140
+ upsertEntity({
141
+ scope_id: scope.id,
142
+ kind: entity.kind,
143
+ name: entity.name,
144
+ summary: entity.summary,
145
+ confidence,
146
+ });
147
+ }
148
+ export async function runEndOfTaskMemoryHook(input) {
149
+ const autoAcceptEnabled = isMemoryAutoAcceptEnabled();
150
+ const summary = {
151
+ task_id: input.taskId,
152
+ proposals_total: 0,
153
+ accepted: 0,
154
+ rejected: 0,
155
+ implicit_extracted: 0,
156
+ auto_accept: autoAcceptEnabled,
157
+ };
158
+ if (!isEndOfTaskHookEnabled()) {
159
+ return summary;
160
+ }
161
+ const proposals = listPendingMemoryProposalsForTask(input.taskId);
162
+ summary.proposals_total = proposals.length;
163
+ const callLLM = input.callLLM ?? (async ({ system, user, model }) => {
164
+ const result = await runOneShotPrompt({
165
+ client: input.copilotClient,
166
+ model,
167
+ system,
168
+ user,
169
+ expectJson: true,
170
+ });
171
+ return result.content;
172
+ });
173
+ const review = parseReviewerResponse(await callLLM({
174
+ system: buildReviewerSystemPrompt(),
175
+ user: buildReviewerUserPrompt(input.finalResult, proposals),
176
+ model: input.model ?? config.copilotModel,
177
+ }));
178
+ const proposalsById = new Map(proposals.map((proposal) => [proposal.id, proposal]));
179
+ const reviewedProposalIds = new Set();
180
+ for (const decision of review.decisions) {
181
+ const proposal = proposalsById.get(decision.proposal_id);
182
+ if (!proposal) {
183
+ continue;
184
+ }
185
+ reviewedProposalIds.add(proposal.id);
186
+ if (decision.decision === "accept") {
187
+ summary.accepted++;
188
+ if (autoAcceptEnabled) {
189
+ const envelope = parseEnvelope(proposal.payload);
190
+ rememberAcceptedMemory(envelope.kind, envelope.scope_slug ?? getScope(proposal.scopeId).slug, envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence);
191
+ resolveInboxItem(proposal.id, "accepted", decision.reason);
192
+ }
193
+ continue;
194
+ }
195
+ summary.rejected++;
196
+ if (autoAcceptEnabled) {
197
+ resolveInboxItem(proposal.id, "rejected", decision.reason);
198
+ }
199
+ }
200
+ for (const proposal of proposals) {
201
+ if (reviewedProposalIds.has(proposal.id)) {
202
+ continue;
203
+ }
204
+ summary.rejected++;
205
+ if (autoAcceptEnabled) {
206
+ resolveInboxItem(proposal.id, "rejected", "Reviewer did not select this proposal for acceptance.");
207
+ }
208
+ }
209
+ if (autoAcceptEnabled) {
210
+ for (const implicitMemory of review.implicit_memories) {
211
+ rememberAcceptedMemory(implicitMemory.kind, implicitMemory.scope_slug, implicitMemory.payload, "agent:eot", implicitMemory.confidence);
212
+ summary.implicit_extracted++;
213
+ }
214
+ }
215
+ log.info(summary, "memory.eot.processed");
216
+ input.onProcessed?.(summary);
217
+ return summary;
218
+ }
219
+ //# sourceMappingURL=eot.js.map