chapterhouse 0.4.0 → 0.4.2

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.
@@ -45,30 +45,30 @@ test("dedupObservationsPass supersedes similar observations in scope determinist
45
45
  const team = getScope("team");
46
46
  assert.ok(chapterhouse && team);
47
47
  const first = recordObservation({
48
- scope_id: chapterhouse.id,
48
+ scope_id: team.id,
49
49
  content: "The worker event stream uses server sent events for live task output.",
50
50
  source: "test",
51
51
  confidence: 0.4,
52
52
  });
53
53
  const keeper = recordObservation({
54
- scope_id: chapterhouse.id,
54
+ scope_id: team.id,
55
55
  content: "Worker event streams use server sent events for live task output.",
56
56
  source: "test",
57
57
  confidence: 0.9,
58
58
  });
59
59
  const third = recordObservation({
60
- scope_id: chapterhouse.id,
60
+ scope_id: team.id,
61
61
  content: "The worker event stream uses server sent events for live task output today.",
62
62
  source: "test",
63
63
  confidence: 0.9,
64
64
  });
65
65
  const otherScope = recordObservation({
66
- scope_id: team.id,
66
+ scope_id: chapterhouse.id,
67
67
  content: "Worker event streams use server sent events for live task output.",
68
68
  source: "test",
69
69
  confidence: 0.1,
70
70
  });
71
- const summary = housekeepingModule.dedupObservationsPass(chapterhouse.id);
71
+ const summary = housekeepingModule.dedupObservationsPass(team.id);
72
72
  assert.equal(summary.pass, "dedupObservationsPass");
73
73
  assert.equal(summary.examined, 3);
74
74
  assert.equal(summary.modified, 2);
@@ -79,7 +79,7 @@ test("dedupObservationsPass supersedes similar observations in scope determinist
79
79
  { id: third.id, superseded_by: keeper.id },
80
80
  ]);
81
81
  assert.equal(db.prepare(`SELECT superseded_by FROM mem_observations WHERE id = ?`).get(otherScope.id).superseded_by, null);
82
- const second = housekeepingModule.dedupObservationsPass(chapterhouse.id);
82
+ const second = housekeepingModule.dedupObservationsPass(team.id);
83
83
  assert.equal(second.modified, 0);
84
84
  });
85
85
  test("dedupDecisionsPass supersedes similar active decisions within scope and keeps the latest decision", async () => {
@@ -88,39 +88,39 @@ test("dedupDecisionsPass supersedes similar active decisions within scope and ke
88
88
  const getScope = getFunction(memoryModule, "getScope");
89
89
  const upsertEntity = getFunction(memoryModule, "upsertEntity");
90
90
  const recordDecision = getFunction(memoryModule, "recordDecision");
91
- const chapterhouse = getScope("chapterhouse");
92
91
  const team = getScope("team");
93
- assert.ok(chapterhouse && team);
94
- const api = upsertEntity({ scope_id: chapterhouse.id, kind: "component", name: "api" });
95
- const web = upsertEntity({ scope_id: chapterhouse.id, kind: "component", name: "web" });
92
+ const infra = getScope("infra");
93
+ assert.ok(team && infra);
94
+ const api = upsertEntity({ scope_id: team.id, kind: "component", name: "api" });
95
+ const web = upsertEntity({ scope_id: team.id, kind: "component", name: "web" });
96
96
  const oldDecision = recordDecision({
97
- scope_id: chapterhouse.id,
97
+ scope_id: team.id,
98
98
  entity_id: api.id,
99
99
  title: "Use SQLite FTS5 for memory recall",
100
100
  rationale: "Initial choice.",
101
101
  decided_at: "2026-05-11",
102
102
  });
103
103
  const keeper = recordDecision({
104
- scope_id: chapterhouse.id,
104
+ scope_id: team.id,
105
105
  entity_id: api.id,
106
106
  title: "Use SQLite FTS5 for scoped memory recall",
107
107
  rationale: "Latest choice.",
108
108
  decided_at: "2026-05-13",
109
109
  });
110
110
  const otherEntity = recordDecision({
111
- scope_id: chapterhouse.id,
111
+ scope_id: team.id,
112
112
  entity_id: web.id,
113
113
  title: "Use SQLite FTS5 for memory recall",
114
114
  rationale: "Same title, different entity context, but newer scope-level decision.",
115
115
  decided_at: "2026-05-14",
116
116
  });
117
117
  const otherScope = recordDecision({
118
- scope_id: team.id,
118
+ scope_id: infra.id,
119
119
  title: "Use SQLite FTS5 for memory recall",
120
120
  rationale: "Same title, different scope.",
121
121
  decided_at: "2026-05-14",
122
122
  });
123
- const summary = housekeepingModule.dedupDecisionsPass(chapterhouse.id);
123
+ const summary = housekeepingModule.dedupDecisionsPass(team.id);
124
124
  assert.equal(summary.pass, "dedupDecisionsPass");
125
125
  assert.equal(summary.examined, 3);
126
126
  assert.equal(summary.modified, 2);
@@ -129,7 +129,7 @@ test("dedupDecisionsPass supersedes similar active decisions within scope and ke
129
129
  assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(keeper.id).superseded_by, otherEntity.id);
130
130
  assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(otherEntity.id).superseded_by, null);
131
131
  assert.equal(db.prepare(`SELECT superseded_by FROM mem_decisions WHERE id = ?`).get(otherScope.id).superseded_by, null);
132
- const second = housekeepingModule.dedupDecisionsPass(chapterhouse.id);
132
+ const second = housekeepingModule.dedupDecisionsPass(team.id);
133
133
  assert.equal(second.modified, 0);
134
134
  });
135
135
  test("orphanCleanupPass clears missing observation entity references without touching valid or out-of-scope rows", async () => {
@@ -225,18 +225,18 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
225
225
  const upsertEntity = getFunction(memoryModule, "upsertEntity");
226
226
  const recordObservation = getFunction(memoryModule, "recordObservation");
227
227
  const recordDecision = getFunction(memoryModule, "recordDecision");
228
- const chapterhouse = getScope("chapterhouse");
229
- assert.ok(chapterhouse);
230
- const entity = upsertEntity({ scope_id: chapterhouse.id, kind: "component", name: "memory", tier: "warm" });
228
+ const team = getScope("team");
229
+ assert.ok(team);
230
+ const entity = upsertEntity({ scope_id: team.id, kind: "component", name: "memory", tier: "warm" });
231
231
  const referencedObservation = recordObservation({
232
- scope_id: chapterhouse.id,
232
+ scope_id: team.id,
233
233
  entity_id: entity.id,
234
234
  content: "Referenced by a recent decision through its entity.",
235
235
  source: "test",
236
236
  tier: "warm",
237
237
  });
238
238
  const recentDecision = recordDecision({
239
- scope_id: chapterhouse.id,
239
+ scope_id: team.id,
240
240
  entity_id: entity.id,
241
241
  title: "Restate memory tiering decision",
242
242
  rationale: "Recent entity-linked decisions keep related observations hot.",
@@ -244,20 +244,20 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
244
244
  tier: "warm",
245
245
  });
246
246
  const oldHot = recordObservation({
247
- scope_id: chapterhouse.id,
247
+ scope_id: team.id,
248
248
  content: "Old hot row with no recall activity should cool down.",
249
249
  source: "test",
250
250
  tier: "hot",
251
251
  });
252
252
  const staleLowConfidence = recordObservation({
253
- scope_id: chapterhouse.id,
253
+ scope_id: team.id,
254
254
  content: "Low confidence stale row should go cold.",
255
255
  source: "test",
256
256
  tier: "warm",
257
257
  confidence: 0.2,
258
258
  });
259
259
  const archived = recordObservation({
260
- scope_id: chapterhouse.id,
260
+ scope_id: team.id,
261
261
  content: "Archived row should always be cold.",
262
262
  source: "test",
263
263
  tier: "hot",
@@ -265,7 +265,7 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
265
265
  db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-45 days') WHERE id = ?`).run(oldHot.id);
266
266
  db.prepare(`UPDATE mem_observations SET created_at = datetime('now', '-61 days') WHERE id = ?`).run(staleLowConfidence.id);
267
267
  db.prepare(`UPDATE mem_observations SET archived_at = CURRENT_TIMESTAMP WHERE id = ?`).run(archived.id);
268
- const summary = housekeepingModule.tieringPass(chapterhouse.id);
268
+ const summary = housekeepingModule.tieringPass(team.id);
269
269
  assert.equal(summary.pass, "tieringPass");
270
270
  assert.equal(summary.modified, 5);
271
271
  assert.deepEqual(summary.errors, []);
@@ -274,7 +274,7 @@ test("tieringPass promotes and demotes rows from lifecycle signals and is idempo
274
274
  assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(oldHot.id).tier, "warm");
275
275
  assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(staleLowConfidence.id).tier, "cold");
276
276
  assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(archived.id).tier, "cold");
277
- const second = housekeepingModule.tieringPass(chapterhouse.id);
277
+ const second = housekeepingModule.tieringPass(team.id);
278
278
  assert.equal(second.modified, 0);
279
279
  });
280
280
  //# sourceMappingURL=housekeeping.test.js.map
@@ -1,6 +1,17 @@
1
1
  import { config } from "../config.js";
2
2
  import { getDb, isFts5Available } from "../store/db.js";
3
3
  import { getActiveScope } from "./active-scope.js";
4
+ function quoteFts5QueryTerms(query) {
5
+ return query
6
+ .trim()
7
+ .split(/\s+/)
8
+ .filter((term) => term.length > 0)
9
+ .map((term) => {
10
+ const unquoted = term.replace(/^["']|["']$/g, "");
11
+ return `"${unquoted.replace(/"/g, "\"\"")}"`;
12
+ })
13
+ .join(" ");
14
+ }
4
15
  function recallHotTier(scopeId, options = {}) {
5
16
  const rows = getDb().prepare(`
6
17
  SELECT 'observation' AS kind, id, content
@@ -25,6 +36,7 @@ function recallHotTier(scopeId, options = {}) {
25
36
  }
26
37
  function recallObservationHits(query, scopeId, options = {}) {
27
38
  if (isFts5Available()) {
39
+ const ftsQuery = quoteFts5QueryTerms(query);
28
40
  const rows = getDb().prepare(`
29
41
  SELECT
30
42
  o.id,
@@ -43,7 +55,7 @@ function recallObservationHits(query, scopeId, options = {}) {
43
55
  AND (? = 1 OR o.superseded_by IS NULL)
44
56
  AND (? = 1 OR o.archived_at IS NULL)
45
57
  ORDER BY score DESC, o.id DESC
46
- `).all(config.memoryHotRecallBoost, query, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
58
+ `).all(config.memoryHotRecallBoost, ftsQuery, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
47
59
  return rows.map((row) => ({
48
60
  kind: "observation",
49
61
  id: row.id,
@@ -78,6 +90,7 @@ function recallObservationHits(query, scopeId, options = {}) {
78
90
  }
79
91
  function recallDecisionHits(query, scopeId, options = {}) {
80
92
  if (isFts5Available()) {
93
+ const ftsQuery = quoteFts5QueryTerms(query);
81
94
  const rows = getDb().prepare(`
82
95
  SELECT
83
96
  d.id,
@@ -99,7 +112,7 @@ function recallDecisionHits(query, scopeId, options = {}) {
99
112
  AND (? = 1 OR d.superseded_by IS NULL)
100
113
  AND (? = 1 OR d.archived_at IS NULL)
101
114
  ORDER BY score DESC, d.id DESC
102
- `).all(config.memoryHotRecallBoost, query, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
115
+ `).all(config.memoryHotRecallBoost, ftsQuery, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, options.includeSuperseded ? 1 : 0, options.includeArchived ? 1 : 0);
103
116
  return rows.map((row) => ({
104
117
  kind: "decision",
105
118
  id: row.id,
@@ -193,4 +193,46 @@ test("recall boosts hot rows and excludes cold rows unless includeCold is set",
193
193
  const withCold = recall({ query: "tierboost sentinel", scope_id: chapterhouse.id, kinds: ["observation"], limit: 10, includeCold: true });
194
194
  assert.equal(withCold.hits.some((hit) => hit.id === cold.id), true);
195
195
  });
196
+ test("recall matches multi-word observation queries with exact tokens", async () => {
197
+ const { dbModule, memoryModule } = await loadModules();
198
+ dbModule.getDb();
199
+ const getScope = getFunction(memoryModule, "getScope");
200
+ const recordObservation = getFunction(memoryModule, "recordObservation");
201
+ const recall = getFunction(memoryModule, "recall");
202
+ const chapterhouse = getScope("chapterhouse");
203
+ assert.ok(chapterhouse);
204
+ const observation = recordObservation({
205
+ scope_id: chapterhouse.id,
206
+ content: "Chapterhouse memory P1 shipped on 2026-05-13",
207
+ source: "test",
208
+ });
209
+ const result = recall({
210
+ query: "memory P1 shipped",
211
+ scope_id: chapterhouse.id,
212
+ kinds: ["observation"],
213
+ limit: 10,
214
+ });
215
+ assert.equal(result.hits.some((hit) => hit.id === observation.id), true);
216
+ });
217
+ test("recall treats hyphenated observation query terms literally", async () => {
218
+ const { dbModule, memoryModule } = await loadModules();
219
+ dbModule.getDb();
220
+ const getScope = getFunction(memoryModule, "getScope");
221
+ const recordObservation = getFunction(memoryModule, "recordObservation");
222
+ const recall = getFunction(memoryModule, "recall");
223
+ const chapterhouse = getScope("chapterhouse");
224
+ assert.ok(chapterhouse);
225
+ const observation = recordObservation({
226
+ scope_id: chapterhouse.id,
227
+ content: "Chapterhouse agent-memory recall shipped safely",
228
+ source: "test",
229
+ });
230
+ const result = recall({
231
+ query: "agent-memory",
232
+ scope_id: chapterhouse.id,
233
+ kinds: ["observation"],
234
+ limit: 10,
235
+ });
236
+ assert.equal(result.hits.some((hit) => hit.id === observation.id), true);
237
+ });
196
238
  //# sourceMappingURL=recall.test.js.map