chapterhouse 0.4.1 → 0.4.3

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,3 +1,4 @@
1
+ export { completeActionItem, dropActionItem, getActionItem, listActionItems, recordActionItem, snoozeActionItem, } from "./action-items.js";
1
2
  export { getActiveScope, inferScopeFromText, setActiveScope } from "./active-scope.js";
2
3
  export { recordDecision, getDecision, listDecisions, supersedeDecision } from "./decisions.js";
3
4
  export { getEntity, findEntityByName, listEntities, upsertEntity } from "./entities.js";
@@ -169,6 +169,63 @@ function recallEntityHits(query, scopeId, options = {}) {
169
169
  snippet: `${row.name}${row.summary ? ` — ${row.summary}` : ""}`,
170
170
  }));
171
171
  }
172
+ function recallActionItemHits(query, scopeId, options = {}) {
173
+ if (isFts5Available()) {
174
+ const ftsQuery = quoteFts5QueryTerms(query);
175
+ const rows = getDb().prepare(`
176
+ SELECT
177
+ a.id,
178
+ a.scope_id,
179
+ s.slug AS scope,
180
+ a.title,
181
+ a.detail,
182
+ a.tier,
183
+ -bm25(mem_action_items_fts) * CASE WHEN a.tier = 'hot' THEN ? ELSE 1 END AS score,
184
+ snippet(mem_action_items_fts, 0, '[', ']', '…', 8) || COALESCE(' — ' ||
185
+ snippet(mem_action_items_fts, 1, '[', ']', '…', 12), '') AS snippet
186
+ FROM mem_action_items a
187
+ JOIN mem_scopes s ON s.id = a.scope_id
188
+ JOIN mem_action_items_fts ON mem_action_items_fts.rowid = a.id
189
+ WHERE mem_action_items_fts MATCH ?
190
+ AND (? IS NULL OR a.scope_id = ?)
191
+ AND (? = 1 OR a.tier != 'cold')
192
+ AND a.status IN ('open', 'snoozed')
193
+ ORDER BY score DESC, a.id DESC
194
+ `).all(config.memoryHotRecallBoost, ftsQuery, scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0);
195
+ return rows.map((row) => ({
196
+ kind: "action_item",
197
+ id: row.id,
198
+ scopeId: row.scope_id,
199
+ scope: row.scope,
200
+ content: `${row.title}${row.detail ? ` — ${row.detail}` : ""}`,
201
+ score: row.score,
202
+ snippet: row.snippet ?? `${row.title}${row.detail ? ` — ${row.detail}` : ""}`,
203
+ }));
204
+ }
205
+ const pattern = `%${query}%`;
206
+ const rows = getDb().prepare(`
207
+ SELECT a.id, a.scope_id, s.slug AS scope, a.title, a.detail, a.tier
208
+ FROM mem_action_items a
209
+ JOIN mem_scopes s ON s.id = a.scope_id
210
+ WHERE (? IS NULL OR a.scope_id = ?)
211
+ AND (? = 1 OR a.tier != 'cold')
212
+ AND a.status IN ('open', 'snoozed')
213
+ AND (a.title LIKE ? OR COALESCE(a.detail, '') LIKE ?)
214
+ ORDER BY
215
+ CASE WHEN a.due_at IS NULL THEN 1 ELSE 0 END ASC,
216
+ datetime(a.due_at) ASC,
217
+ a.id DESC
218
+ `).all(scopeId ?? null, scopeId ?? null, options.includeCold ? 1 : 0, pattern, pattern);
219
+ return rows.map((row) => ({
220
+ kind: "action_item",
221
+ id: row.id,
222
+ scopeId: row.scope_id,
223
+ scope: row.scope,
224
+ content: `${row.title}${row.detail ? ` — ${row.detail}` : ""}`,
225
+ score: row.tier === "hot" ? config.memoryHotRecallBoost : 1,
226
+ snippet: `${row.title}${row.detail ? ` — ${row.detail}` : ""}`,
227
+ }));
228
+ }
172
229
  export function recall(input) {
173
230
  const activeScope = getActiveScope();
174
231
  const effectiveScopeId = input.scope_id ?? activeScope?.id;
@@ -180,6 +237,7 @@ export function recall(input) {
180
237
  ...(requestedKinds.has("observation") ? recallObservationHits(input.query, effectiveScopeId, input) : []),
181
238
  ...(requestedKinds.has("decision") ? recallDecisionHits(input.query, effectiveScopeId, input) : []),
182
239
  ...(requestedKinds.has("entity") ? recallEntityHits(input.query, effectiveScopeId, input) : []),
240
+ ...(requestedKinds.has("action_item") ? recallActionItemHits(input.query, effectiveScopeId, input) : []),
183
241
  ]
184
242
  .sort((a, b) => {
185
243
  if (b.score !== a.score)
@@ -193,6 +251,7 @@ export function recall(input) {
193
251
  observation: "mem_observations",
194
252
  decision: "mem_decisions",
195
253
  entity: "mem_entities",
254
+ action_item: "mem_action_items",
196
255
  };
197
256
  for (const kind of Object.keys(tables)) {
198
257
  const ids = hits.filter((hit) => hit.kind === kind).map((hit) => hit.id);
@@ -96,6 +96,33 @@ test("recall returns active-scope hot-tier entries before ranked FTS hits and re
96
96
  });
97
97
  assert.equal(limited.hits.length, 1);
98
98
  });
99
+ test("recall can opt into action item hits without adding them to default kind searches", async () => {
100
+ const { dbModule, memoryModule } = await loadModules();
101
+ dbModule.getDb();
102
+ const getScope = getFunction(memoryModule, "getScope");
103
+ const recordActionItem = getFunction(memoryModule, "recordActionItem");
104
+ const recall = getFunction(memoryModule, "recall");
105
+ const chapterhouse = getScope("chapterhouse");
106
+ assert.ok(chapterhouse);
107
+ const actionItem = recordActionItem({
108
+ scope_id: chapterhouse.id,
109
+ title: "Bellonda disk alert",
110
+ detail: "Next time disk usage exceeds 85 percent, notify infra.",
111
+ source: "test",
112
+ });
113
+ const defaults = recall({ query: "Bellonda disk alert", scope_id: chapterhouse.id, limit: 10 });
114
+ assert.equal(defaults.hits.some((hit) => hit.kind === "action_item" && hit.id === actionItem.id), false);
115
+ const actionOnly = recall({
116
+ query: "Bellonda disk alert",
117
+ scope_id: chapterhouse.id,
118
+ kinds: ["action_item"],
119
+ limit: 10,
120
+ });
121
+ assert.equal(actionOnly.hits.length, 1);
122
+ assert.equal(actionOnly.hits[0]?.kind, "action_item");
123
+ assert.equal(actionOnly.hits[0]?.id, actionItem.id);
124
+ assert.match(actionOnly.hits[0]?.content ?? "", /Bellonda disk alert/);
125
+ });
99
126
  test("recall excludes superseded and archived rows by default with opt-in inclusion", async () => {
100
127
  const { dbModule, memoryModule } = await loadModules();
101
128
  const db = dbModule.getDb();
@@ -6,6 +6,7 @@ const TABLES = {
6
6
  observation: "mem_observations",
7
7
  decision: "mem_decisions",
8
8
  entity: "mem_entities",
9
+ action_item: "mem_action_items",
9
10
  };
10
11
  function dbTable(table) {
11
12
  return TABLES[table];
@@ -71,8 +72,9 @@ export function tieringPass(scopeId) {
71
72
  SELECT
72
73
  (SELECT COUNT(*) FROM mem_observations WHERE scope_id = ?) AS observations,
73
74
  (SELECT COUNT(*) FROM mem_decisions WHERE scope_id = ?) AS decisions,
74
- (SELECT COUNT(*) FROM mem_entities WHERE scope_id = ?) AS entities
75
- `).get(scopeId, scopeId, scopeId);
75
+ (SELECT COUNT(*) FROM mem_entities WHERE scope_id = ?) AS entities,
76
+ (SELECT COUNT(*) FROM mem_action_items WHERE scope_id = ?) AS action_items
77
+ `).get(scopeId, scopeId, scopeId, scopeId);
76
78
  let modified = 0;
77
79
  const tx = db.transaction(() => {
78
80
  modified += updateTier(`
@@ -163,6 +165,34 @@ export function tieringPass(scopeId) {
163
165
  AND superseded_by IS NULL
164
166
  AND last_recalled_at IS NULL
165
167
  AND datetime(decided_at) < datetime('now', ?)
168
+ `, [scopeId, `-${config.memoryHotAgeDays} days`]);
169
+ modified += updateTier(`
170
+ UPDATE mem_action_items
171
+ SET tier = 'cold', tier_reason = 'resolved action item'
172
+ WHERE scope_id = ?
173
+ AND tier != 'cold'
174
+ AND tier_pinned_at IS NULL
175
+ AND status IN ('done', 'dropped')
176
+ `, [scopeId]);
177
+ modified += updateTier(`
178
+ UPDATE mem_action_items
179
+ SET tier = 'hot', tier_reason = 'open action item due soon'
180
+ WHERE scope_id = ?
181
+ AND tier != 'hot'
182
+ AND tier_pinned_at IS NULL
183
+ AND status = 'open'
184
+ AND due_at IS NOT NULL
185
+ AND datetime(due_at) <= datetime('now', '+7 days')
186
+ `, [scopeId]);
187
+ modified += updateTier(`
188
+ UPDATE mem_action_items
189
+ SET tier = 'warm', tier_reason = 'hot age threshold without recall'
190
+ WHERE scope_id = ?
191
+ AND tier = 'hot'
192
+ AND tier_pinned_at IS NULL
193
+ AND status = 'open'
194
+ AND last_recalled_at IS NULL
195
+ AND datetime(created_at) < datetime('now', ?)
166
196
  `, [scopeId, `-${config.memoryHotAgeDays} days`]);
167
197
  modified += updateTier(`
168
198
  UPDATE mem_observations
@@ -184,7 +214,7 @@ export function tieringPass(scopeId) {
184
214
  `, [scopeId]);
185
215
  });
186
216
  tx();
187
- return passSummary("tieringPass", counts.observations + counts.decisions + counts.entities, modified);
217
+ return passSummary("tieringPass", counts.observations + counts.decisions + counts.entities + counts.action_items, modified);
188
218
  }
189
219
  catch (error) {
190
220
  return passSummary("tieringPass", 0, 0, [error instanceof Error ? error.message : String(error)]);