chapterhouse 0.4.2 → 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.
@@ -0,0 +1,152 @@
1
+ import { childLogger } from "../util/logger.js";
2
+ import { isHousekeepingInFlight, runHousekeeping } from "./housekeeping.js";
3
+ export const DEFAULT_MEMORY_HOUSEKEEP_INTERVAL_MS = 21_600_000;
4
+ export const DEFAULT_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS = 300_000;
5
+ function parseNonNegativeIntegerEnv(name, rawValue, defaultValue) {
6
+ const normalized = rawValue?.trim();
7
+ if (!normalized) {
8
+ return defaultValue;
9
+ }
10
+ const parsed = Number(normalized);
11
+ if (!Number.isInteger(parsed) || parsed < 0) {
12
+ throw new Error(`${name} must be a non-negative integer, got: "${rawValue}"`);
13
+ }
14
+ return parsed;
15
+ }
16
+ function toPassCounts(result) {
17
+ return Object.fromEntries(result.summaries.map((summary) => [
18
+ summary.pass,
19
+ {
20
+ examined: summary.examined,
21
+ modified: summary.modified,
22
+ errors: summary.errors,
23
+ },
24
+ ]));
25
+ }
26
+ export class MemoryHousekeepingScheduler {
27
+ env;
28
+ runHousekeepingImpl;
29
+ log;
30
+ setTimeoutImpl;
31
+ clearTimeoutImpl;
32
+ setIntervalImpl;
33
+ clearIntervalImpl;
34
+ timeoutHandle;
35
+ intervalHandle;
36
+ activeRun;
37
+ running = false;
38
+ started = false;
39
+ constructor(options = {}) {
40
+ this.env = options.env ?? process.env;
41
+ this.runHousekeepingImpl = options.runHousekeeping ?? runHousekeeping;
42
+ this.log = options.log ?? childLogger("memory.housekeeping.scheduler");
43
+ this.setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
44
+ this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle));
45
+ this.setIntervalImpl = options.setIntervalImpl ?? setInterval;
46
+ this.clearIntervalImpl = options.clearIntervalImpl ?? ((handle) => clearInterval(handle));
47
+ }
48
+ start() {
49
+ if (this.started) {
50
+ return;
51
+ }
52
+ const intervalMs = parseNonNegativeIntegerEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS", this.env.CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS, DEFAULT_MEMORY_HOUSEKEEP_INTERVAL_MS);
53
+ if (intervalMs === 0) {
54
+ this.log.info({ interval_ms: intervalMs }, "Memory housekeeping scheduler disabled");
55
+ return;
56
+ }
57
+ const initialDelayMs = parseNonNegativeIntegerEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS", this.env.CHAPTERHOUSE_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS, DEFAULT_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS);
58
+ this.started = true;
59
+ this.timeoutHandle = this.setTimeoutImpl(() => {
60
+ this.timeoutHandle = undefined;
61
+ this.startScheduledRun("initial_delay");
62
+ this.intervalHandle = this.setIntervalImpl(() => {
63
+ this.startScheduledRun("interval");
64
+ }, intervalMs);
65
+ this.intervalHandle?.unref?.();
66
+ }, initialDelayMs);
67
+ this.timeoutHandle?.unref?.();
68
+ this.log.info({ interval_ms: intervalMs, initial_delay_ms: initialDelayMs }, "Memory housekeeping scheduler started");
69
+ }
70
+ async stop() {
71
+ if (this.timeoutHandle) {
72
+ this.clearTimeoutImpl(this.timeoutHandle);
73
+ this.timeoutHandle = undefined;
74
+ }
75
+ if (this.intervalHandle) {
76
+ this.clearIntervalImpl(this.intervalHandle);
77
+ this.intervalHandle = undefined;
78
+ }
79
+ this.started = false;
80
+ await this.activeRun;
81
+ }
82
+ startScheduledRun(trigger) {
83
+ const run = this.runScheduledHousekeeping(trigger);
84
+ const tracked = run.finally(() => {
85
+ if (this.activeRun === tracked) {
86
+ this.activeRun = undefined;
87
+ }
88
+ });
89
+ this.activeRun = tracked;
90
+ void this.activeRun;
91
+ }
92
+ async runScheduledHousekeeping(trigger) {
93
+ if (this.running || isHousekeepingInFlight()) {
94
+ this.log.warn({ trigger }, "Memory housekeeping run skipped because a previous run is still active");
95
+ return;
96
+ }
97
+ this.running = true;
98
+ try {
99
+ const result = await this.runHousekeepingImpl({ allScopes: true });
100
+ if (isHousekeepingRunResult(result)) {
101
+ if (isContentionResult(result)) {
102
+ this.log.warn({
103
+ trigger,
104
+ scope_ids: result.scopeIds,
105
+ summaries: result.summaries,
106
+ }, "Memory housekeeping run skipped because a previous run is still active");
107
+ return;
108
+ }
109
+ this.log.info({
110
+ trigger,
111
+ scope_ids: result.scopeIds,
112
+ summaries: result.summaries,
113
+ pass_counts: toPassCounts(result),
114
+ total_examined: result.totalExamined,
115
+ total_modified: result.totalModified,
116
+ duration_ms: result.durationMs,
117
+ }, "Memory housekeeping scheduled run complete");
118
+ }
119
+ else {
120
+ this.log.info({ trigger }, "Memory housekeeping scheduled run complete");
121
+ }
122
+ }
123
+ catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ if (this.log.error) {
126
+ this.log.error({ trigger, err: message }, "Memory housekeeping scheduled run failed");
127
+ }
128
+ else {
129
+ this.log.warn({ trigger, err: message }, "Memory housekeeping scheduled run failed");
130
+ }
131
+ }
132
+ finally {
133
+ this.running = false;
134
+ }
135
+ }
136
+ }
137
+ function isContentionResult(result) {
138
+ return result.summaries.some((summary) => (summary.pass === "runHousekeeping"
139
+ && summary.errors.some((error) => /already in flight/i.test(error))));
140
+ }
141
+ function isHousekeepingRunResult(value) {
142
+ if (!value || typeof value !== "object") {
143
+ return false;
144
+ }
145
+ const candidate = value;
146
+ return Array.isArray(candidate.scopeIds)
147
+ && Array.isArray(candidate.summaries)
148
+ && typeof candidate.totalExamined === "number"
149
+ && typeof candidate.totalModified === "number"
150
+ && typeof candidate.durationMs === "number";
151
+ }
152
+ //# sourceMappingURL=housekeeping-scheduler.js.map
@@ -0,0 +1,187 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ async function loadSchedulerModule() {
4
+ return await import(new URL(`./housekeeping-scheduler.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
5
+ }
6
+ function createTimers() {
7
+ let nextId = 1;
8
+ const timeouts = [];
9
+ const intervals = [];
10
+ return {
11
+ timeouts,
12
+ intervals,
13
+ setTimeoutImpl(callback, delayMs) {
14
+ const handle = { kind: "timeout", id: nextId++, unref() { } };
15
+ timeouts.push({ handle, callback, delayMs, cleared: false });
16
+ return handle;
17
+ },
18
+ clearTimeoutImpl(handle) {
19
+ const entry = timeouts.find((item) => item.handle === handle);
20
+ if (entry)
21
+ entry.cleared = true;
22
+ },
23
+ setIntervalImpl(callback, delayMs) {
24
+ const handle = { kind: "interval", id: nextId++, unref() { } };
25
+ intervals.push({ handle, callback, delayMs, cleared: false });
26
+ return handle;
27
+ },
28
+ clearIntervalImpl(handle) {
29
+ const entry = intervals.find((item) => item.handle === handle);
30
+ if (entry)
31
+ entry.cleared = true;
32
+ },
33
+ };
34
+ }
35
+ test("MemoryHousekeepingScheduler registers with the default 6h interval when env is unset", async () => {
36
+ const schedulerModule = await loadSchedulerModule();
37
+ const timers = createTimers();
38
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
39
+ env: {},
40
+ runHousekeeping: () => ({ scopeIds: [], summaries: [], totalExamined: 0, totalModified: 0, durationMs: 0 }),
41
+ setTimeoutImpl: timers.setTimeoutImpl,
42
+ clearTimeoutImpl: timers.clearTimeoutImpl,
43
+ setIntervalImpl: timers.setIntervalImpl,
44
+ clearIntervalImpl: timers.clearIntervalImpl,
45
+ });
46
+ scheduler.start();
47
+ timers.timeouts[0]?.callback();
48
+ assert.equal(schedulerModule.DEFAULT_MEMORY_HOUSEKEEP_INTERVAL_MS, 21_600_000);
49
+ assert.equal(timers.intervals.length, 1);
50
+ assert.equal(timers.intervals[0]?.delayMs, 21_600_000);
51
+ });
52
+ test("MemoryHousekeepingScheduler is disabled when CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS is 0", async () => {
53
+ const schedulerModule = await loadSchedulerModule();
54
+ const timers = createTimers();
55
+ let runs = 0;
56
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
57
+ env: { CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS: "0" },
58
+ runHousekeeping: () => {
59
+ runs += 1;
60
+ },
61
+ setTimeoutImpl: timers.setTimeoutImpl,
62
+ clearTimeoutImpl: timers.clearTimeoutImpl,
63
+ setIntervalImpl: timers.setIntervalImpl,
64
+ clearIntervalImpl: timers.clearIntervalImpl,
65
+ });
66
+ scheduler.start();
67
+ assert.equal(timers.timeouts.length, 0);
68
+ assert.equal(timers.intervals.length, 0);
69
+ assert.equal(runs, 0);
70
+ });
71
+ test("MemoryHousekeepingScheduler waits for the initial delay before the first run", async () => {
72
+ const schedulerModule = await loadSchedulerModule();
73
+ const timers = createTimers();
74
+ let runs = 0;
75
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
76
+ env: {},
77
+ runHousekeeping: () => {
78
+ runs += 1;
79
+ },
80
+ setTimeoutImpl: timers.setTimeoutImpl,
81
+ clearTimeoutImpl: timers.clearTimeoutImpl,
82
+ setIntervalImpl: timers.setIntervalImpl,
83
+ clearIntervalImpl: timers.clearIntervalImpl,
84
+ });
85
+ scheduler.start();
86
+ assert.equal(schedulerModule.DEFAULT_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS, 300_000);
87
+ assert.equal(timers.timeouts.length, 1);
88
+ assert.equal(timers.timeouts[0]?.delayMs, 300_000);
89
+ assert.equal(runs, 0);
90
+ timers.timeouts[0]?.callback();
91
+ await Promise.resolve();
92
+ assert.equal(runs, 1);
93
+ });
94
+ test("MemoryHousekeepingScheduler does not overlap runs when an interval fires during an active run", async () => {
95
+ const schedulerModule = await loadSchedulerModule();
96
+ const timers = createTimers();
97
+ const warnings = [];
98
+ let runs = 0;
99
+ let releaseRun;
100
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
101
+ env: {
102
+ CHAPTERHOUSE_MEMORY_HOUSEKEEP_INITIAL_DELAY_MS: "1",
103
+ CHAPTERHOUSE_MEMORY_HOUSEKEEP_INTERVAL_MS: "2",
104
+ },
105
+ runHousekeeping: () => {
106
+ runs += 1;
107
+ return new Promise((resolve) => {
108
+ releaseRun = resolve;
109
+ });
110
+ },
111
+ log: {
112
+ info: () => { },
113
+ warn: (_obj, msg) => warnings.push(msg),
114
+ },
115
+ setTimeoutImpl: timers.setTimeoutImpl,
116
+ clearTimeoutImpl: timers.clearTimeoutImpl,
117
+ setIntervalImpl: timers.setIntervalImpl,
118
+ clearIntervalImpl: timers.clearIntervalImpl,
119
+ });
120
+ scheduler.start();
121
+ timers.timeouts[0]?.callback();
122
+ await Promise.resolve();
123
+ timers.intervals[0]?.callback();
124
+ await Promise.resolve();
125
+ assert.equal(runs, 1);
126
+ assert.ok(warnings.some((entry) => entry.includes("Memory housekeeping run skipped")));
127
+ releaseRun?.();
128
+ await Promise.resolve();
129
+ timers.intervals[0]?.callback();
130
+ await Promise.resolve();
131
+ assert.equal(runs, 2);
132
+ });
133
+ test("MemoryHousekeepingScheduler stop clears pending initial-delay and active interval timers", async () => {
134
+ const schedulerModule = await loadSchedulerModule();
135
+ const timers = createTimers();
136
+ const beforeInitialRun = new schedulerModule.MemoryHousekeepingScheduler({
137
+ env: {},
138
+ runHousekeeping: () => { },
139
+ setTimeoutImpl: timers.setTimeoutImpl,
140
+ clearTimeoutImpl: timers.clearTimeoutImpl,
141
+ setIntervalImpl: timers.setIntervalImpl,
142
+ clearIntervalImpl: timers.clearIntervalImpl,
143
+ });
144
+ beforeInitialRun.start();
145
+ await beforeInitialRun.stop();
146
+ assert.equal(timers.timeouts[0]?.cleared, true);
147
+ const afterInitialRun = new schedulerModule.MemoryHousekeepingScheduler({
148
+ env: {},
149
+ runHousekeeping: () => { },
150
+ setTimeoutImpl: timers.setTimeoutImpl,
151
+ clearTimeoutImpl: timers.clearTimeoutImpl,
152
+ setIntervalImpl: timers.setIntervalImpl,
153
+ clearIntervalImpl: timers.clearIntervalImpl,
154
+ });
155
+ afterInitialRun.start();
156
+ timers.timeouts[1]?.callback();
157
+ await afterInitialRun.stop();
158
+ assert.equal(timers.intervals[0]?.cleared, true);
159
+ });
160
+ test("MemoryHousekeepingScheduler stop waits for an in-flight run to finish without aborting it", async () => {
161
+ const schedulerModule = await loadSchedulerModule();
162
+ const timers = createTimers();
163
+ let releaseRun;
164
+ let stopResolved = false;
165
+ const scheduler = new schedulerModule.MemoryHousekeepingScheduler({
166
+ env: {},
167
+ runHousekeeping: () => new Promise((resolve) => {
168
+ releaseRun = resolve;
169
+ }),
170
+ setTimeoutImpl: timers.setTimeoutImpl,
171
+ clearTimeoutImpl: timers.clearTimeoutImpl,
172
+ setIntervalImpl: timers.setIntervalImpl,
173
+ clearIntervalImpl: timers.clearIntervalImpl,
174
+ });
175
+ scheduler.start();
176
+ timers.timeouts[0]?.callback();
177
+ await Promise.resolve();
178
+ const stopped = scheduler.stop().then(() => {
179
+ stopResolved = true;
180
+ });
181
+ await Promise.resolve();
182
+ assert.equal(stopResolved, false);
183
+ releaseRun?.();
184
+ await stopped;
185
+ assert.equal(stopResolved, true);
186
+ });
187
+ //# sourceMappingURL=housekeeping-scheduler.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)]);