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.
- package/dist/copilot/agents.js +3 -2
- package/dist/copilot/orchestrator.js +48 -10
- package/dist/copilot/orchestrator.test.js +59 -14
- package/dist/copilot/tools.js +183 -7
- package/dist/copilot/tools.memory.test.js +125 -1
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +108 -0
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/index.js +1 -0
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +103 -16
- package/dist/store/db.test.js +61 -5
- package/package.json +1 -1
- package/web/dist/assets/index-BTI_m0OE.css +10 -0
- package/web/dist/assets/{index-B_cCSHan.js → index-D4-uRAi6.js} +4 -4
- package/web/dist/assets/index-D4-uRAi6.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B_cCSHan.js.map +0 -1
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
|
@@ -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
|
package/dist/memory/index.js
CHANGED
|
@@ -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";
|
package/dist/memory/recall.js
CHANGED
|
@@ -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();
|
package/dist/memory/tiering.js
CHANGED
|
@@ -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
|
-
|
|
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)]);
|