chapterhouse 0.5.0 → 0.5.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.
- package/dist/api/server.js +31 -3
- package/dist/api/server.test.js +48 -5
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +44 -0
- package/dist/copilot/orchestrator.js +17 -6
- package/dist/copilot/orchestrator.test.js +50 -3
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +30 -18
- package/dist/copilot/system-message.js +3 -3
- package/dist/copilot/system-message.test.js +10 -0
- package/dist/copilot/tools.js +79 -12
- package/dist/copilot/tools.memory.test.js +94 -6
- package/dist/daemon.js +7 -2
- package/dist/integrations/team-push.js +8 -1
- package/dist/integrations/teams-notify.js +8 -1
- package/dist/memory/active-scope.test.js +7 -2
- package/dist/memory/eot.js +96 -8
- package/dist/memory/eot.test.js +186 -5
- package/dist/memory/hot-tier.test.js +14 -4
- package/dist/memory/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +115 -16
- package/dist/memory/inbox.test.js +178 -0
- package/dist/memory/scopes.test.js +0 -24
- package/dist/memory/tiering.test.js +323 -0
- package/dist/mode-context.js +28 -0
- package/dist/mode-context.test.js +42 -0
- package/dist/setup.js +152 -95
- package/dist/setup.test.js +122 -0
- package/dist/store/db.js +0 -18
- package/dist/wiki/team-sync.js +8 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-BfHqP3-C.js → index-CPaILy2j.js} +69 -69
- package/web/dist/assets/index-CPaILy2j.js.map +1 -0
- package/web/dist/assets/index-Cs7AGeaL.css +10 -0
- package/web/dist/index.html +2 -2
- package/agents/bellonda.agent.md +0 -11
- package/agents/hwi-noree.agent.md +0 -12
- package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
- package/web/dist/assets/index-_O6AoWOS.css +0 -10
package/dist/memory/eot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
+
import { getAgent, loadAgents } from "../copilot/agents.js";
|
|
2
3
|
import { runOneShotPrompt } from "../copilot/oneshot.js";
|
|
3
4
|
import { childLogger } from "../util/logger.js";
|
|
4
5
|
import { recordDecision } from "./decisions.js";
|
|
@@ -6,6 +7,7 @@ import { upsertEntity } from "./entities.js";
|
|
|
6
7
|
import { listPendingMemoryProposalsForTask, resolveInboxItem } from "./inbox.js";
|
|
7
8
|
import { recordObservation } from "./observations.js";
|
|
8
9
|
import { recordActionItem } from "./action-items.js";
|
|
10
|
+
import { getActiveScope } from "./active-scope.js";
|
|
9
11
|
import { getScope } from "./scopes.js";
|
|
10
12
|
const log = childLogger("memory.eot");
|
|
11
13
|
function isEndOfTaskHookEnabled() {
|
|
@@ -119,7 +121,75 @@ function parseReviewerResponse(raw) {
|
|
|
119
121
|
: [],
|
|
120
122
|
};
|
|
121
123
|
}
|
|
122
|
-
function
|
|
124
|
+
function isNonEmptyString(value) {
|
|
125
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
126
|
+
}
|
|
127
|
+
function isIsoTimestamp(value) {
|
|
128
|
+
return !Number.isNaN(Date.parse(value));
|
|
129
|
+
}
|
|
130
|
+
function validateActionItemPayload(payload) {
|
|
131
|
+
const actionItem = payload;
|
|
132
|
+
if (!isNonEmptyString(actionItem.title)) {
|
|
133
|
+
throw new Error("Action item proposal payload requires a non-empty title.");
|
|
134
|
+
}
|
|
135
|
+
if (actionItem.detail !== undefined && typeof actionItem.detail !== "string") {
|
|
136
|
+
throw new Error("Action item proposal payload detail must be a string.");
|
|
137
|
+
}
|
|
138
|
+
if (actionItem.due_at !== undefined && (typeof actionItem.due_at !== "string" || !isIsoTimestamp(actionItem.due_at))) {
|
|
139
|
+
throw new Error("Action item proposal payload due_at must be an ISO timestamp.");
|
|
140
|
+
}
|
|
141
|
+
if (actionItem.entity_id !== undefined && (!Number.isInteger(actionItem.entity_id) || actionItem.entity_id <= 0)) {
|
|
142
|
+
throw new Error("Action item proposal payload entity_id must be a positive integer.");
|
|
143
|
+
}
|
|
144
|
+
if (actionItem.entity_id !== undefined && actionItem.entity_name !== undefined) {
|
|
145
|
+
throw new Error("Action item proposal payload must provide either entity_id or entity_name/entity_kind, not both.");
|
|
146
|
+
}
|
|
147
|
+
const hasEntityName = actionItem.entity_name !== undefined;
|
|
148
|
+
const hasEntityKind = actionItem.entity_kind !== undefined;
|
|
149
|
+
if (hasEntityName !== hasEntityKind) {
|
|
150
|
+
throw new Error("Action item proposal payload entity_name and entity_kind must be provided together.");
|
|
151
|
+
}
|
|
152
|
+
if (hasEntityName && !isNonEmptyString(actionItem.entity_name)) {
|
|
153
|
+
throw new Error("Action item proposal payload entity_name must be non-empty when provided.");
|
|
154
|
+
}
|
|
155
|
+
if (hasEntityKind && !isNonEmptyString(actionItem.entity_kind)) {
|
|
156
|
+
throw new Error("Action item proposal payload entity_kind must be non-empty when provided.");
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
title: actionItem.title.trim(),
|
|
160
|
+
detail: actionItem.detail,
|
|
161
|
+
due_at: actionItem.due_at,
|
|
162
|
+
source: actionItem.source,
|
|
163
|
+
entity_id: actionItem.entity_id,
|
|
164
|
+
entity_name: actionItem.entity_name?.trim(),
|
|
165
|
+
entity_kind: actionItem.entity_kind?.trim(),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function getBoundScopeSlug(sourceAgent) {
|
|
169
|
+
const agent = getAgent(sourceAgent);
|
|
170
|
+
return agent?.scope ?? loadAgents().find((entry) => entry.slug === sourceAgent)?.scope;
|
|
171
|
+
}
|
|
172
|
+
function resolveAcceptedProposalScopeSlug(envelope, proposal) {
|
|
173
|
+
if (isNonEmptyString(envelope.scope_slug)) {
|
|
174
|
+
return envelope.scope_slug.trim();
|
|
175
|
+
}
|
|
176
|
+
const boundScope = getBoundScopeSlug(proposal.sourceAgent);
|
|
177
|
+
if (boundScope) {
|
|
178
|
+
return boundScope;
|
|
179
|
+
}
|
|
180
|
+
const activeScope = getActiveScope();
|
|
181
|
+
if (activeScope) {
|
|
182
|
+
return activeScope.slug;
|
|
183
|
+
}
|
|
184
|
+
if (proposal.scopeId) {
|
|
185
|
+
const queuedScope = getScope(proposal.scopeId);
|
|
186
|
+
if (queuedScope) {
|
|
187
|
+
return queuedScope.slug;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw new Error("No memory scope could be resolved for this proposal.");
|
|
191
|
+
}
|
|
192
|
+
function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
|
|
123
193
|
const scope = getScope(scopeSlug);
|
|
124
194
|
if (!scope) {
|
|
125
195
|
throw new Error(`Unknown memory scope '${scopeSlug}'.`);
|
|
@@ -146,14 +216,22 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence) {
|
|
|
146
216
|
return;
|
|
147
217
|
}
|
|
148
218
|
if (kind === "action_item") {
|
|
149
|
-
const actionItem = payload;
|
|
219
|
+
const actionItem = validateActionItemPayload(payload);
|
|
220
|
+
const entity = actionItem.entity_name && actionItem.entity_kind
|
|
221
|
+
? upsertEntity({
|
|
222
|
+
scope_id: scope.id,
|
|
223
|
+
kind: actionItem.entity_kind,
|
|
224
|
+
name: actionItem.entity_name,
|
|
225
|
+
confidence,
|
|
226
|
+
})
|
|
227
|
+
: undefined;
|
|
150
228
|
recordActionItem({
|
|
151
229
|
scope_id: scope.id,
|
|
152
|
-
entity_id: actionItem.entity_id,
|
|
230
|
+
entity_id: entity?.id ?? actionItem.entity_id,
|
|
153
231
|
title: actionItem.title,
|
|
154
232
|
detail: actionItem.detail,
|
|
155
233
|
due_at: actionItem.due_at,
|
|
156
|
-
source: actionItem.source ?? source,
|
|
234
|
+
source: sourceAgent ? `subagent_proposal:${sourceAgent}` : actionItem.source ?? source,
|
|
157
235
|
});
|
|
158
236
|
return;
|
|
159
237
|
}
|
|
@@ -209,11 +287,21 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
209
287
|
}
|
|
210
288
|
reviewedProposalIds.add(proposal.id);
|
|
211
289
|
if (decision.decision === "accept") {
|
|
212
|
-
|
|
213
|
-
|
|
290
|
+
if (!autoAcceptEnabled) {
|
|
291
|
+
summary.accepted++;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
214
294
|
const envelope = parseEnvelope(proposal.payload);
|
|
215
|
-
|
|
216
|
-
|
|
295
|
+
try {
|
|
296
|
+
rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
|
|
297
|
+
resolveInboxItem(proposal.id, "accepted", decision.reason);
|
|
298
|
+
summary.accepted++;
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
302
|
+
resolveInboxItem(proposal.id, "rejected", reason);
|
|
303
|
+
summary.rejected++;
|
|
304
|
+
}
|
|
217
305
|
}
|
|
218
306
|
continue;
|
|
219
307
|
}
|
package/dist/memory/eot.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
const repoRoot = process.cwd();
|
|
@@ -15,7 +15,8 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
|
|
|
15
15
|
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
16
16
|
const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
|
|
17
17
|
const eotModule = await import(new URL(`./eot.js?case=${cacheBust}`, import.meta.url).href);
|
|
18
|
-
|
|
18
|
+
const agentsModule = await import(new URL(`../copilot/agents.js?case=${cacheBust}`, import.meta.url).href);
|
|
19
|
+
return { dbModule, memoryModule, eotModule, agentsModule };
|
|
19
20
|
}
|
|
20
21
|
function getFunction(module, name) {
|
|
21
22
|
const value = module[name];
|
|
@@ -171,7 +172,6 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
|
|
|
171
172
|
payload: {
|
|
172
173
|
title: "Migrate feature ideas",
|
|
173
174
|
detail: "Move feature-ideas.md into mem_action_items.",
|
|
174
|
-
source: "subagent_proposal",
|
|
175
175
|
},
|
|
176
176
|
confidence: 0.9,
|
|
177
177
|
}));
|
|
@@ -189,11 +189,192 @@ test("runEndOfTaskMemoryHook accepts action_item proposals into mem_action_items
|
|
|
189
189
|
}),
|
|
190
190
|
});
|
|
191
191
|
const actionItems = listActionItems({ scope_id: chapterhouse.id });
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
const actionItem = actionItems.find((item) => item.title === "Migrate feature ideas");
|
|
193
|
+
assert.ok(actionItem);
|
|
194
|
+
assert.equal(actionItem.detail, "Move feature-ideas.md into mem_action_items.");
|
|
195
|
+
assert.equal(actionItem.status, "open");
|
|
196
|
+
assert.equal(actionItem.source, "subagent_proposal:coder");
|
|
194
197
|
const inbox = db.prepare(`SELECT status FROM mem_inbox WHERE id = ?`).get(Number(inserted.lastInsertRowid));
|
|
195
198
|
assert.equal(inbox.status, "accepted");
|
|
196
199
|
});
|
|
200
|
+
test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear reason", async () => {
|
|
201
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-invalid");
|
|
202
|
+
const db = dbModule.getDb();
|
|
203
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
204
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
205
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
206
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
207
|
+
const chapterhouse = getScope("chapterhouse");
|
|
208
|
+
assert.ok(chapterhouse);
|
|
209
|
+
const inserted = db.prepare(`
|
|
210
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
211
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-invalid', 'pending')
|
|
212
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
213
|
+
kind: "action_item",
|
|
214
|
+
payload: {
|
|
215
|
+
detail: "A title is required before this can become an action item.",
|
|
216
|
+
},
|
|
217
|
+
confidence: 0.9,
|
|
218
|
+
}));
|
|
219
|
+
await runEndOfTaskMemoryHook({
|
|
220
|
+
taskId: "task-eot-action-item-invalid",
|
|
221
|
+
finalResult: "Completed and proposed a malformed follow-up.",
|
|
222
|
+
copilotClient: {},
|
|
223
|
+
callLLM: async () => JSON.stringify({
|
|
224
|
+
decisions: [{
|
|
225
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
226
|
+
decision: "accept",
|
|
227
|
+
reason: "Concrete follow-up.",
|
|
228
|
+
}],
|
|
229
|
+
implicit_memories: [],
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
|
|
233
|
+
const inbox = db.prepare(`
|
|
234
|
+
SELECT status, resolution_reason, resolved_at
|
|
235
|
+
FROM mem_inbox
|
|
236
|
+
WHERE id = ?
|
|
237
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
238
|
+
assert.equal(inbox.status, "rejected");
|
|
239
|
+
assert.match(inbox.resolution_reason ?? "", /title/i);
|
|
240
|
+
assert.ok(inbox.resolved_at);
|
|
241
|
+
});
|
|
242
|
+
test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity references", async () => {
|
|
243
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
|
|
244
|
+
const db = dbModule.getDb();
|
|
245
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
246
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
247
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
248
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
249
|
+
const chapterhouse = getScope("chapterhouse");
|
|
250
|
+
assert.ok(chapterhouse);
|
|
251
|
+
const inserted = db.prepare(`
|
|
252
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
253
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-action-item-ambiguous-entity', 'pending')
|
|
254
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
255
|
+
kind: "action_item",
|
|
256
|
+
payload: {
|
|
257
|
+
title: "Resolve ambiguous entity link",
|
|
258
|
+
entity_id: 1,
|
|
259
|
+
entity_name: "Bellonda",
|
|
260
|
+
entity_kind: "agent",
|
|
261
|
+
},
|
|
262
|
+
confidence: 0.9,
|
|
263
|
+
}));
|
|
264
|
+
await runEndOfTaskMemoryHook({
|
|
265
|
+
taskId: "task-eot-action-item-ambiguous-entity",
|
|
266
|
+
finalResult: "Completed and proposed an ambiguous follow-up.",
|
|
267
|
+
copilotClient: {},
|
|
268
|
+
callLLM: async () => JSON.stringify({
|
|
269
|
+
decisions: [{
|
|
270
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
271
|
+
decision: "accept",
|
|
272
|
+
reason: "Concrete follow-up.",
|
|
273
|
+
}],
|
|
274
|
+
implicit_memories: [],
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
|
|
278
|
+
const inbox = db.prepare(`
|
|
279
|
+
SELECT status, resolution_reason
|
|
280
|
+
FROM mem_inbox
|
|
281
|
+
WHERE id = ?
|
|
282
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
283
|
+
assert.equal(inbox.status, "rejected");
|
|
284
|
+
assert.match(inbox.resolution_reason ?? "", /entity_id.*entity_name|entity_name.*entity_id/i);
|
|
285
|
+
});
|
|
286
|
+
test("runEndOfTaskMemoryHook falls back to the source agent bound scope for action_item proposals without scope_slug", async () => {
|
|
287
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
288
|
+
assert.ok(home, "test home should be set");
|
|
289
|
+
const { AGENTS_DIR: agentsDir } = await import("../paths.js");
|
|
290
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
291
|
+
writeFileSync(join(agentsDir, "bellonda.agent.md"), [
|
|
292
|
+
"---",
|
|
293
|
+
"name: Bellonda",
|
|
294
|
+
"description: Mentat of the infrastructure domain",
|
|
295
|
+
"model: claude-sonnet-4.6",
|
|
296
|
+
"persistent: true",
|
|
297
|
+
"scope: infra",
|
|
298
|
+
"---",
|
|
299
|
+
"",
|
|
300
|
+
"You are Bellonda.",
|
|
301
|
+
].join("\n"));
|
|
302
|
+
const { dbModule, memoryModule, eotModule, agentsModule } = await loadModules("action-item-bound-scope");
|
|
303
|
+
agentsModule.loadAgents();
|
|
304
|
+
const db = dbModule.getDb();
|
|
305
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
306
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
307
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
308
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
309
|
+
const chapterhouse = getScope("chapterhouse");
|
|
310
|
+
const infra = createScope({
|
|
311
|
+
slug: "infra",
|
|
312
|
+
title: "Infra",
|
|
313
|
+
description: "Infra test scope",
|
|
314
|
+
keywords: ["infra"],
|
|
315
|
+
});
|
|
316
|
+
assert.ok(chapterhouse);
|
|
317
|
+
const inserted = db.prepare(`
|
|
318
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
319
|
+
VALUES (?, 'memory_proposal', ?, 'bellonda', 'task-eot-action-item-bound-scope', 'pending')
|
|
320
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
321
|
+
kind: "action_item",
|
|
322
|
+
payload: {
|
|
323
|
+
title: "Review NAS disk alerts",
|
|
324
|
+
},
|
|
325
|
+
confidence: 0.9,
|
|
326
|
+
}));
|
|
327
|
+
await runEndOfTaskMemoryHook({
|
|
328
|
+
taskId: "task-eot-action-item-bound-scope",
|
|
329
|
+
finalResult: "Bellonda proposed an infra follow-up.",
|
|
330
|
+
copilotClient: {},
|
|
331
|
+
callLLM: async () => JSON.stringify({
|
|
332
|
+
decisions: [{
|
|
333
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
334
|
+
decision: "accept",
|
|
335
|
+
reason: "Concrete infra follow-up.",
|
|
336
|
+
}],
|
|
337
|
+
implicit_memories: [],
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
assert.equal(listActionItems({ scope_id: infra.id }).some((item) => item.title === "Review NAS disk alerts"), true);
|
|
341
|
+
assert.equal(listActionItems({ scope_id: chapterhouse.id }).some((item) => item.title === "Review NAS disk alerts"), false);
|
|
342
|
+
});
|
|
343
|
+
test("runEndOfTaskMemoryHook still accepts observation, decision, and entity proposals", async () => {
|
|
344
|
+
const { dbModule, memoryModule, eotModule } = await loadModules("existing-proposal-kinds");
|
|
345
|
+
const db = dbModule.getDb();
|
|
346
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
347
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
348
|
+
const listDecisions = getFunction(memoryModule, "listDecisions");
|
|
349
|
+
const listEntities = getFunction(memoryModule, "listEntities");
|
|
350
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
351
|
+
const chapterhouse = getScope("chapterhouse");
|
|
352
|
+
assert.ok(chapterhouse);
|
|
353
|
+
const proposals = [
|
|
354
|
+
{ kind: "observation", payload: { content: "Existing observation proposals still persist." } },
|
|
355
|
+
{ kind: "decision", payload: { title: "Keep auto-accept", rationale: "It is required for EOT memory promotion." } },
|
|
356
|
+
{ kind: "entity", payload: { name: "Bellonda", entity_kind: "agent", summary: "Infrastructure mentat." } },
|
|
357
|
+
].map((envelope) => db.prepare(`
|
|
358
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
359
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-existing-kinds', 'pending')
|
|
360
|
+
`).run(chapterhouse.id, JSON.stringify(envelope)));
|
|
361
|
+
await runEndOfTaskMemoryHook({
|
|
362
|
+
taskId: "task-eot-existing-kinds",
|
|
363
|
+
finalResult: "Completed with several proposal kinds.",
|
|
364
|
+
copilotClient: {},
|
|
365
|
+
callLLM: async () => JSON.stringify({
|
|
366
|
+
decisions: proposals.map((proposal) => ({
|
|
367
|
+
proposal_id: Number(proposal.lastInsertRowid),
|
|
368
|
+
decision: "accept",
|
|
369
|
+
reason: "Valid durable memory.",
|
|
370
|
+
})),
|
|
371
|
+
implicit_memories: [],
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "Existing observation proposals still persist."), true);
|
|
375
|
+
assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Keep auto-accept" && row.rationale === "It is required for EOT memory promotion."), true);
|
|
376
|
+
assert.equal(listEntities({ scope_id: chapterhouse.id, kind: "agent" }).some((row) => row.name === "Bellonda"), true);
|
|
377
|
+
});
|
|
197
378
|
test("runEndOfTaskMemoryHook accepts entity proposals with entity_kind into mem_entities", async () => {
|
|
198
379
|
const { dbModule, memoryModule, eotModule } = await loadModules("entity-accept");
|
|
199
380
|
const db = dbModule.getDb();
|
|
@@ -108,12 +108,17 @@ test("active-scope hot-tier queries do not leak rows from other scopes", async (
|
|
|
108
108
|
const { dbModule, memoryModule, hotTierModule } = await loadModules();
|
|
109
109
|
dbModule.getDb();
|
|
110
110
|
const getScope = getFunction(memoryModule, "getScope");
|
|
111
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
111
112
|
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
112
113
|
const recordObservation = getFunction(memoryModule, "recordObservation");
|
|
113
114
|
const chapterhouse = getScope("chapterhouse");
|
|
114
|
-
const team =
|
|
115
|
+
const team = createScope({
|
|
116
|
+
slug: "team",
|
|
117
|
+
title: "Team",
|
|
118
|
+
description: "Team test scope",
|
|
119
|
+
keywords: ["team"],
|
|
120
|
+
});
|
|
115
121
|
assert.ok(chapterhouse);
|
|
116
|
-
assert.ok(team);
|
|
117
122
|
recordObservation({
|
|
118
123
|
scope_id: chapterhouse.id,
|
|
119
124
|
content: "Chapterhouse hot entry",
|
|
@@ -135,11 +140,16 @@ test("renderHotTierXML includes open active-scope action items in a bounded acti
|
|
|
135
140
|
const { dbModule, memoryModule, hotTierModule } = await loadModules();
|
|
136
141
|
dbModule.getDb();
|
|
137
142
|
const getScope = getFunction(memoryModule, "getScope");
|
|
143
|
+
const createScope = getFunction(memoryModule, "createScope");
|
|
138
144
|
const recordActionItem = getFunction(memoryModule, "recordActionItem");
|
|
139
145
|
const chapterhouse = getScope("chapterhouse");
|
|
140
|
-
const team =
|
|
146
|
+
const team = createScope({
|
|
147
|
+
slug: "team",
|
|
148
|
+
title: "Team",
|
|
149
|
+
description: "Team test scope",
|
|
150
|
+
keywords: ["team"],
|
|
151
|
+
});
|
|
141
152
|
assert.ok(chapterhouse);
|
|
142
|
-
assert.ok(team);
|
|
143
153
|
const urgent = recordActionItem({
|
|
144
154
|
scope_id: chapterhouse.id,
|
|
145
155
|
title: "Migrate <feature ideas>",
|
|
@@ -8,7 +8,8 @@ import { tieringPass } from "./tiering.js";
|
|
|
8
8
|
export { tieringPass };
|
|
9
9
|
const log = childLogger("memory.housekeeping");
|
|
10
10
|
const SIMILARITY_THRESHOLD = 0.8;
|
|
11
|
-
const
|
|
11
|
+
const GLOBAL_PASS_SCOPE = Symbol("global-housekeeping-pass-scope");
|
|
12
|
+
const inFlightScopesByPass = new Map();
|
|
12
13
|
const PASS_ORDER = [
|
|
13
14
|
"dedup_observations",
|
|
14
15
|
"dedup_decisions",
|
|
@@ -280,38 +281,91 @@ function resolveScopeIds(input) {
|
|
|
280
281
|
const activeScope = getActiveScope();
|
|
281
282
|
return activeScope ? [activeScope.id] : [];
|
|
282
283
|
}
|
|
283
|
-
function
|
|
284
|
+
function getInFlightScopes(pass) {
|
|
285
|
+
let scopes = inFlightScopesByPass.get(pass);
|
|
286
|
+
if (!scopes) {
|
|
287
|
+
scopes = new Set();
|
|
288
|
+
inFlightScopesByPass.set(pass, scopes);
|
|
289
|
+
}
|
|
290
|
+
return scopes;
|
|
291
|
+
}
|
|
292
|
+
function getReservedPassScopes(scopeIds, passes) {
|
|
293
|
+
const reserved = [];
|
|
294
|
+
for (const pass of passes) {
|
|
295
|
+
if (pass === "compact_inbox") {
|
|
296
|
+
reserved.push({ pass, scope: GLOBAL_PASS_SCOPE });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
for (const scopeId of scopeIds) {
|
|
300
|
+
reserved.push({ pass, scope: scopeId });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return reserved;
|
|
304
|
+
}
|
|
305
|
+
function reservePassScopes(reserved) {
|
|
306
|
+
if (reserved.some(({ pass, scope }) => getInFlightScopes(pass).has(scope))) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
for (const { pass, scope } of reserved) {
|
|
310
|
+
getInFlightScopes(pass).add(scope);
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
function releasePassScopes(reserved) {
|
|
315
|
+
for (const { pass, scope } of reserved) {
|
|
316
|
+
const scopes = inFlightScopesByPass.get(pass);
|
|
317
|
+
scopes?.delete(scope);
|
|
318
|
+
if (scopes && scopes.size === 0) {
|
|
319
|
+
inFlightScopesByPass.delete(pass);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function runPass(pass, scopeId) {
|
|
284
324
|
switch (pass) {
|
|
285
325
|
case "dedup_observations":
|
|
286
|
-
return dedupObservationsPass(scopeId);
|
|
326
|
+
return await Promise.resolve(dedupObservationsPass(scopeId));
|
|
287
327
|
case "dedup_decisions":
|
|
288
|
-
return dedupDecisionsPass(scopeId);
|
|
328
|
+
return await Promise.resolve(dedupDecisionsPass(scopeId));
|
|
289
329
|
case "orphan_cleanup":
|
|
290
|
-
return orphanCleanupPass(scopeId);
|
|
330
|
+
return await Promise.resolve(orphanCleanupPass(scopeId));
|
|
291
331
|
case "decay":
|
|
292
|
-
return decayPass(scopeId);
|
|
332
|
+
return await Promise.resolve(decayPass(scopeId));
|
|
293
333
|
case "compact_inbox":
|
|
294
|
-
return compactInboxPass();
|
|
334
|
+
return await Promise.resolve(compactInboxPass());
|
|
295
335
|
case "tiering":
|
|
296
|
-
return tieringPass(scopeId);
|
|
336
|
+
return await Promise.resolve(tieringPass(scopeId));
|
|
297
337
|
}
|
|
298
338
|
}
|
|
299
|
-
function
|
|
300
|
-
|
|
339
|
+
async function runScopePasses(scopeId, passes) {
|
|
340
|
+
const summaries = [];
|
|
341
|
+
for (const pass of passes) {
|
|
342
|
+
summaries.push(await runPass(pass, scopeId));
|
|
343
|
+
}
|
|
344
|
+
return summaries;
|
|
301
345
|
}
|
|
302
346
|
export function isHousekeepingInFlight(scopeIds, passes) {
|
|
347
|
+
const normalizedPasses = passes?.map(normalizePassName) ?? PASS_ORDER;
|
|
303
348
|
if (!scopeIds || scopeIds.length === 0) {
|
|
304
|
-
return
|
|
349
|
+
return normalizedPasses.some((pass) => (inFlightScopesByPass.get(pass)?.size ?? 0) > 0);
|
|
305
350
|
}
|
|
306
|
-
const
|
|
307
|
-
return
|
|
351
|
+
const uniqueScopeIds = [...new Set(scopeIds)].sort((a, b) => a - b);
|
|
352
|
+
return normalizedPasses.some((pass) => {
|
|
353
|
+
const scopes = inFlightScopesByPass.get(pass);
|
|
354
|
+
if (!scopes) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (pass === "compact_inbox") {
|
|
358
|
+
return scopes.has(GLOBAL_PASS_SCOPE);
|
|
359
|
+
}
|
|
360
|
+
return uniqueScopeIds.some((scopeId) => scopes.has(scopeId));
|
|
361
|
+
});
|
|
308
362
|
}
|
|
309
|
-
export function runHousekeeping(opts = {}) {
|
|
363
|
+
export async function runHousekeeping(opts = {}) {
|
|
310
364
|
const started = performance.now();
|
|
311
365
|
const scopeIds = resolveScopeIds(opts).sort((a, b) => a - b);
|
|
312
366
|
const passes = opts.passes?.length ? opts.passes.map(normalizePassName) : PASS_ORDER;
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
367
|
+
const reservedPassScopes = getReservedPassScopes(scopeIds, passes);
|
|
368
|
+
if (!reservePassScopes(reservedPassScopes)) {
|
|
315
369
|
return {
|
|
316
370
|
scopeIds,
|
|
317
371
|
summaries: [passSummary("runHousekeeping", 0, 0, ["Housekeeping is already in flight for this scope/pass set."])],
|
|
@@ -320,18 +374,12 @@ export function runHousekeeping(opts = {}) {
|
|
|
320
374
|
durationMs: 0,
|
|
321
375
|
};
|
|
322
376
|
}
|
|
323
|
-
inFlightKeys.add(key);
|
|
324
|
-
const summaries = [];
|
|
325
377
|
try {
|
|
326
378
|
const scopedPasses = passes.filter((pass) => pass !== "compact_inbox");
|
|
327
379
|
const hasCompactInbox = passes.includes("compact_inbox");
|
|
328
|
-
|
|
329
|
-
for (const pass of scopedPasses) {
|
|
330
|
-
summaries.push(runPass(pass, scopeId));
|
|
331
|
-
}
|
|
332
|
-
}
|
|
380
|
+
const summaries = (await Promise.all(scopeIds.map((scopeId) => runScopePasses(scopeId, scopedPasses)))).flat();
|
|
333
381
|
if (hasCompactInbox) {
|
|
334
|
-
summaries.push(
|
|
382
|
+
summaries.push(await runPass("compact_inbox", undefined));
|
|
335
383
|
}
|
|
336
384
|
const totalExamined = summaries.reduce((sum, summary) => sum + summary.examined, 0);
|
|
337
385
|
const totalModified = summaries.reduce((sum, summary) => sum + summary.modified, 0);
|
|
@@ -346,7 +394,7 @@ export function runHousekeeping(opts = {}) {
|
|
|
346
394
|
return { scopeIds, summaries, totalExamined, totalModified, durationMs };
|
|
347
395
|
}
|
|
348
396
|
finally {
|
|
349
|
-
|
|
397
|
+
releasePassScopes(reservedPassScopes);
|
|
350
398
|
}
|
|
351
399
|
}
|
|
352
400
|
//# sourceMappingURL=housekeeping.js.map
|