chapterhouse 0.5.2 → 0.6.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/.pr-types.json +14 -0
- package/README.md +6 -0
- package/dist/config.test.js +29 -0
- package/dist/copilot/memory-coordinator.js +234 -0
- package/dist/copilot/memory-coordinator.test.js +257 -0
- package/dist/copilot/orchestrator.js +31 -209
- package/dist/copilot/orchestrator.test.js +111 -0
- package/dist/copilot/pr-title.js +92 -0
- package/dist/copilot/pr-title.test.js +54 -0
- package/dist/copilot/router.test.js +30 -0
- package/dist/copilot/threat-model.js +50 -0
- package/dist/copilot/threat-model.test.js +129 -0
- package/dist/copilot/tools.js +61 -37
- package/dist/copilot/tools.wiki.test.js +15 -6
- package/dist/setup.js +15 -5
- package/dist/setup.test.js +20 -3
- package/dist/sprint-merge.js +168 -0
- package/dist/sprint-merge.test.js +131 -0
- package/dist/store/db.js +63 -0
- package/dist/store/db.test.js +279 -0
- package/package.json +6 -1
- package/web/dist/assets/{index-CPaILy2j.js → index-B5oDsQ5y.js} +84 -84
- package/web/dist/assets/{index-CPaILy2j.js.map → index-B5oDsQ5y.js.map} +1 -1
- package/web/dist/assets/index-DknKAtDS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Cs7AGeaL.css +0 -10
|
@@ -3,13 +3,9 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { approveAll } from "@github/copilot-sdk";
|
|
4
4
|
import { createTools } from "./tools.js";
|
|
5
5
|
import { getOrchestratorSystemMessage } from "./system-message.js";
|
|
6
|
-
import { renderHotTierForActiveScope } from "../memory/hot-tier.js";
|
|
7
|
-
import { getHotTierEntries, renderHotTierXML } from "../memory/hot-tier.js";
|
|
8
6
|
import { getActiveScope, withActiveScope } from "../memory/active-scope.js";
|
|
9
7
|
import { getScope } from "../memory/scopes.js";
|
|
10
|
-
import {
|
|
11
|
-
import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
|
|
12
|
-
import { runEndOfTaskMemoryHook } from "../memory/eot.js";
|
|
8
|
+
import { MemoryCoordinator } from "./memory-coordinator.js";
|
|
13
9
|
import { CHAPTERHOUSE_VERSION } from "../version.js";
|
|
14
10
|
import { config, DEFAULT_MODEL } from "../config.js";
|
|
15
11
|
import { loadMcpConfig } from "./mcp-config.js";
|
|
@@ -77,157 +73,15 @@ let currentUserContext;
|
|
|
77
73
|
let currentAuthenticatedUser;
|
|
78
74
|
let currentAuthorizationHeader;
|
|
79
75
|
let lastRouteResult;
|
|
80
|
-
|
|
81
|
-
const checkpointTurnsBySession = new Map();
|
|
82
|
-
const housekeepingTurnsBySession = new Map();
|
|
83
|
-
const MAX_CHECKPOINT_CHARS_PER_SIDE = 4_000;
|
|
76
|
+
let memoryCoordinator;
|
|
84
77
|
export function getLastRouteResult() {
|
|
85
78
|
return lastRouteResult;
|
|
86
79
|
}
|
|
87
|
-
function truncateCheckpointText(value) {
|
|
88
|
-
const trimmed = value.trim();
|
|
89
|
-
if (trimmed.length <= MAX_CHECKPOINT_CHARS_PER_SIDE) {
|
|
90
|
-
return trimmed;
|
|
91
|
-
}
|
|
92
|
-
return `${trimmed.slice(0, MAX_CHECKPOINT_CHARS_PER_SIDE)}…`;
|
|
93
|
-
}
|
|
94
|
-
function getCheckpointTracker(sessionKey) {
|
|
95
|
-
let tracker = checkpointTrackers.get(sessionKey);
|
|
96
|
-
if (!tracker) {
|
|
97
|
-
tracker = new CheckpointTracker();
|
|
98
|
-
checkpointTrackers.set(sessionKey, tracker);
|
|
99
|
-
}
|
|
100
|
-
return tracker;
|
|
101
|
-
}
|
|
102
80
|
export function resetCheckpointSessionState(sessionKey) {
|
|
103
|
-
|
|
104
|
-
checkpointTurnsBySession.delete(sessionKey);
|
|
105
|
-
housekeepingTurnsBySession.delete(sessionKey);
|
|
106
|
-
}
|
|
107
|
-
function appendCheckpointTurn(sessionKey, turn) {
|
|
108
|
-
const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
|
|
109
|
-
turns.push(turn);
|
|
110
|
-
const overflow = turns.length - config.memoryCheckpointTurns;
|
|
111
|
-
if (overflow > 0) {
|
|
112
|
-
turns.splice(0, overflow);
|
|
113
|
-
}
|
|
114
|
-
checkpointTurnsBySession.set(sessionKey, turns);
|
|
115
|
-
return turns;
|
|
116
|
-
}
|
|
117
|
-
function scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source) {
|
|
118
|
-
if (source.type === "background") {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
const tracker = getCheckpointTracker(sessionKey);
|
|
122
|
-
const turns = appendCheckpointTurn(sessionKey, {
|
|
123
|
-
user: truncateCheckpointText(prompt),
|
|
124
|
-
assistant: truncateCheckpointText(finalContent),
|
|
125
|
-
});
|
|
126
|
-
if (!config.memoryCheckpointEnabled) {
|
|
127
|
-
log.info({ sessionKey }, "memory.checkpoint.disabled");
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
tracker.tickOrchestratorTurn();
|
|
131
|
-
if (!tracker.shouldFire()) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
tracker.markFired();
|
|
135
|
-
if (isCheckpointInFlight(sessionKey)) {
|
|
136
|
-
log.info({ sessionKey }, "memory.checkpoint.in_flight_skip");
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (!copilotClient) {
|
|
140
|
-
log.error({ sessionKey }, "memory.checkpoint.error");
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
const activeScope = getMemoryScopeForSession(sessionKey);
|
|
144
|
-
void runCheckpointExtraction({
|
|
145
|
-
sessionKey,
|
|
146
|
-
turns: turns.slice(-config.memoryCheckpointTurns),
|
|
147
|
-
activeScope,
|
|
148
|
-
copilotClient,
|
|
149
|
-
trigger: "cadence",
|
|
150
|
-
}).catch((error) => {
|
|
151
|
-
log.error({ err: error, sessionKey }, "memory.checkpoint.error");
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
function scheduleHousekeeping(sessionKey, source) {
|
|
155
|
-
if (source.type === "background") {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
if (!config.memoryHousekeepingEnabled) {
|
|
159
|
-
log.info({ sessionKey }, "memory.housekeeping.disabled");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const turns = (housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
|
|
163
|
-
if (turns < config.memoryHousekeepingTurns) {
|
|
164
|
-
housekeepingTurnsBySession.set(sessionKey, turns);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
housekeepingTurnsBySession.set(sessionKey, 0);
|
|
168
|
-
const activeScope = getMemoryScopeForSession(sessionKey);
|
|
169
|
-
if (!activeScope) {
|
|
170
|
-
log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
const scopeIds = [activeScope.id];
|
|
174
|
-
if (isHousekeepingInFlight(scopeIds)) {
|
|
175
|
-
log.info({ sessionKey, scope_ids: scopeIds }, "memory.housekeeping.in_flight_skip");
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
void runHousekeeping({ scopeIds }).catch((error) => {
|
|
179
|
-
log.error({ err: error, sessionKey, scope_ids: scopeIds }, "memory.housekeeping.error");
|
|
180
|
-
});
|
|
81
|
+
memoryCoordinator?.reset(sessionKey);
|
|
181
82
|
}
|
|
182
83
|
export function maybeScheduleScopeChangeCheckpoint(sessionKey, previousScope, nextScope) {
|
|
183
|
-
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (!config.memoryCheckpointOnScopeChange) {
|
|
187
|
-
log.info({ sessionKey, scope: previousScope.slug }, "memory.checkpoint.scope_change_disabled");
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
const tracker = getCheckpointTracker(sessionKey);
|
|
191
|
-
const turnsSinceLast = tracker.turnsSinceLastFire();
|
|
192
|
-
if (turnsSinceLast < config.memoryCheckpointMinTurnsForScopeFire) {
|
|
193
|
-
log.info({
|
|
194
|
-
sessionKey,
|
|
195
|
-
scope: previousScope.slug,
|
|
196
|
-
turns_since_last: turnsSinceLast,
|
|
197
|
-
min_required: config.memoryCheckpointMinTurnsForScopeFire,
|
|
198
|
-
}, "memory.checkpoint.scope_change_skip");
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
if (isCheckpointInFlight(sessionKey)) {
|
|
202
|
-
log.info({ sessionKey, trigger: "scope_change" }, "memory.checkpoint.in_flight_skip");
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
if (!copilotClient) {
|
|
206
|
-
log.error({ sessionKey }, "memory.checkpoint.error");
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
const turns = checkpointTurnsBySession.get(sessionKey) ?? [];
|
|
210
|
-
if (turns.length === 0) {
|
|
211
|
-
log.info({
|
|
212
|
-
sessionKey,
|
|
213
|
-
scope: previousScope.slug,
|
|
214
|
-
turns_since_last: turnsSinceLast,
|
|
215
|
-
min_required: config.memoryCheckpointMinTurnsForScopeFire,
|
|
216
|
-
}, "memory.checkpoint.scope_change_skip");
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
tracker.markScopeChangeFire();
|
|
220
|
-
void runCheckpointExtraction({
|
|
221
|
-
sessionKey,
|
|
222
|
-
turns: turns.slice(-config.memoryCheckpointTurns),
|
|
223
|
-
activeScope: previousScope,
|
|
224
|
-
copilotClient,
|
|
225
|
-
trigger: "scope_change",
|
|
226
|
-
scopeChangeContext: {
|
|
227
|
-
from: previousScope.slug,
|
|
228
|
-
to: nextScope?.slug ?? "no active scope",
|
|
229
|
-
},
|
|
230
|
-
}).catch((error) => {
|
|
84
|
+
void memoryCoordinator?.onScopeChange(sessionKey, previousScope?.slug ?? "", nextScope?.slug ?? "").catch((error) => {
|
|
231
85
|
log.error({ err: error, sessionKey }, "memory.checkpoint.error");
|
|
232
86
|
});
|
|
233
87
|
}
|
|
@@ -339,39 +193,11 @@ function getMemoryScopeForSession(sessionKey) {
|
|
|
339
193
|
}
|
|
340
194
|
return getActiveScope();
|
|
341
195
|
}
|
|
342
|
-
function
|
|
343
|
-
if (!config.memoryInjectEnabled || !scope) {
|
|
344
|
-
return undefined;
|
|
345
|
-
}
|
|
346
|
-
const hotTierXml = renderHotTierXML(getHotTierEntries(scope.id));
|
|
347
|
-
return hotTierXml ? hotTierXml.trimEnd() : undefined;
|
|
348
|
-
}
|
|
349
|
-
function buildHotTierContext() {
|
|
350
|
-
if (!config.memoryInjectEnabled) {
|
|
351
|
-
return undefined;
|
|
352
|
-
}
|
|
353
|
-
const hotTierXml = renderHotTierForActiveScope();
|
|
354
|
-
if (!hotTierXml) {
|
|
355
|
-
return undefined;
|
|
356
|
-
}
|
|
357
|
-
return hotTierXml.trimEnd();
|
|
358
|
-
}
|
|
359
|
-
function buildPerTurnMemoryHooks(sessionKey) {
|
|
360
|
-
if (!config.memoryInjectEnabled) {
|
|
361
|
-
return undefined;
|
|
362
|
-
}
|
|
363
|
-
return {
|
|
364
|
-
onUserPromptSubmitted: () => {
|
|
365
|
-
const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
|
|
366
|
-
return hotTierXml ? { additionalContext: hotTierXml } : undefined;
|
|
367
|
-
},
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
function getSystemMessageOptions(memorySummary) {
|
|
196
|
+
function getSystemMessageOptions(memorySummary, hotTierXml) {
|
|
371
197
|
return {
|
|
372
198
|
selfEditEnabled: config.selfEditEnabled,
|
|
373
199
|
memorySummary: memorySummary || undefined,
|
|
374
|
-
hotTierXml
|
|
200
|
+
hotTierXml,
|
|
375
201
|
agentRoster: buildAgentRoster(),
|
|
376
202
|
userContext: currentUserContext,
|
|
377
203
|
};
|
|
@@ -407,15 +233,9 @@ function updateRequestContext(source) {
|
|
|
407
233
|
}
|
|
408
234
|
}
|
|
409
235
|
export function feedAgentResult(taskId, agentSlug, result) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
finalResult: result,
|
|
414
|
-
copilotClient,
|
|
415
|
-
}).catch((error) => {
|
|
416
|
-
log.error({ err: error, taskId }, "memory.eot.error");
|
|
417
|
-
});
|
|
418
|
-
}
|
|
236
|
+
void memoryCoordinator?.onAgentTaskComplete(taskId, result).catch((error) => {
|
|
237
|
+
log.error({ err: error, taskId }, "memory.eot.error");
|
|
238
|
+
});
|
|
419
239
|
const sessionKey = getTaskSessionKey(taskId) || "default";
|
|
420
240
|
const agentTurnId = randomUUID();
|
|
421
241
|
const agentDisplayName = getAgentRegistry().find((agent) => agent.slug === agentSlug)?.name ?? agentSlug;
|
|
@@ -509,13 +329,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
509
329
|
let { tools, mcpServers, skillDirectories } = baseConfig;
|
|
510
330
|
const isProjectSession = sessionKey.startsWith("project:");
|
|
511
331
|
const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
|
|
512
|
-
const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
|
|
513
332
|
const infiniteSessions = {
|
|
514
333
|
enabled: true,
|
|
515
334
|
backgroundCompactionThreshold: 0.80,
|
|
516
335
|
bufferExhaustionThreshold: 0.95,
|
|
517
336
|
};
|
|
518
|
-
const memoryHooks =
|
|
337
|
+
const memoryHooks = memoryCoordinator?.buildPerTurnHooks(sessionKey);
|
|
519
338
|
let model = config.copilotModel;
|
|
520
339
|
let systemMessageContent;
|
|
521
340
|
let sessionMode = isProjectSession ? "project" : "default";
|
|
@@ -525,7 +344,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
525
344
|
client: copilotClient,
|
|
526
345
|
onAgentTaskComplete: feedAgentResult,
|
|
527
346
|
})));
|
|
528
|
-
const scopedHotTier =
|
|
347
|
+
const scopedHotTier = (await memoryCoordinator?.buildHotTierContext(sessionKey)) || undefined;
|
|
529
348
|
const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
|
|
530
349
|
systemMessageContent = [
|
|
531
350
|
composeAgentSystemMessage(persistentAgent),
|
|
@@ -536,8 +355,9 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
536
355
|
}
|
|
537
356
|
else {
|
|
538
357
|
const memorySummary = getWikiSummary();
|
|
358
|
+
const hotTierXml = (await memoryCoordinator?.buildHotTierContext(sessionKey)) || undefined;
|
|
539
359
|
systemMessageContent = getOrchestratorSystemMessage({
|
|
540
|
-
...getSystemMessageOptions(memorySummary),
|
|
360
|
+
...getSystemMessageOptions(memorySummary, hotTierXml),
|
|
541
361
|
version: CHAPTERHOUSE_VERSION,
|
|
542
362
|
});
|
|
543
363
|
}
|
|
@@ -559,7 +379,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
559
379
|
infiniteSessions,
|
|
560
380
|
});
|
|
561
381
|
log.info({ sessionKey }, "Session resumed successfully");
|
|
562
|
-
|
|
382
|
+
memoryCoordinator?.reset(sessionKey);
|
|
563
383
|
upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
|
|
564
384
|
const mgr = registry?.get(sessionKey);
|
|
565
385
|
if (mgr)
|
|
@@ -586,7 +406,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
586
406
|
infiniteSessions,
|
|
587
407
|
});
|
|
588
408
|
log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
|
|
589
|
-
|
|
409
|
+
memoryCoordinator?.reset(sessionKey);
|
|
590
410
|
upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
|
|
591
411
|
if (sessionKey === "default")
|
|
592
412
|
setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
|
|
@@ -597,6 +417,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
|
|
|
597
417
|
}
|
|
598
418
|
export async function initOrchestrator(client) {
|
|
599
419
|
copilotClient = client;
|
|
420
|
+
memoryCoordinator?.shutdown();
|
|
421
|
+
memoryCoordinator = new MemoryCoordinator({
|
|
422
|
+
getCopilotClient: () => copilotClient,
|
|
423
|
+
config,
|
|
424
|
+
resolveScopeForSession: getMemoryScopeForSession,
|
|
425
|
+
});
|
|
600
426
|
// Initialize per-task ring buffer — subscribes to agentEventBus for session:tool_call events.
|
|
601
427
|
initTaskEventLog();
|
|
602
428
|
// (Re-)create the registry — supports multiple initOrchestrator calls in tests
|
|
@@ -893,12 +719,8 @@ async function executeOnSession(manager, item) {
|
|
|
893
719
|
spawnArgsMap.delete(taskId);
|
|
894
720
|
activeSubagentTaskIds.delete(taskId);
|
|
895
721
|
db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
|
|
896
|
-
if (
|
|
897
|
-
void
|
|
898
|
-
taskId,
|
|
899
|
-
finalResult,
|
|
900
|
-
copilotClient,
|
|
901
|
-
}).catch((error) => {
|
|
722
|
+
if (finalResult) {
|
|
723
|
+
void memoryCoordinator?.onAgentTaskComplete(taskId, finalResult).catch((error) => {
|
|
902
724
|
log.error({ err: error, taskId }, "memory.eot.error");
|
|
903
725
|
});
|
|
904
726
|
}
|
|
@@ -1249,8 +1071,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
1249
1071
|
logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
|
|
1250
1072
|
}
|
|
1251
1073
|
catch { /* best-effort */ }
|
|
1252
|
-
|
|
1253
|
-
|
|
1074
|
+
void memoryCoordinator?.onTurnComplete(sessionKey, prompt, finalContent, source.type).catch((error) => {
|
|
1075
|
+
log.error({ err: error, sessionKey }, "memory.turn_complete.error");
|
|
1076
|
+
});
|
|
1254
1077
|
if (copilotClient) {
|
|
1255
1078
|
maybeWriteEpisode(copilotClient).catch((err) => {
|
|
1256
1079
|
log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
|
|
@@ -1350,8 +1173,9 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
|
|
|
1350
1173
|
logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
|
|
1351
1174
|
}
|
|
1352
1175
|
catch { /* best-effort */ }
|
|
1353
|
-
|
|
1354
|
-
|
|
1176
|
+
void memoryCoordinator?.onTurnComplete(sessionKey, newPrompt, finalContent, source.type).catch((error) => {
|
|
1177
|
+
log.error({ err: error, sessionKey }, "memory.turn_complete.error");
|
|
1178
|
+
});
|
|
1355
1179
|
if (copilotClient) {
|
|
1356
1180
|
maybeWriteEpisode(copilotClient).catch((err) => {
|
|
1357
1181
|
log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
|
|
@@ -1491,15 +1315,13 @@ export function getAgentInfo() {
|
|
|
1491
1315
|
}
|
|
1492
1316
|
/** Clean up on shutdown/restart. */
|
|
1493
1317
|
export async function shutdownAgents() {
|
|
1318
|
+
memoryCoordinator?.shutdown();
|
|
1319
|
+
memoryCoordinator = undefined;
|
|
1494
1320
|
if (!registry) {
|
|
1495
|
-
checkpointTrackers.clear();
|
|
1496
|
-
checkpointTurnsBySession.clear();
|
|
1497
1321
|
await clearActiveTasks();
|
|
1498
1322
|
return;
|
|
1499
1323
|
}
|
|
1500
1324
|
await registry.shutdown();
|
|
1501
|
-
checkpointTrackers.clear();
|
|
1502
|
-
checkpointTurnsBySession.clear();
|
|
1503
1325
|
await clearActiveTasks();
|
|
1504
1326
|
}
|
|
1505
1327
|
//# sourceMappingURL=orchestrator.js.map
|
|
@@ -158,6 +158,117 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
158
158
|
DEFAULT_MODEL: "fallback-model",
|
|
159
159
|
},
|
|
160
160
|
});
|
|
161
|
+
t.mock.module("./memory-coordinator.js", {
|
|
162
|
+
namedExports: {
|
|
163
|
+
MemoryCoordinator: class {
|
|
164
|
+
checkpointTrackers = new Map();
|
|
165
|
+
checkpointTurnsBySession = new Map();
|
|
166
|
+
housekeepingTurnsBySession = new Map();
|
|
167
|
+
constructor(_options) { }
|
|
168
|
+
getCheckpointTracker(sessionKey) {
|
|
169
|
+
let tracker = this.checkpointTrackers.get(sessionKey);
|
|
170
|
+
if (!tracker) {
|
|
171
|
+
tracker = { turns: 0 };
|
|
172
|
+
this.checkpointTrackers.set(sessionKey, tracker);
|
|
173
|
+
}
|
|
174
|
+
return tracker;
|
|
175
|
+
}
|
|
176
|
+
async onTurnComplete(sessionKey, prompt, response, source) {
|
|
177
|
+
if (source === "background") {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const tracker = this.getCheckpointTracker(sessionKey);
|
|
181
|
+
state.checkpointTickCalls++;
|
|
182
|
+
tracker.turns++;
|
|
183
|
+
const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
|
|
184
|
+
turns.push({ user: prompt.trim(), assistant: response.trim() });
|
|
185
|
+
this.checkpointTurnsBySession.set(sessionKey, turns);
|
|
186
|
+
if (state.config.memoryCheckpointEnabled !== false && tracker.turns >= state.checkpointShouldFireAfter && !state.checkpointInFlight) {
|
|
187
|
+
state.checkpointMarkFiredCalls++;
|
|
188
|
+
tracker.turns = 0;
|
|
189
|
+
state.checkpointRuns.push({
|
|
190
|
+
sessionKey,
|
|
191
|
+
turns: turns.slice(-5),
|
|
192
|
+
activeScope: state.activeScope ?? null,
|
|
193
|
+
trigger: "cadence",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (state.config.memoryHousekeepingEnabled === false) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const count = (this.housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
|
|
200
|
+
const cadence = state.config.memoryHousekeepingTurns ?? 50;
|
|
201
|
+
if (count < cadence) {
|
|
202
|
+
this.housekeepingTurnsBySession.set(sessionKey, count);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
this.housekeepingTurnsBySession.set(sessionKey, 0);
|
|
206
|
+
if (!state.activeScope || state.housekeepingInFlight) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
state.housekeepingRuns.push({ scopeIds: [state.activeScope.id] });
|
|
210
|
+
}
|
|
211
|
+
async onScopeChange(sessionKey, prev, next) {
|
|
212
|
+
if (!prev || state.config.memoryCheckpointOnScopeChange === false || state.checkpointInFlight) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const tracker = this.getCheckpointTracker(sessionKey);
|
|
216
|
+
if (tracker.turns < (state.config.memoryCheckpointMinTurnsForScopeFire ?? 2)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
|
|
220
|
+
if (turns.length === 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
state.checkpointMarkScopeChangeFireCalls++;
|
|
224
|
+
tracker.turns = 0;
|
|
225
|
+
state.checkpointRuns.push({
|
|
226
|
+
sessionKey,
|
|
227
|
+
turns: turns.slice(-5),
|
|
228
|
+
activeScope: { slug: prev },
|
|
229
|
+
trigger: "scope_change",
|
|
230
|
+
scopeChangeContext: { from: prev, to: next || "no active scope" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
async buildHotTierContext(sessionKey) {
|
|
234
|
+
if (state.config.memoryInjectEnabled === false) {
|
|
235
|
+
return "";
|
|
236
|
+
}
|
|
237
|
+
if (sessionKey.startsWith("agent:")) {
|
|
238
|
+
const agent = state.registry.find((entry) => `agent:${entry.slug}` === sessionKey);
|
|
239
|
+
if (agent?.scope) {
|
|
240
|
+
return (state.hotTierByScope?.get(agent.scope) ?? "").trimEnd();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const xml = state.hotTierXml ?? state.hotTierByScope?.get(state.activeScope?.slug ?? "") ?? "";
|
|
244
|
+
return xml.trimEnd();
|
|
245
|
+
}
|
|
246
|
+
buildPerTurnHooks(sessionKey) {
|
|
247
|
+
if (state.config.memoryInjectEnabled === false) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
onUserPromptSubmitted: async () => {
|
|
252
|
+
const additionalContext = await this.buildHotTierContext(sessionKey);
|
|
253
|
+
return additionalContext ? { additionalContext } : undefined;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async onAgentTaskComplete(_taskId, _result) { }
|
|
258
|
+
reset(sessionKey) {
|
|
259
|
+
state.checkpointResetCalls++;
|
|
260
|
+
this.getCheckpointTracker(sessionKey).turns = 0;
|
|
261
|
+
this.checkpointTurnsBySession.delete(sessionKey);
|
|
262
|
+
this.housekeepingTurnsBySession.delete(sessionKey);
|
|
263
|
+
}
|
|
264
|
+
shutdown() {
|
|
265
|
+
this.checkpointTrackers.clear();
|
|
266
|
+
this.checkpointTurnsBySession.clear();
|
|
267
|
+
this.housekeepingTurnsBySession.clear();
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
});
|
|
161
272
|
t.mock.module("../memory/hot-tier.js", {
|
|
162
273
|
namedExports: {
|
|
163
274
|
renderHotTierForActiveScope: () => state.hotTierXml ?? "",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const PR_TYPES_CONFIG_PATH = join(__dirname, "..", "..", ".pr-types.json");
|
|
6
|
+
function loadPrTitleTypes() {
|
|
7
|
+
const parsed = JSON.parse(readFileSync(PR_TYPES_CONFIG_PATH, "utf-8"));
|
|
8
|
+
if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string" || value.trim().length === 0)) {
|
|
9
|
+
throw new Error(`Invalid PR title types config at ${PR_TYPES_CONFIG_PATH}`);
|
|
10
|
+
}
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
const VALID_PR_TITLE_TYPES = loadPrTitleTypes();
|
|
14
|
+
const PR_TITLE_PATTERN = new RegExp(`^(?<type>${VALID_PR_TITLE_TYPES.join("|")})(?:\\((?<scope>[^()\\r\\n]+)\\))?: (?<description>.+)$`);
|
|
15
|
+
function examplesBlock() {
|
|
16
|
+
return [
|
|
17
|
+
"Examples:",
|
|
18
|
+
" Valid: feat: add user search",
|
|
19
|
+
" Valid: fix(auth): handle token expiry",
|
|
20
|
+
" Valid: test: memory tiering edge cases",
|
|
21
|
+
" Invalid: adding new thing",
|
|
22
|
+
" Invalid: WIP",
|
|
23
|
+
" Invalid: HOTFIX",
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
26
|
+
function allowedTypesLine() {
|
|
27
|
+
return `Allowed types: ${VALID_PR_TITLE_TYPES.join(", ")}.`;
|
|
28
|
+
}
|
|
29
|
+
function isAllCapsDescription(description) {
|
|
30
|
+
const lettersOnly = description.replace(/[^A-Za-z]/g, "");
|
|
31
|
+
return lettersOnly.length > 0 && lettersOnly === lettersOnly.toUpperCase();
|
|
32
|
+
}
|
|
33
|
+
export function explainPrTitleValidation(title) {
|
|
34
|
+
const normalizedTitle = title.trim();
|
|
35
|
+
if (!normalizedTitle) {
|
|
36
|
+
return {
|
|
37
|
+
valid: false,
|
|
38
|
+
message: [
|
|
39
|
+
"PR title is required.",
|
|
40
|
+
"Use conventional commit format: type(optional-scope): description",
|
|
41
|
+
allowedTypesLine(),
|
|
42
|
+
examplesBlock(),
|
|
43
|
+
].join("\n"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const match = PR_TITLE_PATTERN.exec(normalizedTitle);
|
|
47
|
+
if (!match?.groups) {
|
|
48
|
+
return {
|
|
49
|
+
valid: false,
|
|
50
|
+
message: [
|
|
51
|
+
`PR title \"${normalizedTitle}\" must match conventional commit format: type(optional-scope): description`,
|
|
52
|
+
allowedTypesLine(),
|
|
53
|
+
examplesBlock(),
|
|
54
|
+
].join("\n"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const description = match.groups.description.trim();
|
|
58
|
+
if (!description) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
message: [
|
|
62
|
+
"PR title description must be non-empty.",
|
|
63
|
+
examplesBlock(),
|
|
64
|
+
].join("\n"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (isAllCapsDescription(description)) {
|
|
68
|
+
return {
|
|
69
|
+
valid: false,
|
|
70
|
+
message: [
|
|
71
|
+
`PR title description must not be ALL CAPS: \"${description}\".`,
|
|
72
|
+
"Use a short, sentence-style description after the conventional type prefix.",
|
|
73
|
+
examplesBlock(),
|
|
74
|
+
].join("\n"),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
valid: true,
|
|
79
|
+
message: `PR title is valid: ${normalizedTitle}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function isValidPrTitle(title) {
|
|
83
|
+
return explainPrTitleValidation(title).valid;
|
|
84
|
+
}
|
|
85
|
+
export function assertValidPrTitle(title) {
|
|
86
|
+
const result = explainPrTitleValidation(title);
|
|
87
|
+
if (!result.valid) {
|
|
88
|
+
throw new Error(result.message);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export { VALID_PR_TITLE_TYPES };
|
|
92
|
+
//# sourceMappingURL=pr-title.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { explainPrTitleValidation, isValidPrTitle } from "./pr-title.js";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PR_TYPES_CONFIG_PATH = join(__dirname, "..", "..", ".pr-types.json");
|
|
9
|
+
test("accepts conventional PR titles with and without scopes", () => {
|
|
10
|
+
assert.equal(isValidPrTitle("feat: add user search"), true);
|
|
11
|
+
assert.equal(isValidPrTitle("fix(auth): handle token expiry"), true);
|
|
12
|
+
assert.equal(isValidPrTitle("test: memory tiering edge cases"), true);
|
|
13
|
+
assert.equal(isValidPrTitle("release: v1.2.3"), true);
|
|
14
|
+
});
|
|
15
|
+
test("rejects blank or malformed PR titles with clear guidance", () => {
|
|
16
|
+
const blank = explainPrTitleValidation(" ");
|
|
17
|
+
assert.equal(blank.valid, false);
|
|
18
|
+
assert.match(blank.message, /PR title is required/i);
|
|
19
|
+
assert.match(blank.message, /feat: add user search/);
|
|
20
|
+
const malformed = explainPrTitleValidation("adding new thing");
|
|
21
|
+
assert.equal(malformed.valid, false);
|
|
22
|
+
assert.match(malformed.message, /must match conventional commit format/i);
|
|
23
|
+
assert.match(malformed.message, /type\(optional-scope\): description/i);
|
|
24
|
+
});
|
|
25
|
+
test("rejects all-caps descriptions even when the prefix is valid", () => {
|
|
26
|
+
const result = explainPrTitleValidation("fix: HOTFIX");
|
|
27
|
+
assert.equal(result.valid, false);
|
|
28
|
+
assert.match(result.message, /description must not be all caps/i);
|
|
29
|
+
});
|
|
30
|
+
test("rejects unsupported types", () => {
|
|
31
|
+
const result = explainPrTitleValidation("hotfix: patch prod issue");
|
|
32
|
+
assert.equal(result.valid, false);
|
|
33
|
+
assert.match(result.message, /allowed types/i);
|
|
34
|
+
assert.match(result.message, /feat, fix, docs, style, refactor, perf, test, chore, build, ci, revert, release/);
|
|
35
|
+
});
|
|
36
|
+
test("loads PR title types from the shared config file", () => {
|
|
37
|
+
const raw = readFileSync(PR_TYPES_CONFIG_PATH, "utf-8");
|
|
38
|
+
const types = JSON.parse(raw);
|
|
39
|
+
assert.deepEqual(types, [
|
|
40
|
+
"feat",
|
|
41
|
+
"fix",
|
|
42
|
+
"docs",
|
|
43
|
+
"style",
|
|
44
|
+
"refactor",
|
|
45
|
+
"perf",
|
|
46
|
+
"test",
|
|
47
|
+
"chore",
|
|
48
|
+
"build",
|
|
49
|
+
"ci",
|
|
50
|
+
"revert",
|
|
51
|
+
"release",
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=pr-title.test.js.map
|
|
@@ -29,6 +29,7 @@ async function loadRouterModule(t, options = {}) {
|
|
|
29
29
|
return { router, state };
|
|
30
30
|
}
|
|
31
31
|
test("router config defaults personal-mode auto-routing to standard-cost routes before opt-in", async (t) => {
|
|
32
|
+
// Security/Billing: personal mode must not silently spend premium quota until the user explicitly opts in.
|
|
32
33
|
const { router } = await loadRouterModule(t, { mode: "personal" });
|
|
33
34
|
assert.deepEqual(router.getRouterConfig(), {
|
|
34
35
|
enabled: true,
|
|
@@ -55,6 +56,7 @@ test("router config keeps auto-routing off by default in team mode", async (t) =
|
|
|
55
56
|
assert.equal(router.getRouterConfig().enabled, false);
|
|
56
57
|
});
|
|
57
58
|
test("saving router config in personal mode opts into premium defaults and deep-merges tier model updates", async (t) => {
|
|
59
|
+
// Security/Billing: persisted router_config is the explicit consent boundary for premium model usage.
|
|
58
60
|
const { router, state } = await loadRouterModule(t, { mode: "personal" });
|
|
59
61
|
const saved = router.updateRouterConfig({ enabled: true });
|
|
60
62
|
assert.equal(saved.enabled, true);
|
|
@@ -84,6 +86,7 @@ test("resolveModel stays in manual mode when the router is disabled", async (t)
|
|
|
84
86
|
});
|
|
85
87
|
});
|
|
86
88
|
test("resolveModel applies safe design overrides before personal-mode opt-in and ignores partial-word matches", async (t) => {
|
|
89
|
+
// Security/Billing: premium-looking prompts like design work must still stay on standard-cost models until opt-in is stored.
|
|
87
90
|
const { router } = await loadRouterModule(t, {
|
|
88
91
|
classify: async () => "fast",
|
|
89
92
|
});
|
|
@@ -96,7 +99,34 @@ test("resolveModel applies safe design overrides before personal-mode opt-in and
|
|
|
96
99
|
assert.equal(noOverride.model, "gpt-4.1");
|
|
97
100
|
assert.equal(noOverride.tier, "fast");
|
|
98
101
|
});
|
|
102
|
+
test("stored router opt-in re-enables premium design overrides in personal mode", async (t) => {
|
|
103
|
+
// Security/Billing: once the user explicitly opts in, premium routing should activate so future regressions do not strand consented users on downgraded routing.
|
|
104
|
+
const { router } = await loadRouterModule(t, {
|
|
105
|
+
mode: "personal",
|
|
106
|
+
storedConfig: JSON.stringify({
|
|
107
|
+
enabled: true,
|
|
108
|
+
tierModels: {
|
|
109
|
+
fast: "gpt-4.1",
|
|
110
|
+
standard: "claude-sonnet-4.6",
|
|
111
|
+
premium: "claude-opus-4.6",
|
|
112
|
+
},
|
|
113
|
+
overrides: [
|
|
114
|
+
{
|
|
115
|
+
name: "design",
|
|
116
|
+
keywords: ["design", "ui"],
|
|
117
|
+
model: "claude-opus-4.6",
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
cooldownMessages: 2,
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
const result = await router.resolveModel("Need a UI refresh", "claude-sonnet-4.6", [], {});
|
|
124
|
+
assert.equal(result.overrideName, "design");
|
|
125
|
+
assert.equal(result.model, "claude-opus-4.6");
|
|
126
|
+
assert.equal(result.switched, true);
|
|
127
|
+
});
|
|
99
128
|
test("short follow-ups inherit the previous tier without forcing premium before opt-in", async (t) => {
|
|
129
|
+
// Security/Billing: follow-up replies must not become a backdoor that silently upgrades personal-mode users to premium routing.
|
|
100
130
|
const { router } = await loadRouterModule(t);
|
|
101
131
|
const result = await router.resolveModel("yes", "claude-sonnet-4.6", ["premium"]);
|
|
102
132
|
assert.deepEqual(result, {
|