chapterhouse 0.9.2 → 0.10.0
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/README.md +1 -1
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1780
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +6 -3
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +87 -85
- package/dist/memory/eot.test.js +71 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +43 -13
- package/dist/wiki/frontmatter.test.js +24 -0
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +1 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
|
@@ -5,6 +5,7 @@ import { recordDecision } from "./decisions.js";
|
|
|
5
5
|
import { listEntities } from "./entities.js";
|
|
6
6
|
import { recordObservation, listObservations } from "./observations.js";
|
|
7
7
|
import { getScope } from "./scopes.js";
|
|
8
|
+
import { releaseScopeWriteLocks, tryAcquireScopeWriteLocks } from "./scope-lock.js";
|
|
8
9
|
import { listDecisions } from "./decisions.js";
|
|
9
10
|
import { buildCheckpointSystemPrompt, buildCheckpointUserPrompt, } from "./checkpoint-prompt.js";
|
|
10
11
|
const log = childLogger("memory.checkpoint");
|
|
@@ -48,29 +49,35 @@ function calculateSimilarity(left, right) {
|
|
|
48
49
|
return overlap / new Set([...leftTokens, ...rightTokens]).size;
|
|
49
50
|
}
|
|
50
51
|
function parseProposals(raw) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
return parsed.proposals.flatMap((proposal) => {
|
|
56
|
-
if (!proposal || typeof proposal !== "object") {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
const candidate = proposal;
|
|
60
|
-
if ((candidate.kind !== "observation" && candidate.kind !== "decision")
|
|
61
|
-
|| typeof candidate.content !== "string"
|
|
62
|
-
|| typeof candidate.confidence !== "number") {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
if (!Array.isArray(parsed.proposals)) {
|
|
63
55
|
return [];
|
|
64
56
|
}
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
return parsed.proposals.flatMap((proposal) => {
|
|
58
|
+
if (!proposal || typeof proposal !== "object") {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const candidate = proposal;
|
|
62
|
+
if ((candidate.kind !== "observation" && candidate.kind !== "decision")
|
|
63
|
+
|| typeof candidate.content !== "string"
|
|
64
|
+
|| typeof candidate.confidence !== "number") {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return [{
|
|
68
|
+
kind: candidate.kind,
|
|
69
|
+
title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
|
|
70
|
+
content: candidate.content.trim(),
|
|
71
|
+
scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
|
|
72
|
+
confidence: candidate.confidence,
|
|
73
|
+
decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
|
|
74
|
+
}];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
log.warn({ err }, "malformed checkpoint proposals response");
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
74
81
|
}
|
|
75
82
|
function resolveProposalScope(proposal, activeScope) {
|
|
76
83
|
if (!proposal.scope || proposal.scope === activeScope.slug) {
|
|
@@ -81,14 +88,15 @@ function resolveProposalScope(proposal, activeScope) {
|
|
|
81
88
|
function isDuplicateObservation(content, scopeId, batchContents) {
|
|
82
89
|
const existing = listObservations({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
|
|
83
90
|
const combined = [...existing.map((observation) => observation.content), ...batchContents];
|
|
84
|
-
return combined.some((candidate) => calculateSimilarity(candidate, content) >=
|
|
91
|
+
return combined.some((candidate) => calculateSimilarity(candidate, content) >= config.memoryCheckpointDuplicateThreshold);
|
|
85
92
|
}
|
|
86
93
|
function isDuplicateDecision(proposal, scopeId) {
|
|
87
94
|
const existing = listDecisions({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
|
|
88
95
|
return existing.some((decision) => {
|
|
89
96
|
const titleSimilarity = proposal.title ? calculateSimilarity(decision.title, proposal.title) : 0;
|
|
90
97
|
const rationaleSimilarity = calculateSimilarity(decision.rationale, proposal.content);
|
|
91
|
-
return titleSimilarity >=
|
|
98
|
+
return titleSimilarity >= config.memoryCheckpointDuplicateThreshold
|
|
99
|
+
|| rationaleSimilarity >= config.memoryCheckpointDuplicateThreshold;
|
|
92
100
|
});
|
|
93
101
|
}
|
|
94
102
|
export class CheckpointTracker {
|
|
@@ -96,12 +104,8 @@ export class CheckpointTracker {
|
|
|
96
104
|
cadence;
|
|
97
105
|
enabled;
|
|
98
106
|
constructor(options = {}) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const rawEnabled = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED?.trim();
|
|
102
|
-
const envEnabled = rawEnabled === "0" ? false : rawEnabled === "1" ? true : undefined;
|
|
103
|
-
this.cadence = options.turns ?? envTurns ?? config.memoryCheckpointTurns;
|
|
104
|
-
this.enabled = options.enabled ?? envEnabled ?? config.memoryCheckpointEnabled;
|
|
107
|
+
this.cadence = options.turns ?? config.memoryCheckpointTurns;
|
|
108
|
+
this.enabled = options.enabled ?? config.memoryCheckpointEnabled;
|
|
105
109
|
}
|
|
106
110
|
tickOrchestratorTurn() {
|
|
107
111
|
this.turnCountSinceLastFire++;
|
|
@@ -180,65 +184,78 @@ export async function runCheckpointExtraction(input) {
|
|
|
180
184
|
scope: input.activeScope.slug,
|
|
181
185
|
sessionKey: input.sessionKey ?? "default",
|
|
182
186
|
}, "memory.checkpoint.proposal_count");
|
|
187
|
+
const resolvedProposals = proposals.map((proposal) => ({
|
|
188
|
+
proposal,
|
|
189
|
+
scope: resolveProposalScope(proposal, input.activeScope),
|
|
190
|
+
}));
|
|
191
|
+
const lockedScopeIds = [...new Set(resolvedProposals.flatMap(({ scope }) => (scope ? [scope.id] : [])))];
|
|
192
|
+
if (lockedScopeIds.length > 0 && !tryAcquireScopeWriteLocks(lockedScopeIds)) {
|
|
193
|
+
log.info({ sessionKey: input.sessionKey ?? "default", scope: input.activeScope.slug }, "memory.checkpoint.scope_in_flight_skip");
|
|
194
|
+
return { written: 0, skipped: 0, errors };
|
|
195
|
+
}
|
|
183
196
|
let written = 0;
|
|
184
197
|
let skipped = 0;
|
|
185
198
|
const pendingObservationContents = [];
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (written >= MAX_WRITES_PER_CHECKPOINT) {
|
|
193
|
-
skipped++;
|
|
194
|
-
log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
const scope = resolveProposalScope(proposal, input.activeScope);
|
|
198
|
-
if (!scope) {
|
|
199
|
-
skipped++;
|
|
200
|
-
log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
|
|
204
|
-
skipped++;
|
|
205
|
-
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (proposal.kind === "decision") {
|
|
209
|
-
if (!proposal.title) {
|
|
199
|
+
try {
|
|
200
|
+
for (const { proposal, scope } of resolvedProposals) {
|
|
201
|
+
if (proposal.confidence < MIN_CONFIDENCE) {
|
|
210
202
|
skipped++;
|
|
211
|
-
log.info({ reason: "
|
|
203
|
+
log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
212
204
|
continue;
|
|
213
205
|
}
|
|
214
|
-
if (
|
|
206
|
+
if (written >= MAX_WRITES_PER_CHECKPOINT) {
|
|
207
|
+
skipped++;
|
|
208
|
+
log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!scope) {
|
|
212
|
+
skipped++;
|
|
213
|
+
log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
|
|
215
217
|
skipped++;
|
|
216
218
|
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
217
219
|
continue;
|
|
218
220
|
}
|
|
219
|
-
|
|
221
|
+
if (proposal.kind === "decision") {
|
|
222
|
+
if (!proposal.title) {
|
|
223
|
+
skipped++;
|
|
224
|
+
log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (isDuplicateDecision(proposal, scope.id)) {
|
|
228
|
+
skipped++;
|
|
229
|
+
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const decision = recordDecision({
|
|
233
|
+
scope_id: scope.id,
|
|
234
|
+
title: proposal.title,
|
|
235
|
+
rationale: proposal.content,
|
|
236
|
+
decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
|
|
237
|
+
tier: "warm",
|
|
238
|
+
});
|
|
239
|
+
written++;
|
|
240
|
+
log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const observation = recordObservation({
|
|
220
244
|
scope_id: scope.id,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
|
|
245
|
+
content: proposal.content,
|
|
246
|
+
source: "checkpoint:orchestrator",
|
|
224
247
|
tier: "warm",
|
|
248
|
+
confidence: proposal.confidence,
|
|
225
249
|
});
|
|
250
|
+
pendingObservationContents.push(proposal.content);
|
|
226
251
|
written++;
|
|
227
|
-
log.info({ kind: "
|
|
228
|
-
continue;
|
|
252
|
+
log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
|
|
229
253
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
tier: "warm",
|
|
235
|
-
confidence: proposal.confidence,
|
|
236
|
-
});
|
|
237
|
-
pendingObservationContents.push(proposal.content);
|
|
238
|
-
written++;
|
|
239
|
-
log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
|
|
254
|
+
return { written, skipped, errors };
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
releaseScopeWriteLocks(lockedScopeIds);
|
|
240
258
|
}
|
|
241
|
-
return { written, skipped, errors };
|
|
242
259
|
}
|
|
243
260
|
catch (error) {
|
|
244
261
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -18,6 +18,24 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
|
|
|
18
18
|
const checkpointPromptModule = await import(new URL(`./checkpoint-prompt.js?case=${cacheBust}`, import.meta.url).href);
|
|
19
19
|
return { dbModule, memoryModule, checkpointModule, checkpointPromptModule };
|
|
20
20
|
}
|
|
21
|
+
async function loadModulesWithWarnSpy(t, cacheBust) {
|
|
22
|
+
const warnings = [];
|
|
23
|
+
t.mock.module("../util/logger.js", {
|
|
24
|
+
namedExports: {
|
|
25
|
+
childLogger: () => ({
|
|
26
|
+
info: () => { },
|
|
27
|
+
warn: (...args) => {
|
|
28
|
+
warnings.push(args);
|
|
29
|
+
},
|
|
30
|
+
error: () => { },
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
35
|
+
const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
|
|
36
|
+
const checkpointModule = await import(new URL(`./checkpoint.js?case=${cacheBust}`, import.meta.url).href);
|
|
37
|
+
return { dbModule, memoryModule, checkpointModule, warnings };
|
|
38
|
+
}
|
|
21
39
|
function getFunction(module, name) {
|
|
22
40
|
const value = module[name];
|
|
23
41
|
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
@@ -157,8 +175,8 @@ test("runCheckpointExtraction writes high-confidence memories, skips low-confide
|
|
|
157
175
|
assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Run checkpoint extraction asynchronously"), true);
|
|
158
176
|
assert.equal(listObservations({ scope_id: chapterhouse.id, limit: 20 }).filter((row) => row.content === "Base new memory implementation branches from origin/main after dependent PRs merge.").length, 1);
|
|
159
177
|
});
|
|
160
|
-
test("runCheckpointExtraction
|
|
161
|
-
const { dbModule, memoryModule, checkpointModule } = await
|
|
178
|
+
test("runCheckpointExtraction ignores malformed JSON responses and warns", async (t) => {
|
|
179
|
+
const { dbModule, memoryModule, checkpointModule, warnings } = await loadModulesWithWarnSpy(t, "malformed-json");
|
|
162
180
|
dbModule.getDb();
|
|
163
181
|
const getScope = getFunction(memoryModule, "getScope");
|
|
164
182
|
const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
|
|
@@ -171,7 +189,9 @@ test("runCheckpointExtraction handles malformed JSON responses without crashing"
|
|
|
171
189
|
callLLM: async () => "{ definitely-not-json",
|
|
172
190
|
});
|
|
173
191
|
assert.equal(result.written, 0);
|
|
174
|
-
assert.
|
|
192
|
+
assert.equal(result.skipped, 0);
|
|
193
|
+
assert.deepEqual(result.errors, []);
|
|
194
|
+
assert.equal(warnings.length, 1);
|
|
175
195
|
});
|
|
176
196
|
test("runCheckpointExtraction prevents overlapping executions with the in-flight guard", async () => {
|
|
177
197
|
const { dbModule, memoryModule, checkpointModule } = await loadModules();
|
package/dist/memory/eot.js
CHANGED
|
@@ -11,28 +11,13 @@ import { getActiveScope } from "./active-scope.js";
|
|
|
11
11
|
import { getScope } from "./scopes.js";
|
|
12
12
|
const log = childLogger("memory.eot");
|
|
13
13
|
function isEndOfTaskHookEnabled() {
|
|
14
|
-
|
|
15
|
-
if (raw === "0")
|
|
16
|
-
return false;
|
|
17
|
-
if (raw === "1")
|
|
18
|
-
return true;
|
|
19
|
-
return true;
|
|
14
|
+
return config.memoryEndOfTaskHookEnabled;
|
|
20
15
|
}
|
|
21
16
|
function isMemoryAutoAcceptEnabled() {
|
|
22
|
-
|
|
23
|
-
if (raw === "0")
|
|
24
|
-
return false;
|
|
25
|
-
if (raw === "1")
|
|
26
|
-
return true;
|
|
27
|
-
return true;
|
|
17
|
+
return config.memoryAutoAcceptEnabled;
|
|
28
18
|
}
|
|
29
19
|
function isFrictionHookEnabled() {
|
|
30
|
-
|
|
31
|
-
if (raw === "0")
|
|
32
|
-
return false;
|
|
33
|
-
if (raw === "1")
|
|
34
|
-
return true;
|
|
35
|
-
return false;
|
|
20
|
+
return config.memoryEndOfTaskFrictionEnabled;
|
|
36
21
|
}
|
|
37
22
|
function buildReviewerSystemPrompt() {
|
|
38
23
|
return [
|
|
@@ -71,77 +56,89 @@ function buildFrictionUserPrompt(finalResult) {
|
|
|
71
56
|
return JSON.stringify({ final_result: finalResult }, null, 2);
|
|
72
57
|
}
|
|
73
58
|
function parseEnvelope(raw) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (!parsed || typeof parsed !== "object") {
|
|
62
|
+
throw new Error("Invalid memory proposal payload.");
|
|
63
|
+
}
|
|
64
|
+
if (parsed.kind !== "observation"
|
|
65
|
+
&& parsed.kind !== "decision"
|
|
66
|
+
&& parsed.kind !== "entity"
|
|
67
|
+
&& parsed.kind !== "action_item") {
|
|
68
|
+
throw new Error("Invalid proposal kind.");
|
|
69
|
+
}
|
|
70
|
+
if (!parsed.payload || typeof parsed.payload !== "object") {
|
|
71
|
+
throw new Error("Invalid proposal payload.");
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
kind: parsed.kind,
|
|
75
|
+
scope_slug: typeof parsed.scope_slug === "string" ? parsed.scope_slug : undefined,
|
|
76
|
+
confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.5,
|
|
77
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
78
|
+
payload: parsed.payload,
|
|
79
|
+
};
|
|
83
80
|
}
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
catch (err) {
|
|
82
|
+
log.warn({ err }, "malformed memory proposal payload");
|
|
83
|
+
return null;
|
|
86
84
|
}
|
|
87
|
-
return {
|
|
88
|
-
kind: parsed.kind,
|
|
89
|
-
scope_slug: typeof parsed.scope_slug === "string" ? parsed.scope_slug : undefined,
|
|
90
|
-
confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.5,
|
|
91
|
-
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
92
|
-
payload: parsed.payload,
|
|
93
|
-
};
|
|
94
85
|
}
|
|
95
86
|
function parseReviewerResponse(raw) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
89
|
+
return {
|
|
90
|
+
decisions: Array.isArray(parsed.decisions)
|
|
91
|
+
? parsed.decisions.flatMap((entry) => {
|
|
92
|
+
if (!entry || typeof entry !== "object") {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const candidate = entry;
|
|
96
|
+
if (typeof candidate.proposal_id !== "number") {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
if (candidate.decision !== "accept" && candidate.decision !== "reject") {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return [{
|
|
106
|
+
proposal_id: candidate.proposal_id,
|
|
107
|
+
decision: candidate.decision,
|
|
108
|
+
reason: candidate.reason.trim(),
|
|
109
|
+
}];
|
|
110
|
+
})
|
|
111
|
+
: [],
|
|
112
|
+
implicit_memories: Array.isArray(parsed.implicit_memories)
|
|
113
|
+
? parsed.implicit_memories.flatMap((entry) => {
|
|
114
|
+
if (!entry || typeof entry !== "object") {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const candidate = entry;
|
|
118
|
+
if (candidate.kind !== "observation"
|
|
119
|
+
&& candidate.kind !== "decision"
|
|
120
|
+
&& candidate.kind !== "entity"
|
|
121
|
+
&& candidate.kind !== "action_item") {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
if (typeof candidate.scope_slug !== "string" || !candidate.payload || typeof candidate.payload !== "object") {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
return [{
|
|
128
|
+
kind: candidate.kind,
|
|
129
|
+
scope_slug: candidate.scope_slug,
|
|
130
|
+
payload: candidate.payload,
|
|
131
|
+
confidence: typeof candidate.confidence === "number" ? candidate.confidence : undefined,
|
|
132
|
+
reason: typeof candidate.reason === "string" ? candidate.reason : undefined,
|
|
133
|
+
}];
|
|
134
|
+
})
|
|
135
|
+
: [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
log.warn({ err }, "malformed reviewer response");
|
|
140
|
+
return { decisions: [], implicit_memories: [] };
|
|
141
|
+
}
|
|
145
142
|
}
|
|
146
143
|
function parseFrictionResponse(raw) {
|
|
147
144
|
try {
|
|
@@ -368,6 +365,11 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
368
365
|
}
|
|
369
366
|
else {
|
|
370
367
|
const envelope = parseEnvelope(proposal.payload);
|
|
368
|
+
if (!envelope) {
|
|
369
|
+
resolveInboxItem(proposal.id, "rejected", "Malformed memory proposal payload.");
|
|
370
|
+
summary.rejected++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
371
373
|
try {
|
|
372
374
|
const accepted = rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
|
|
373
375
|
if (!accepted) {
|
package/dist/memory/eot.test.js
CHANGED
|
@@ -218,7 +218,6 @@ test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear
|
|
|
218
218
|
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-invalid");
|
|
219
219
|
const db = dbModule.getDb();
|
|
220
220
|
const getScope = getFunction(memoryModule, "getScope");
|
|
221
|
-
const createScope = getFunction(memoryModule, "createScope");
|
|
222
221
|
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
223
222
|
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
224
223
|
const chapterhouse = getScope("chapterhouse");
|
|
@@ -352,7 +351,6 @@ test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity
|
|
|
352
351
|
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
|
|
353
352
|
const db = dbModule.getDb();
|
|
354
353
|
const getScope = getFunction(memoryModule, "getScope");
|
|
355
|
-
const createScope = getFunction(memoryModule, "createScope");
|
|
356
354
|
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
357
355
|
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
358
356
|
const chapterhouse = getScope("chapterhouse");
|
|
@@ -593,7 +591,7 @@ test("runEndOfTaskMemoryHook rejects pending same-task proposals omitted by the
|
|
|
593
591
|
assert.match(row.resolution_reason ?? "", /did not select/i);
|
|
594
592
|
});
|
|
595
593
|
test("runEndOfTaskMemoryHook can persist implicit extracted memories that were not explicitly proposed", async () => {
|
|
596
|
-
const {
|
|
594
|
+
const { memoryModule, eotModule } = await loadModules("implicit");
|
|
597
595
|
const getScope = getFunction(memoryModule, "getScope");
|
|
598
596
|
const listObservations = getFunction(memoryModule, "listObservations");
|
|
599
597
|
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
@@ -763,6 +761,76 @@ test("runEndOfTaskMemoryHook persists implicit observation memories with valid c
|
|
|
763
761
|
assert.equal(summary.implicit_extracted, 1);
|
|
764
762
|
assert.equal(warnings.length, 0);
|
|
765
763
|
});
|
|
764
|
+
test("runEndOfTaskMemoryHook treats malformed reviewer JSON as an empty review and warns", async (t) => {
|
|
765
|
+
const { dbModule, memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "reviewer-malformed-json");
|
|
766
|
+
const db = dbModule.getDb();
|
|
767
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
768
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
769
|
+
const chapterhouse = getScope("chapterhouse");
|
|
770
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
771
|
+
const inserted = db.prepare(`
|
|
772
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
773
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-reviewer-malformed-json', 'pending')
|
|
774
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
775
|
+
kind: "observation",
|
|
776
|
+
payload: { content: "Malformed reviewer JSON must not crash the hook." },
|
|
777
|
+
confidence: 0.9,
|
|
778
|
+
}));
|
|
779
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
780
|
+
taskId: "task-eot-reviewer-malformed-json",
|
|
781
|
+
finalResult: "The reviewer returned malformed JSON, so the hook should fail closed and continue.",
|
|
782
|
+
copilotClient: {},
|
|
783
|
+
callLLM: async () => "{ definitely-not-json",
|
|
784
|
+
});
|
|
785
|
+
const inbox = db.prepare(`
|
|
786
|
+
SELECT status, resolution_reason
|
|
787
|
+
FROM mem_inbox
|
|
788
|
+
WHERE id = ?
|
|
789
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
790
|
+
assert.equal(summary.accepted, 0);
|
|
791
|
+
assert.equal(summary.rejected, 1);
|
|
792
|
+
assert.equal(inbox.status, "rejected");
|
|
793
|
+
assert.match(inbox.resolution_reason ?? "", /reviewer did not select/i);
|
|
794
|
+
assert.equal(warnings.length, 1);
|
|
795
|
+
});
|
|
796
|
+
test("runEndOfTaskMemoryHook rejects malformed accepted proposal payload JSON and warns", async (t) => {
|
|
797
|
+
const { dbModule, memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "proposal-malformed-json");
|
|
798
|
+
const db = dbModule.getDb();
|
|
799
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
800
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
801
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
802
|
+
const chapterhouse = getScope("chapterhouse");
|
|
803
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
804
|
+
const beforeCount = listObservations({ scope_id: chapterhouse.id }).length;
|
|
805
|
+
const inserted = db.prepare(`
|
|
806
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
807
|
+
VALUES (?, 'memory_proposal', ?, 'coder', 'task-eot-proposal-malformed-json', 'pending')
|
|
808
|
+
`).run(chapterhouse.id, "{ definitely-not-json");
|
|
809
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
810
|
+
taskId: "task-eot-proposal-malformed-json",
|
|
811
|
+
finalResult: "The accepted proposal payload is malformed JSON, so the hook should reject it safely.",
|
|
812
|
+
copilotClient: {},
|
|
813
|
+
callLLM: async () => JSON.stringify({
|
|
814
|
+
decisions: [{
|
|
815
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
816
|
+
decision: "accept",
|
|
817
|
+
reason: "Looks durable.",
|
|
818
|
+
}],
|
|
819
|
+
implicit_memories: [],
|
|
820
|
+
}),
|
|
821
|
+
});
|
|
822
|
+
const inbox = db.prepare(`
|
|
823
|
+
SELECT status, resolution_reason
|
|
824
|
+
FROM mem_inbox
|
|
825
|
+
WHERE id = ?
|
|
826
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
827
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).length, beforeCount);
|
|
828
|
+
assert.equal(summary.accepted, 0);
|
|
829
|
+
assert.equal(summary.rejected, 1);
|
|
830
|
+
assert.equal(inbox.status, "rejected");
|
|
831
|
+
assert.match(inbox.resolution_reason ?? "", /malformed/i);
|
|
832
|
+
assert.equal(warnings.length, 1);
|
|
833
|
+
});
|
|
766
834
|
test("runFrictionHook does nothing by default when CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED is unset", async () => {
|
|
767
835
|
const { eotModule } = await loadModules("friction-disabled-default");
|
|
768
836
|
const runFrictionHook = getFunction(eotModule, "runFrictionHook");
|
package/dist/memory/hooks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { childLogger } from "../util/logger.js";
|
|
2
|
+
import { config } from "../config.js";
|
|
2
3
|
import { getActiveScope } from "./active-scope.js";
|
|
3
4
|
import { recordObservation } from "./observations.js";
|
|
4
5
|
import { getScope } from "./scopes.js";
|
|
@@ -7,10 +8,7 @@ const log = childLogger("memory.hooks");
|
|
|
7
8
|
// Env knob
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
function isHooksEnabled() {
|
|
10
|
-
|
|
11
|
-
if (raw === "false" || raw === "0")
|
|
12
|
-
return false;
|
|
13
|
-
return true;
|
|
11
|
+
return config.memoryHooksEnabled;
|
|
14
12
|
}
|
|
15
13
|
// ---------------------------------------------------------------------------
|
|
16
14
|
// Dispatcher
|
|
@@ -44,7 +44,7 @@ export class MemoryHousekeepingScheduler {
|
|
|
44
44
|
reflectIntervalDays;
|
|
45
45
|
lastReflectAtMs;
|
|
46
46
|
constructor(options = {}) {
|
|
47
|
-
this.env = options.env ??
|
|
47
|
+
this.env = options.env ?? {};
|
|
48
48
|
this.runHousekeepingImpl = options.runHousekeeping ?? runHousekeeping;
|
|
49
49
|
this.runReflectAllScopesImpl = options.runReflectAllScopes ?? reflectAllScopes;
|
|
50
50
|
this.nowImpl = options.nowImpl ?? Date.now;
|
|
@@ -227,8 +227,7 @@ test("MemoryHousekeepingScheduler runs weekly reflection after housekeeping and
|
|
|
227
227
|
assert.equal(reflectRuns.length, 0);
|
|
228
228
|
now = 7 * DAY_MS;
|
|
229
229
|
timers.intervals[0]?.callback();
|
|
230
|
-
await
|
|
231
|
-
await Promise.resolve();
|
|
230
|
+
await scheduler.stop();
|
|
232
231
|
assert.equal(housekeepingRuns.length, 2);
|
|
233
232
|
assert.equal(reflectRuns.length, 1);
|
|
234
233
|
assert.equal(infos.some((entry) => entry.msg.includes("Memory reflect scheduled run complete")), true);
|