chapterhouse 0.9.1 → 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/agents/korg.agent.md +20 -0
- 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 -1725
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +358 -6
- 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 +194 -89
- package/dist/memory/eot.test.js +186 -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 +45 -14
- package/dist/wiki/frontmatter.test.js +26 -1
- 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 +17 -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
package/dist/memory/eot.js
CHANGED
|
@@ -11,20 +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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return true;
|
|
27
|
-
return true;
|
|
17
|
+
return config.memoryAutoAcceptEnabled;
|
|
18
|
+
}
|
|
19
|
+
function isFrictionHookEnabled() {
|
|
20
|
+
return config.memoryEndOfTaskFrictionEnabled;
|
|
28
21
|
}
|
|
29
22
|
function buildReviewerSystemPrompt() {
|
|
30
23
|
return [
|
|
@@ -48,78 +41,132 @@ function buildReviewerUserPrompt(finalResult, proposals) {
|
|
|
48
41
|
})),
|
|
49
42
|
}, null, 2);
|
|
50
43
|
}
|
|
44
|
+
function buildFrictionSystemPrompt() {
|
|
45
|
+
return [
|
|
46
|
+
"You review a completed agent task for tool friction.",
|
|
47
|
+
"Tool friction is: missing validation feedback, missing batch capability, silent failures,",
|
|
48
|
+
"overly strict input constraints, or tool gaps that caused the agent to work around limitations.",
|
|
49
|
+
"If you identify friction, return a JSON array of action items.",
|
|
50
|
+
"Each item must have: title (string), detail (string), source (always 'eot:friction').",
|
|
51
|
+
"If no friction was found, return an empty array [].",
|
|
52
|
+
"Return JSON only. No prose, no wrapping.",
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
function buildFrictionUserPrompt(finalResult) {
|
|
56
|
+
return JSON.stringify({ final_result: finalResult }, null, 2);
|
|
57
|
+
}
|
|
51
58
|
function parseEnvelope(raw) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
};
|
|
61
80
|
}
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
catch (err) {
|
|
82
|
+
log.warn({ err }, "malformed memory proposal payload");
|
|
83
|
+
return null;
|
|
64
84
|
}
|
|
65
|
-
return {
|
|
66
|
-
kind: parsed.kind,
|
|
67
|
-
scope_slug: typeof parsed.scope_slug === "string" ? parsed.scope_slug : undefined,
|
|
68
|
-
confidence: typeof parsed.confidence === "number" ? parsed.confidence : 0.5,
|
|
69
|
-
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
70
|
-
payload: parsed.payload,
|
|
71
|
-
};
|
|
72
85
|
}
|
|
73
86
|
function parseReviewerResponse(raw) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function parseFrictionResponse(raw) {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(raw);
|
|
146
|
+
if (!Array.isArray(parsed)) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
return parsed.flatMap((entry) => {
|
|
150
|
+
if (!entry || typeof entry !== "object") {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const candidate = entry;
|
|
154
|
+
if (!isNonEmptyString(candidate.title) || typeof candidate.detail !== "string") {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
if (candidate.source !== "eot:friction") {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
return [{
|
|
161
|
+
title: candidate.title.trim(),
|
|
162
|
+
detail: candidate.detail,
|
|
163
|
+
source: "eot:friction",
|
|
164
|
+
}];
|
|
165
|
+
}).slice(0, 3);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
123
170
|
}
|
|
124
171
|
function isNonEmptyString(value) {
|
|
125
172
|
return typeof value === "string" && value.trim().length > 0;
|
|
@@ -199,6 +246,25 @@ function resolveAcceptedProposalScopeSlug(envelope, proposal) {
|
|
|
199
246
|
}
|
|
200
247
|
throw new Error("No memory scope could be resolved for this proposal.");
|
|
201
248
|
}
|
|
249
|
+
function resolveActiveScopeSlug() {
|
|
250
|
+
const activeScope = getActiveScope();
|
|
251
|
+
if (activeScope) {
|
|
252
|
+
return activeScope.slug;
|
|
253
|
+
}
|
|
254
|
+
return getScope("chapterhouse")?.slug;
|
|
255
|
+
}
|
|
256
|
+
function resolveCallLLM(input) {
|
|
257
|
+
return input.callLLM ?? (async ({ system, user, model }) => {
|
|
258
|
+
const result = await runOneShotPrompt({
|
|
259
|
+
client: input.copilotClient,
|
|
260
|
+
model,
|
|
261
|
+
system,
|
|
262
|
+
user,
|
|
263
|
+
expectJson: true,
|
|
264
|
+
});
|
|
265
|
+
return result.content;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
202
268
|
function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, sourceAgent) {
|
|
203
269
|
const scope = getScope(scopeSlug);
|
|
204
270
|
if (!scope) {
|
|
@@ -279,16 +345,7 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
279
345
|
}
|
|
280
346
|
const proposals = listPendingMemoryProposalsForTask(input.taskId);
|
|
281
347
|
summary.proposals_total = proposals.length;
|
|
282
|
-
const callLLM = input
|
|
283
|
-
const result = await runOneShotPrompt({
|
|
284
|
-
client: input.copilotClient,
|
|
285
|
-
model,
|
|
286
|
-
system,
|
|
287
|
-
user,
|
|
288
|
-
expectJson: true,
|
|
289
|
-
});
|
|
290
|
-
return result.content;
|
|
291
|
-
});
|
|
348
|
+
const callLLM = resolveCallLLM(input);
|
|
292
349
|
const review = parseReviewerResponse(await callLLM({
|
|
293
350
|
system: buildReviewerSystemPrompt(),
|
|
294
351
|
user: buildReviewerUserPrompt(input.finalResult, proposals),
|
|
@@ -308,6 +365,11 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
308
365
|
}
|
|
309
366
|
else {
|
|
310
367
|
const envelope = parseEnvelope(proposal.payload);
|
|
368
|
+
if (!envelope) {
|
|
369
|
+
resolveInboxItem(proposal.id, "rejected", "Malformed memory proposal payload.");
|
|
370
|
+
summary.rejected++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
311
373
|
try {
|
|
312
374
|
const accepted = rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
|
|
313
375
|
if (!accepted) {
|
|
@@ -347,8 +409,51 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
347
409
|
}
|
|
348
410
|
}
|
|
349
411
|
}
|
|
412
|
+
await runFrictionHook({
|
|
413
|
+
taskId: input.taskId,
|
|
414
|
+
finalResult: input.finalResult,
|
|
415
|
+
copilotClient: input.copilotClient,
|
|
416
|
+
callLLM,
|
|
417
|
+
model: input.model,
|
|
418
|
+
});
|
|
350
419
|
log.info(summary, "memory.eot.processed");
|
|
351
420
|
input.onProcessed?.(summary);
|
|
352
421
|
return summary;
|
|
353
422
|
}
|
|
423
|
+
export async function runFrictionHook(input) {
|
|
424
|
+
try {
|
|
425
|
+
if (!isFrictionHookEnabled()) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (input.finalResult.trim().length <= 100) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const scopeSlug = resolveActiveScopeSlug();
|
|
432
|
+
if (!scopeSlug) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const callLLM = resolveCallLLM(input);
|
|
436
|
+
const raw = await callLLM({
|
|
437
|
+
system: buildFrictionSystemPrompt(),
|
|
438
|
+
user: buildFrictionUserPrompt(input.finalResult),
|
|
439
|
+
model: input.model ?? config.copilotModel,
|
|
440
|
+
});
|
|
441
|
+
const frictionItems = parseFrictionResponse(raw);
|
|
442
|
+
for (const item of frictionItems) {
|
|
443
|
+
try {
|
|
444
|
+
rememberAcceptedMemory("action_item", scopeSlug, {
|
|
445
|
+
title: item.title,
|
|
446
|
+
detail: item.detail,
|
|
447
|
+
source: item.source,
|
|
448
|
+
}, "eot:friction");
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
log.warn({ err, taskId: input.taskId, title: item.title }, "friction hook: failed to record action item");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
log.warn({ err, taskId: input.taskId }, "friction hook failed");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
354
459
|
//# sourceMappingURL=eot.js.map
|
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,4 +761,189 @@ 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
|
+
});
|
|
834
|
+
test("runFrictionHook does nothing by default when CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED is unset", async () => {
|
|
835
|
+
const { eotModule } = await loadModules("friction-disabled-default");
|
|
836
|
+
const runFrictionHook = getFunction(eotModule, "runFrictionHook");
|
|
837
|
+
let llmCalls = 0;
|
|
838
|
+
await runFrictionHook({
|
|
839
|
+
taskId: "task-friction-disabled-default",
|
|
840
|
+
finalResult: "A substantive final result that is definitely longer than one hundred characters to prove the friction hook still stays off by default.",
|
|
841
|
+
copilotClient: {},
|
|
842
|
+
callLLM: async () => {
|
|
843
|
+
llmCalls++;
|
|
844
|
+
return "[]";
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
assert.equal(llmCalls, 0);
|
|
848
|
+
});
|
|
849
|
+
test("runFrictionHook skips short final results even when enabled", async () => {
|
|
850
|
+
process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
|
|
851
|
+
const { eotModule } = await loadModules("friction-short-result");
|
|
852
|
+
const runFrictionHook = getFunction(eotModule, "runFrictionHook");
|
|
853
|
+
let llmCalls = 0;
|
|
854
|
+
await runFrictionHook({
|
|
855
|
+
taskId: "task-friction-short-result",
|
|
856
|
+
finalResult: "too short",
|
|
857
|
+
copilotClient: {},
|
|
858
|
+
callLLM: async () => {
|
|
859
|
+
llmCalls++;
|
|
860
|
+
return "[]";
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
assert.equal(llmCalls, 0);
|
|
864
|
+
});
|
|
865
|
+
test("runFrictionHook records action items when enabled and the task result is substantive", async () => {
|
|
866
|
+
process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
|
|
867
|
+
const { memoryModule, eotModule } = await loadModules("friction-records-action-items");
|
|
868
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
869
|
+
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
870
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
871
|
+
const runFrictionHook = getFunction(eotModule, "runFrictionHook");
|
|
872
|
+
const chapterhouse = getScope("chapterhouse");
|
|
873
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
874
|
+
setActiveScope("chapterhouse");
|
|
875
|
+
await runFrictionHook({
|
|
876
|
+
taskId: "task-friction-records-action-items",
|
|
877
|
+
finalResult: "The agent had to retry the same command several times because the tool returned a generic error with no validation detail, which made the task materially slower and harder to finish cleanly.",
|
|
878
|
+
copilotClient: {},
|
|
879
|
+
callLLM: async () => JSON.stringify([
|
|
880
|
+
{
|
|
881
|
+
title: "Improve validation feedback for memory tools",
|
|
882
|
+
detail: "Return the rejected field name and allowed values instead of a generic failure.",
|
|
883
|
+
source: "eot:friction",
|
|
884
|
+
},
|
|
885
|
+
]),
|
|
886
|
+
});
|
|
887
|
+
const actionItems = listActionItems({ scope_id: chapterhouse.id });
|
|
888
|
+
assert.equal(actionItems.length, 1);
|
|
889
|
+
assert.equal(actionItems[0]?.title, "Improve validation feedback for memory tools");
|
|
890
|
+
assert.equal(actionItems[0]?.detail, "Return the rejected field name and allowed values instead of a generic failure.");
|
|
891
|
+
assert.equal(actionItems[0]?.source, "eot:friction");
|
|
892
|
+
assert.equal(actionItems[0]?.status, "open");
|
|
893
|
+
});
|
|
894
|
+
test("runFrictionHook caps parsed friction items at 3", async () => {
|
|
895
|
+
process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
|
|
896
|
+
const { memoryModule, eotModule } = await loadModules("friction-cap");
|
|
897
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
898
|
+
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
899
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
900
|
+
const runFrictionHook = getFunction(eotModule, "runFrictionHook");
|
|
901
|
+
const chapterhouse = getScope("chapterhouse");
|
|
902
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
903
|
+
setActiveScope("chapterhouse");
|
|
904
|
+
await runFrictionHook({
|
|
905
|
+
taskId: "task-friction-cap",
|
|
906
|
+
finalResult: "The toolchain created several distinct sources of friction across the task, and the result is long enough that the friction hook should inspect it and write only the first three issues back into memory.",
|
|
907
|
+
copilotClient: {},
|
|
908
|
+
callLLM: async () => JSON.stringify([
|
|
909
|
+
{ title: "Item 1", detail: "detail 1", source: "eot:friction" },
|
|
910
|
+
{ title: "Item 2", detail: "detail 2", source: "eot:friction" },
|
|
911
|
+
{ title: "Item 3", detail: "detail 3", source: "eot:friction" },
|
|
912
|
+
{ title: "Item 4", detail: "detail 4", source: "eot:friction" },
|
|
913
|
+
]),
|
|
914
|
+
});
|
|
915
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }).map((item) => item.title).sort(), ["Item 1", "Item 2", "Item 3"]);
|
|
916
|
+
});
|
|
917
|
+
test("runFrictionHook treats malformed JSON as no friction items", async () => {
|
|
918
|
+
process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
|
|
919
|
+
const { memoryModule, eotModule } = await loadModules("friction-malformed-json");
|
|
920
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
921
|
+
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
922
|
+
const listActionItems = getFunction(memoryModule, "listActionItems");
|
|
923
|
+
const runFrictionHook = getFunction(eotModule, "runFrictionHook");
|
|
924
|
+
const chapterhouse = getScope("chapterhouse");
|
|
925
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
926
|
+
setActiveScope("chapterhouse");
|
|
927
|
+
await runFrictionHook({
|
|
928
|
+
taskId: "task-friction-malformed-json",
|
|
929
|
+
finalResult: "The agent hit confusing tool friction repeatedly, but the friction reviewer returned malformed JSON and the hook should safely ignore it without writing any action items.",
|
|
930
|
+
copilotClient: {},
|
|
931
|
+
callLLM: async () => "{not valid json",
|
|
932
|
+
});
|
|
933
|
+
assert.deepEqual(listActionItems({ scope_id: chapterhouse.id }), []);
|
|
934
|
+
});
|
|
935
|
+
test("runFrictionHook never propagates errors from the LLM call", async (t) => {
|
|
936
|
+
process.env.CHAPTERHOUSE_MEMORY_EOT_FRICTION_ENABLED = "1";
|
|
937
|
+
const { eotModule, warnings } = await loadModulesWithWarnSpy(t, "friction-no-throw");
|
|
938
|
+
const runFrictionHook = getFunction(eotModule, "runFrictionHook");
|
|
939
|
+
await assert.doesNotReject(() => runFrictionHook({
|
|
940
|
+
taskId: "task-friction-no-throw",
|
|
941
|
+
finalResult: "The agent encountered enough friction to trigger the hook, but the LLM call itself crashed and the hook must still fail closed without breaking end-of-task processing.",
|
|
942
|
+
copilotClient: {},
|
|
943
|
+
callLLM: async () => {
|
|
944
|
+
throw new Error("boom");
|
|
945
|
+
},
|
|
946
|
+
}));
|
|
947
|
+
assert.equal(warnings.length, 1);
|
|
948
|
+
});
|
|
766
949
|
//# sourceMappingURL=eot.test.js.map
|
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);
|