chapterhouse 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/server.js +14 -8
- package/dist/api/server.test.js +30 -0
- package/dist/copilot/agents.js +3 -2
- package/dist/copilot/orchestrator.js +48 -10
- package/dist/copilot/orchestrator.test.js +59 -14
- package/dist/copilot/tools.js +183 -7
- package/dist/copilot/tools.memory.test.js +125 -1
- package/dist/copilot/turn-event-log.js +35 -15
- package/dist/copilot/turn-event-log.test.js +31 -0
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +111 -3
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/housekeeping.test.js +26 -26
- package/dist/memory/index.js +1 -0
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +430 -16
- package/dist/store/db.test.js +452 -10
- package/package.json +1 -1
- package/web/dist/assets/index-BTI_m0OE.css +10 -0
- package/web/dist/assets/{index-DmYLALt0.js → index-D4-uRAi6.js} +52 -52
- package/web/dist/assets/index-D4-uRAi6.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
- package/web/dist/assets/index-DmYLALt0.js.map +0 -1
package/dist/api/server.js
CHANGED
|
@@ -23,9 +23,9 @@ import { withWikiWrite } from "../wiki/lock.js";
|
|
|
23
23
|
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
24
24
|
import { restartDaemon } from "../daemon.js";
|
|
25
25
|
import { API_TOKEN_PATH } from "../paths.js";
|
|
26
|
-
import { getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
26
|
+
import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
27
27
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
28
|
-
import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
28
|
+
import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
29
29
|
import { getStatus, onStatusChange } from "../status.js";
|
|
30
30
|
import { formatSseData, formatSseEvent } from "./sse.js";
|
|
31
31
|
import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
|
|
@@ -728,6 +728,7 @@ if (config.chatSseEnabled) {
|
|
|
728
728
|
const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
|
|
729
729
|
if (!sessionKey)
|
|
730
730
|
throw new BadRequestError("Missing session key");
|
|
731
|
+
const includeHistorical = req.query.include === "all";
|
|
731
732
|
res.setHeader("Content-Type", "text/event-stream");
|
|
732
733
|
res.setHeader("Cache-Control", "no-cache");
|
|
733
734
|
res.setHeader("Connection", "keep-alive");
|
|
@@ -738,6 +739,10 @@ if (config.chatSseEnabled) {
|
|
|
738
739
|
const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
|
|
739
740
|
? parseInt(rawLastId.trim(), 10)
|
|
740
741
|
: undefined;
|
|
742
|
+
const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
|
|
743
|
+
const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
|
|
744
|
+
? 0
|
|
745
|
+
: lastSeq;
|
|
741
746
|
// Helper: send a named SSE event with an id: field
|
|
742
747
|
const sendEvent = (event, seq) => {
|
|
743
748
|
const payload = JSON.stringify(event);
|
|
@@ -745,13 +750,13 @@ if (config.chatSseEnabled) {
|
|
|
745
750
|
};
|
|
746
751
|
// If Last-Event-ID is present and the session ring buffer doesn't cover it,
|
|
747
752
|
// fall back to SQLite for replay of completed turns.
|
|
748
|
-
let replayHighSeq =
|
|
749
|
-
if (
|
|
753
|
+
let replayHighSeq = effectiveLastSeq;
|
|
754
|
+
if (effectiveLastSeq !== undefined) {
|
|
750
755
|
const oldestBuf = oldestSessionSeq(sessionKey);
|
|
751
|
-
const bufferMissesRange = oldestBuf === undefined || oldestBuf >
|
|
756
|
+
const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
|
|
752
757
|
if (bufferMissesRange) {
|
|
753
758
|
// Replay from SQLite (completed turns)
|
|
754
|
-
const dbEvents = getSessionEventsFromDb(sessionKey,
|
|
759
|
+
const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
|
|
755
760
|
for (const e of dbEvents) {
|
|
756
761
|
sendEvent(e, e._seq);
|
|
757
762
|
if (replayHighSeq === undefined || e._seq > replayHighSeq)
|
|
@@ -766,7 +771,7 @@ if (config.chatSseEnabled) {
|
|
|
766
771
|
sendEvent(e, e._seq);
|
|
767
772
|
}, replayHighSeq);
|
|
768
773
|
// Send connected event
|
|
769
|
-
res.write(`: connected session=${sessionKey}\n\n`);
|
|
774
|
+
res.write(`: connected session=${sessionKey} run=${getCurrentRunId()}\n\n`);
|
|
770
775
|
// Keep-alive every 15 s
|
|
771
776
|
const keepAlive = setInterval(() => {
|
|
772
777
|
res.write(`: keep-alive\n\n`);
|
|
@@ -1063,7 +1068,8 @@ app.get("/api/session/:sessionKey/messages", (req, res) => {
|
|
|
1063
1068
|
if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
|
|
1064
1069
|
throw new BadRequestError("'limit' must be a positive integer");
|
|
1065
1070
|
}
|
|
1066
|
-
const
|
|
1071
|
+
const includeHistorical = req.query.include === "all";
|
|
1072
|
+
const messages = getSessionMessages(sessionKey, limit, { includeHistorical });
|
|
1067
1073
|
res.json({ sessionKey, messages });
|
|
1068
1074
|
});
|
|
1069
1075
|
app.use(apiNotFoundHandler);
|
package/dist/api/server.test.js
CHANGED
|
@@ -371,6 +371,36 @@ test("server worker detail returns the stored dispatched prompt", async () => {
|
|
|
371
371
|
assert.equal(body.completedAt, null);
|
|
372
372
|
});
|
|
373
373
|
});
|
|
374
|
+
test("server session message hydration returns current run by default and include=all returns history", async () => {
|
|
375
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
376
|
+
const db = new Database(join(testRoot, ".chapterhouse", "chapterhouse.db"));
|
|
377
|
+
try {
|
|
378
|
+
const currentRun = db.prepare(`SELECT run_id FROM daemon_runs LIMIT 1`).get();
|
|
379
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
|
|
380
|
+
VALUES ('user', ?, 'web', 'hydration-session', ?)`).run("previous run", "previous-run");
|
|
381
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, run_id)
|
|
382
|
+
VALUES ('assistant', ?, 'web', 'hydration-session', ?)`).run("current run", currentRun.run_id);
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
db.close();
|
|
386
|
+
}
|
|
387
|
+
const currentOnly = await fetch(`${baseUrl}/api/session/hydration-session/messages`, {
|
|
388
|
+
headers: { authorization: authHeader },
|
|
389
|
+
});
|
|
390
|
+
assert.equal(currentOnly.status, 200);
|
|
391
|
+
assert.deepEqual((await currentOnly.json()).messages.map((message) => message.content), [
|
|
392
|
+
"current run",
|
|
393
|
+
]);
|
|
394
|
+
const allRuns = await fetch(`${baseUrl}/api/session/hydration-session/messages?include=all`, {
|
|
395
|
+
headers: { authorization: authHeader },
|
|
396
|
+
});
|
|
397
|
+
assert.equal(allRuns.status, 200);
|
|
398
|
+
assert.deepEqual((await allRuns.json()).messages.map((message) => message.content), [
|
|
399
|
+
"previous run",
|
|
400
|
+
"current run",
|
|
401
|
+
]);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
374
404
|
test("server wiki route synthesizes a welcome page when pages/index.md is missing", async () => {
|
|
375
405
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
376
406
|
rmSync(join(testRoot, ".chapterhouse", "wiki", "pages", "index.md"), { force: true });
|
package/dist/copilot/agents.js
CHANGED
|
@@ -227,7 +227,7 @@ function getAgentBasePrompt() {
|
|
|
227
227
|
You are an agent within Chapterhouse, a team-level AI assistant for engineering teams. You run on the user's local machine.
|
|
228
228
|
|
|
229
229
|
### Agent Memory
|
|
230
|
-
Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`,
|
|
230
|
+
Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\` and \`memory_list_action_items\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`, a named system/tool/person can be proposed as an \`entity\`, and a reminder/follow-up can be proposed as an \`action_item\`.
|
|
231
231
|
|
|
232
232
|
### Shared Wiki
|
|
233
233
|
All agents share a wiki knowledge base for persistent memory. Use \`wiki_read\` and \`wiki_search\` to find existing knowledge, and \`wiki_update\` to save important findings.
|
|
@@ -275,7 +275,7 @@ export function buildAgentRoster() {
|
|
|
275
275
|
const WIKI_TOOL_NAMES = new Set([
|
|
276
276
|
"wiki_search", "wiki_read", "wiki_update", "remember", "recall", "forget",
|
|
277
277
|
"wiki_ingest", "wiki_lint", "wiki_rebuild_index",
|
|
278
|
-
"memory_recall", "memory_propose",
|
|
278
|
+
"memory_recall", "memory_propose", "memory_list_action_items",
|
|
279
279
|
]);
|
|
280
280
|
// Management tools that only @chapterhouse should have
|
|
281
281
|
const MANAGEMENT_TOOL_NAMES = new Set([
|
|
@@ -285,6 +285,7 @@ const MANAGEMENT_TOOL_NAMES = new Set([
|
|
|
285
285
|
"restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
|
|
286
286
|
"list_machine_sessions", "attach_machine_session",
|
|
287
287
|
"memory_remember", "memory_set_scope", "memory_housekeep", "memory_promote", "memory_demote",
|
|
288
|
+
"memory_add_action_item", "memory_complete_action_item", "memory_drop_action_item", "memory_snooze_action_item",
|
|
288
289
|
]);
|
|
289
290
|
export function getCurrentToolAgentSlug() {
|
|
290
291
|
return toolAgentContext.getStore();
|
|
@@ -380,13 +380,49 @@ export function feedAgentResult(taskId, agentSlug, result) {
|
|
|
380
380
|
log.error({ err: error, taskId }, "memory.eot.error");
|
|
381
381
|
});
|
|
382
382
|
}
|
|
383
|
-
const
|
|
384
|
-
const
|
|
383
|
+
const sessionKey = getTaskSessionKey(taskId) || "default";
|
|
384
|
+
const agentTurnId = randomUUID();
|
|
385
|
+
const agentDisplayName = getAgentRegistry().find((agent) => agent.slug === agentSlug)?.name ?? agentSlug;
|
|
386
|
+
try {
|
|
387
|
+
emitTurnEvent(sessionKey, {
|
|
388
|
+
type: "turn:started",
|
|
389
|
+
turnId: agentTurnId,
|
|
390
|
+
sessionKey,
|
|
391
|
+
prompt: "",
|
|
392
|
+
agentSlug,
|
|
393
|
+
agentDisplayName,
|
|
394
|
+
});
|
|
395
|
+
const chunkSize = 500;
|
|
396
|
+
const chunks = result.length === 0 ? [""] : Array.from({ length: Math.ceil(result.length / chunkSize) }, (_, index) => result.slice(index * chunkSize, (index + 1) * chunkSize));
|
|
397
|
+
for (const chunk of chunks) {
|
|
398
|
+
emitTurnEvent(sessionKey, {
|
|
399
|
+
type: "turn:delta",
|
|
400
|
+
turnId: agentTurnId,
|
|
401
|
+
sessionKey,
|
|
402
|
+
part: { type: "text", text: chunk },
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
finalizeTurnEvent(sessionKey, { type: "turn:complete", turnId: agentTurnId, finalMessage: result });
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to emit synthetic agent reply turn");
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
logConversation("agent_completion", result, "background", sessionKey, {
|
|
412
|
+
agentSlug,
|
|
413
|
+
agentDisplayName,
|
|
414
|
+
turnId: agentTurnId,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
|
|
419
|
+
}
|
|
420
|
+
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. Their reply has been shown to the user. Acknowledge briefly.`;
|
|
385
421
|
sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
|
|
386
422
|
if (done && proactiveNotifyFn) {
|
|
387
423
|
proactiveNotifyFn(text);
|
|
388
424
|
}
|
|
389
|
-
});
|
|
425
|
+
}, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
|
|
390
426
|
}
|
|
391
427
|
function sleep(ms) {
|
|
392
428
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -1034,7 +1070,7 @@ function isRecoverableError(err) {
|
|
|
1034
1070
|
return false;
|
|
1035
1071
|
return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
|
|
1036
1072
|
}
|
|
1037
|
-
export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId) {
|
|
1073
|
+
export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId, options) {
|
|
1038
1074
|
updateUserContext(source);
|
|
1039
1075
|
updateRequestContext(source);
|
|
1040
1076
|
// Use the externally-supplied turnId if provided (POST→SSE path needs the ID
|
|
@@ -1103,12 +1139,14 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1103
1139
|
logMessage("out", sourceLabel, finalContent);
|
|
1104
1140
|
}
|
|
1105
1141
|
catch { /* best-effort */ }
|
|
1106
|
-
|
|
1107
|
-
|
|
1142
|
+
if (!options?.suppressPromptLog) {
|
|
1143
|
+
try {
|
|
1144
|
+
logConversation(logRole, prompt, sourceLabel, sessionKey, { turnId });
|
|
1145
|
+
}
|
|
1146
|
+
catch { /* best-effort */ }
|
|
1108
1147
|
}
|
|
1109
|
-
catch { /* best-effort */ }
|
|
1110
1148
|
try {
|
|
1111
|
-
logConversation("assistant", finalContent, sourceLabel, sessionKey);
|
|
1149
|
+
logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
|
|
1112
1150
|
}
|
|
1113
1151
|
catch { /* best-effort */ }
|
|
1114
1152
|
scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
|
|
@@ -1205,11 +1243,11 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
|
|
|
1205
1243
|
}
|
|
1206
1244
|
catch { /* best-effort */ }
|
|
1207
1245
|
try {
|
|
1208
|
-
logConversation("user", newPrompt, sourceLabel, sessionKey);
|
|
1246
|
+
logConversation("user", newPrompt, sourceLabel, sessionKey, { turnId });
|
|
1209
1247
|
}
|
|
1210
1248
|
catch { /* best-effort */ }
|
|
1211
1249
|
try {
|
|
1212
|
-
logConversation("assistant", finalContent, sourceLabel, sessionKey);
|
|
1250
|
+
logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
|
|
1213
1251
|
}
|
|
1214
1252
|
catch { /* best-effort */ }
|
|
1215
1253
|
scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
|
|
@@ -249,8 +249,14 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
249
249
|
});
|
|
250
250
|
t.mock.module("../store/db.js", {
|
|
251
251
|
namedExports: {
|
|
252
|
-
logConversation: (role, content, source) => {
|
|
253
|
-
state.dbLogs.push({
|
|
252
|
+
logConversation: (role, content, source, sessionKey, metadata) => {
|
|
253
|
+
state.dbLogs.push({
|
|
254
|
+
role,
|
|
255
|
+
content,
|
|
256
|
+
source,
|
|
257
|
+
...(sessionKey && sessionKey !== "default" ? { sessionKey } : {}),
|
|
258
|
+
...metadata,
|
|
259
|
+
});
|
|
254
260
|
},
|
|
255
261
|
getState: (key) => state.store.get(key),
|
|
256
262
|
setState: (key, value) => {
|
|
@@ -559,9 +565,11 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
|
|
|
559
565
|
{ direction: "in", source: "web", text: "Summarize the deployment" },
|
|
560
566
|
{ direction: "out", source: "web", text: "All green" },
|
|
561
567
|
]);
|
|
568
|
+
const loggedTurnId = state.dbLogs[0]?.turnId;
|
|
569
|
+
assert.equal(typeof loggedTurnId, "string");
|
|
562
570
|
assert.deepEqual(state.dbLogs, [
|
|
563
|
-
{ role: "user", content: "Summarize the deployment", source: "web" },
|
|
564
|
-
{ role: "assistant", content: "All green", source: "web" },
|
|
571
|
+
{ role: "user", content: "Summarize the deployment", source: "web", turnId: loggedTurnId },
|
|
572
|
+
{ role: "assistant", content: "All green", source: "web", turnId: loggedTurnId },
|
|
565
573
|
]);
|
|
566
574
|
assert.equal(state.episodeWrites, 1);
|
|
567
575
|
});
|
|
@@ -999,7 +1007,7 @@ test("@mentions route through the orchestrator session without invoking the mode
|
|
|
999
1007
|
assert.deepEqual(state.routerArgs, []);
|
|
1000
1008
|
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
|
|
1001
1009
|
});
|
|
1002
|
-
test("feedAgentResult
|
|
1010
|
+
test("feedAgentResult emits an attributed agent reply turn and sends only a short orchestrator prompt", async (t) => {
|
|
1003
1011
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1004
1012
|
config: {
|
|
1005
1013
|
copilotModel: "claude-sonnet-4.6",
|
|
@@ -1016,25 +1024,62 @@ test("feedAgentResult injects a background completion turn and proactively notif
|
|
|
1016
1024
|
orchestrator.feedAgentResult("task-9", "coder", "Fixed the flaky test");
|
|
1017
1025
|
assert.equal(await notified, "Agent complete");
|
|
1018
1026
|
assert.deepEqual(state.sessionPrompts, [{
|
|
1019
|
-
prompt: "[Agent task completed] @coder finished task task-9
|
|
1027
|
+
prompt: "[Agent task completed] @coder finished task task-9. Their reply has been shown to the user. Acknowledge briefly.",
|
|
1020
1028
|
}]);
|
|
1029
|
+
assert.equal(state.sessionPrompts[0]?.prompt.includes("Fixed the flaky test"), false, "orchestrator notification must not include the full agent reply body");
|
|
1030
|
+
const started = events.filter((event) => event.type === "turn:started");
|
|
1031
|
+
const deltas = events.filter((event) => event.type === "turn:delta");
|
|
1032
|
+
const completed = events.filter((event) => event.type === "turn:complete");
|
|
1033
|
+
assert.equal(started.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:started");
|
|
1034
|
+
assert.equal(deltas.length, 1, "agent reply should stream as one or more deltas");
|
|
1035
|
+
assert.equal(completed.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:complete");
|
|
1036
|
+
assert.equal(started[0]?.agentSlug, "coder");
|
|
1037
|
+
assert.equal(started[0]?.agentDisplayName, "Kaylee");
|
|
1038
|
+
assert.equal(started[0]?.prompt, "");
|
|
1021
1039
|
assert.deepEqual(state.dbLogs, [
|
|
1022
1040
|
{
|
|
1023
1041
|
role: "agent_completion",
|
|
1024
|
-
content: "
|
|
1042
|
+
content: "Fixed the flaky test",
|
|
1025
1043
|
source: "background",
|
|
1044
|
+
sessionKey: "chat:bg-lifecycle",
|
|
1045
|
+
agentSlug: "coder",
|
|
1046
|
+
agentDisplayName: "Kaylee",
|
|
1047
|
+
turnId: started[0]?.turnId,
|
|
1026
1048
|
},
|
|
1027
1049
|
{
|
|
1028
1050
|
role: "assistant",
|
|
1029
1051
|
content: "Agent complete",
|
|
1030
1052
|
source: "background",
|
|
1053
|
+
sessionKey: "chat:bg-lifecycle",
|
|
1054
|
+
turnId: started[1]?.turnId,
|
|
1031
1055
|
},
|
|
1032
1056
|
]);
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
assert.equal(
|
|
1036
|
-
assert.equal(completed
|
|
1037
|
-
assert.
|
|
1057
|
+
assert.equal(deltas[0]?.turnId, started[0]?.turnId);
|
|
1058
|
+
assert.deepEqual(deltas[0]?.part, { type: "text", text: "Fixed the flaky test" });
|
|
1059
|
+
assert.equal(completed[0]?.turnId, started[0]?.turnId);
|
|
1060
|
+
assert.equal(completed[0]?.finalMessage, "Fixed the flaky test");
|
|
1061
|
+
assert.notEqual(started[0]?.turnId, started[1]?.turnId, "agent reply and orchestrator acknowledgement need distinct turns");
|
|
1062
|
+
});
|
|
1063
|
+
test("feedAgentResult emits a delta even when the agent result is empty", async (t) => {
|
|
1064
|
+
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
1065
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: true },
|
|
1066
|
+
sendResult: "Acknowledged",
|
|
1067
|
+
taskSessionKeys: new Map([["task-empty", "chat:bg-empty"]]),
|
|
1068
|
+
});
|
|
1069
|
+
await orchestrator.initOrchestrator(client);
|
|
1070
|
+
const events = captureSessionEvents(t, "chat:bg-empty");
|
|
1071
|
+
const notified = new Promise((resolve) => {
|
|
1072
|
+
orchestrator.setProactiveNotify(resolve);
|
|
1073
|
+
});
|
|
1074
|
+
orchestrator.feedAgentResult("task-empty", "coder", "");
|
|
1075
|
+
assert.equal(await notified, "Acknowledged");
|
|
1076
|
+
const agentStarted = events.find((event) => event.type === "turn:started" && event.agentSlug === "coder");
|
|
1077
|
+
assert.ok(agentStarted, "agent reply should emit a started event");
|
|
1078
|
+
const deltas = events
|
|
1079
|
+
.filter((event) => event.type === "turn:delta")
|
|
1080
|
+
.filter((event) => event.turnId === agentStarted.turnId);
|
|
1081
|
+
assert.equal(deltas.length, 1);
|
|
1082
|
+
assert.deepEqual(deltas[0]?.part, { type: "text", text: "" });
|
|
1038
1083
|
});
|
|
1039
1084
|
test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
|
|
1040
1085
|
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
@@ -1227,11 +1272,11 @@ test("feedAgentResult routes to a non-default session when the task's session_ke
|
|
|
1227
1272
|
// A second createSession call proves the orchestrator opened a fresh non-default session
|
|
1228
1273
|
// rather than reusing the already-open default session.
|
|
1229
1274
|
assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a non-default session, not recycle the default one");
|
|
1230
|
-
// The prompt must reference the task and agent
|
|
1275
|
+
// The prompt must reference the task and agent but not include the full reply body.
|
|
1231
1276
|
const prompt = state.sessionPrompts.at(-1);
|
|
1232
1277
|
assert.ok(prompt?.prompt.includes("chat-task-1"), "prompt should reference the task id");
|
|
1233
1278
|
assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
|
|
1234
|
-
assert.
|
|
1279
|
+
assert.equal(prompt?.prompt.includes("Feature done"), false, "prompt should not include the result text");
|
|
1235
1280
|
});
|
|
1236
1281
|
test("ensureOrchestratorSession cleans up in-flight promise on session creation failure", async (t) => {
|
|
1237
1282
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
package/dist/copilot/tools.js
CHANGED
|
@@ -29,7 +29,7 @@ import { TeamsNotifier } from "../integrations/teams-notify.js";
|
|
|
29
29
|
import { TeamPushClient } from "../integrations/team-push.js";
|
|
30
30
|
import { OKRMapper, parseOKRPageContent } from "./okr-mapper.js";
|
|
31
31
|
import { childLogger } from "../util/logger.js";
|
|
32
|
-
import { getActiveScope as getMemoryActiveScope, getScope as getMemoryScope, inferScopeFromText, demoteToCold, demoteToWarm, queueMemoryProposal, recall as recallMemory, recordDecision, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, promoteToHot, upsertEntity, } from "../memory/index.js";
|
|
32
|
+
import { getActiveScope as getMemoryActiveScope, getScope as getMemoryScope, inferScopeFromText, completeActionItem, demoteToCold, demoteToWarm, dropActionItem, queueMemoryProposal, recall as recallMemory, listActionItems, recordDecision, recordActionItem, recordObservation, runHousekeeping, setActiveScope as setMemoryActiveScope, snoozeActionItem, promoteToHot, upsertEntity, } from "../memory/index.js";
|
|
33
33
|
const log = childLogger("tools");
|
|
34
34
|
/** Escape a string for safe inclusion as a single-line YAML scalar value. */
|
|
35
35
|
function yamlEscape(value) {
|
|
@@ -106,11 +106,31 @@ const decisionProposalPayloadSchema = z.object({
|
|
|
106
106
|
});
|
|
107
107
|
const entityProposalPayloadSchema = z.object({
|
|
108
108
|
name: z.string(),
|
|
109
|
-
|
|
109
|
+
entity_kind: z.string().optional(),
|
|
110
|
+
kind: z.string().optional(),
|
|
110
111
|
summary: z.string().optional(),
|
|
112
|
+
}).superRefine((value, context) => {
|
|
113
|
+
if (!value.entity_kind && !value.kind) {
|
|
114
|
+
context.addIssue({
|
|
115
|
+
code: z.ZodIssueCode.custom,
|
|
116
|
+
message: "entity_kind is required for entity proposals.",
|
|
117
|
+
path: ["entity_kind"],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}).transform((value) => ({
|
|
121
|
+
name: value.name,
|
|
122
|
+
entity_kind: value.entity_kind ?? value.kind,
|
|
123
|
+
summary: value.summary,
|
|
124
|
+
}));
|
|
125
|
+
const actionItemProposalPayloadSchema = z.object({
|
|
126
|
+
title: z.string(),
|
|
127
|
+
detail: z.string().optional(),
|
|
128
|
+
due_at: z.string().optional(),
|
|
129
|
+
source: z.string().optional(),
|
|
130
|
+
entity_id: z.number().int().positive().optional(),
|
|
111
131
|
});
|
|
112
132
|
const memoryProposeArgsSchema = z.object({
|
|
113
|
-
kind: z.enum(["observation", "decision", "entity"]),
|
|
133
|
+
kind: z.enum(["observation", "decision", "entity", "action_item"]),
|
|
114
134
|
scope_slug: z.string().optional(),
|
|
115
135
|
payload: z.record(z.string(), z.unknown()),
|
|
116
136
|
confidence: z.number().min(0).max(1).optional(),
|
|
@@ -120,7 +140,9 @@ const memoryProposeArgsSchema = z.object({
|
|
|
120
140
|
? observationProposalPayloadSchema
|
|
121
141
|
: value.kind === "decision"
|
|
122
142
|
? decisionProposalPayloadSchema
|
|
123
|
-
:
|
|
143
|
+
: value.kind === "entity"
|
|
144
|
+
? entityProposalPayloadSchema
|
|
145
|
+
: actionItemProposalPayloadSchema;
|
|
124
146
|
const parsed = schema.safeParse(value.payload);
|
|
125
147
|
if (!parsed.success) {
|
|
126
148
|
for (const issue of parsed.error.issues) {
|
|
@@ -132,7 +154,7 @@ const memoryProposeArgsSchema = z.object({
|
|
|
132
154
|
}
|
|
133
155
|
}
|
|
134
156
|
});
|
|
135
|
-
const memoryTierTableSchema = z.enum(["observation", "decision", "entity"]);
|
|
157
|
+
const memoryTierTableSchema = z.enum(["observation", "decision", "entity", "action_item"]);
|
|
136
158
|
function getCurrentQuarter(now = new Date()) {
|
|
137
159
|
return `${now.getUTCFullYear()}-Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
|
|
138
160
|
}
|
|
@@ -849,7 +871,9 @@ export function createTools(deps) {
|
|
|
849
871
|
? observationProposalPayloadSchema.parse(parsedArgs.payload)
|
|
850
872
|
: parsedArgs.kind === "decision"
|
|
851
873
|
? decisionProposalPayloadSchema.parse(parsedArgs.payload)
|
|
852
|
-
:
|
|
874
|
+
: parsedArgs.kind === "entity"
|
|
875
|
+
? entityProposalPayloadSchema.parse(parsedArgs.payload)
|
|
876
|
+
: actionItemProposalPayloadSchema.parse(parsedArgs.payload);
|
|
853
877
|
const proposal = queueMemoryProposal({
|
|
854
878
|
kind: parsedArgs.kind,
|
|
855
879
|
scopeSlug: parsedArgs.scope_slug,
|
|
@@ -872,12 +896,164 @@ export function createTools(deps) {
|
|
|
872
896
|
}
|
|
873
897
|
},
|
|
874
898
|
}),
|
|
899
|
+
defineTool("memory_add_action_item", {
|
|
900
|
+
description: "Add a scoped memory action item/reminder. Orchestrator-only write tool for follow-ups and proactive reminders.",
|
|
901
|
+
parameters: z.object({
|
|
902
|
+
scope: z.string().optional().describe("Optional memory scope slug. Defaults to active scope, then keyword inference."),
|
|
903
|
+
title: z.string().min(1).describe("Short action item title."),
|
|
904
|
+
detail: z.string().optional().describe("Longer action item detail."),
|
|
905
|
+
due_at: z.string().optional().describe("Optional ISO due timestamp."),
|
|
906
|
+
entity_name: z.string().optional().describe("Optional entity name to attach. Auto-upserts the entity if provided."),
|
|
907
|
+
entity_kind: z.string().optional().describe("Required when entity_name is provided."),
|
|
908
|
+
source: z.string().optional().describe("Action item source, e.g. manual, subagent_proposal, external."),
|
|
909
|
+
}),
|
|
910
|
+
handler: async (args) => {
|
|
911
|
+
const denied = requireOrchestratorMemoryWrite();
|
|
912
|
+
if (denied)
|
|
913
|
+
return denied;
|
|
914
|
+
if (args.entity_name && !args.entity_kind) {
|
|
915
|
+
return "entity_kind is required when entity_name is provided.";
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const { scopeId, scopeSlug } = resolveMemoryScopeForWrite(args.scope, `${args.title}\n${args.detail ?? ""}`);
|
|
919
|
+
const entity = args.entity_name
|
|
920
|
+
? upsertEntity({
|
|
921
|
+
scope_id: scopeId,
|
|
922
|
+
kind: args.entity_kind,
|
|
923
|
+
name: args.entity_name,
|
|
924
|
+
})
|
|
925
|
+
: undefined;
|
|
926
|
+
const actionItem = recordActionItem({
|
|
927
|
+
scope_id: scopeId,
|
|
928
|
+
entity_id: entity?.id,
|
|
929
|
+
title: args.title,
|
|
930
|
+
detail: args.detail,
|
|
931
|
+
due_at: args.due_at,
|
|
932
|
+
source: args.source ?? "manual",
|
|
933
|
+
});
|
|
934
|
+
return {
|
|
935
|
+
ok: true,
|
|
936
|
+
id: actionItem.id,
|
|
937
|
+
scope: scopeSlug,
|
|
938
|
+
status: actionItem.status,
|
|
939
|
+
entity_id: entity?.id,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
catch (err) {
|
|
943
|
+
return err instanceof Error ? err.message : String(err);
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
}),
|
|
947
|
+
defineTool("memory_complete_action_item", {
|
|
948
|
+
description: "Complete a memory action item. Orchestrator-only write tool.",
|
|
949
|
+
parameters: z.object({
|
|
950
|
+
id: z.number().int().positive(),
|
|
951
|
+
resolution_reason: z.string().optional(),
|
|
952
|
+
}),
|
|
953
|
+
handler: async (args) => {
|
|
954
|
+
const denied = requireOrchestratorMemoryWrite();
|
|
955
|
+
if (denied)
|
|
956
|
+
return denied;
|
|
957
|
+
try {
|
|
958
|
+
const actionItem = completeActionItem(args.id, args.resolution_reason);
|
|
959
|
+
return { ok: true, id: actionItem.id, status: actionItem.status, resolved_at: actionItem.resolvedAt };
|
|
960
|
+
}
|
|
961
|
+
catch (err) {
|
|
962
|
+
return err instanceof Error ? err.message : String(err);
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
}),
|
|
966
|
+
defineTool("memory_drop_action_item", {
|
|
967
|
+
description: "Drop a memory action item with a reason. Orchestrator-only write tool.",
|
|
968
|
+
parameters: z.object({
|
|
969
|
+
id: z.number().int().positive(),
|
|
970
|
+
reason: z.string().min(1),
|
|
971
|
+
}),
|
|
972
|
+
handler: async (args) => {
|
|
973
|
+
const denied = requireOrchestratorMemoryWrite();
|
|
974
|
+
if (denied)
|
|
975
|
+
return denied;
|
|
976
|
+
try {
|
|
977
|
+
const actionItem = dropActionItem(args.id, args.reason);
|
|
978
|
+
return { ok: true, id: actionItem.id, status: actionItem.status, resolved_at: actionItem.resolvedAt };
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
return err instanceof Error ? err.message : String(err);
|
|
982
|
+
}
|
|
983
|
+
},
|
|
984
|
+
}),
|
|
985
|
+
defineTool("memory_snooze_action_item", {
|
|
986
|
+
description: "Snooze a memory action item until an ISO timestamp. Orchestrator-only write tool.",
|
|
987
|
+
parameters: z.object({
|
|
988
|
+
id: z.number().int().positive(),
|
|
989
|
+
snooze_until: z.string().min(1),
|
|
990
|
+
}),
|
|
991
|
+
handler: async (args) => {
|
|
992
|
+
const denied = requireOrchestratorMemoryWrite();
|
|
993
|
+
if (denied)
|
|
994
|
+
return denied;
|
|
995
|
+
try {
|
|
996
|
+
const actionItem = snoozeActionItem(args.id, args.snooze_until);
|
|
997
|
+
return {
|
|
998
|
+
ok: true,
|
|
999
|
+
id: actionItem.id,
|
|
1000
|
+
status: actionItem.status,
|
|
1001
|
+
snooze_until: actionItem.snoozeUntil,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
return err instanceof Error ? err.message : String(err);
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
}),
|
|
1009
|
+
defineTool("memory_list_action_items", {
|
|
1010
|
+
description: "List scoped memory action items/reminders. Defaults to currently actionable open items.",
|
|
1011
|
+
parameters: z.object({
|
|
1012
|
+
scope: z.string().optional().describe("Optional memory scope slug. Defaults to active scope when available."),
|
|
1013
|
+
status: z.enum(["open", "done", "dropped", "snoozed"]).optional().describe("Optional status filter."),
|
|
1014
|
+
due_before: z.string().optional().describe("Optional ISO timestamp; only include items due at or before this time."),
|
|
1015
|
+
includeArchived: z.boolean().optional().describe("Include cold-tier items. Defaults to false."),
|
|
1016
|
+
}),
|
|
1017
|
+
handler: async (args) => {
|
|
1018
|
+
const requestedScope = args.scope ? getMemoryScope(args.scope) : undefined;
|
|
1019
|
+
if (args.scope && !requestedScope) {
|
|
1020
|
+
return `Unknown memory scope '${args.scope}'.`;
|
|
1021
|
+
}
|
|
1022
|
+
const activeScope = getMemoryActiveScope();
|
|
1023
|
+
const scope = requestedScope ?? activeScope ?? undefined;
|
|
1024
|
+
const actionItems = listActionItems({
|
|
1025
|
+
scope_id: scope?.id,
|
|
1026
|
+
status: args.status,
|
|
1027
|
+
due_before: args.due_before,
|
|
1028
|
+
includeArchived: args.includeArchived,
|
|
1029
|
+
});
|
|
1030
|
+
return {
|
|
1031
|
+
active_scope: activeScope ? { slug: activeScope.slug, title: activeScope.title } : null,
|
|
1032
|
+
scope: scope ? { slug: scope.slug, title: scope.title } : null,
|
|
1033
|
+
action_items: actionItems.map((item) => ({
|
|
1034
|
+
id: item.id,
|
|
1035
|
+
scope_id: item.scopeId,
|
|
1036
|
+
entity_id: item.entityId,
|
|
1037
|
+
title: item.title,
|
|
1038
|
+
detail: item.detail,
|
|
1039
|
+
status: item.status,
|
|
1040
|
+
due_at: item.dueAt,
|
|
1041
|
+
snooze_until: item.snoozeUntil,
|
|
1042
|
+
source: item.source,
|
|
1043
|
+
created_at: item.createdAt,
|
|
1044
|
+
updated_at: item.updatedAt,
|
|
1045
|
+
resolved_at: item.resolvedAt,
|
|
1046
|
+
resolution_reason: item.resolutionReason,
|
|
1047
|
+
})),
|
|
1048
|
+
};
|
|
1049
|
+
},
|
|
1050
|
+
}),
|
|
875
1051
|
defineTool("memory_recall", {
|
|
876
1052
|
description: "Search scoped agent memory with FTS-backed recall. Use this for the new agent-memory store, not the wiki-backed recall tool.",
|
|
877
1053
|
parameters: z.object({
|
|
878
1054
|
query: z.string().describe("Query text to search for."),
|
|
879
1055
|
scope: z.string().optional().describe("Optional scope slug. Defaults to the active scope when available."),
|
|
880
|
-
kinds: z.array(z.enum(["observation", "decision", "entity"])).optional()
|
|
1056
|
+
kinds: z.array(z.enum(["observation", "decision", "entity", "action_item"])).optional()
|
|
881
1057
|
.describe("Optional filter for memory entry kinds."),
|
|
882
1058
|
limit: z.number().int().positive().optional().describe("Maximum number of ranked hits to return. Defaults to 10."),
|
|
883
1059
|
includeSuperseded: z.boolean().optional().describe("Include superseded observations and decisions. Defaults to false."),
|