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
|
@@ -4,6 +4,85 @@ import { ADO_ORG, ADO_PROJECT, FIELDS, STD_FIELDS, UNIT_FIELD, WIT } from "./ado
|
|
|
4
4
|
function escapeWiqlLiteral(value) {
|
|
5
5
|
return value.replace(/'/g, "''");
|
|
6
6
|
}
|
|
7
|
+
const ALLOWED_WIQL_FIELDS = new Set([
|
|
8
|
+
"System.Id",
|
|
9
|
+
"System.TeamProject",
|
|
10
|
+
"System.WorkItemType",
|
|
11
|
+
STD_FIELDS.TITLE,
|
|
12
|
+
STD_FIELDS.STATE,
|
|
13
|
+
STD_FIELDS.PARENT,
|
|
14
|
+
STD_FIELDS.ASSIGNED_TO,
|
|
15
|
+
STD_FIELDS.TAGS,
|
|
16
|
+
FIELDS.CURRENT_VALUE,
|
|
17
|
+
FIELDS.TARGET_VALUE,
|
|
18
|
+
FIELDS.OKR_PERIOD,
|
|
19
|
+
FIELDS.OKR_OWNER,
|
|
20
|
+
UNIT_FIELD,
|
|
21
|
+
]);
|
|
22
|
+
const ALLOWED_WIQL_OPERATORS = new Set(["=", "<>", "IN", "CONTAINS"]);
|
|
23
|
+
const ALLOWED_WIQL_MACROS = new Set(["@project"]);
|
|
24
|
+
function quoteWiqlField(field) {
|
|
25
|
+
if (!ALLOWED_WIQL_FIELDS.has(field)) {
|
|
26
|
+
throw new Error(`Unsupported WIQL field: ${field}`);
|
|
27
|
+
}
|
|
28
|
+
return `[${field}]`;
|
|
29
|
+
}
|
|
30
|
+
function normalizeWiqlOperator(operator) {
|
|
31
|
+
if (ALLOWED_WIQL_OPERATORS.has(operator)) {
|
|
32
|
+
return operator;
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Unsupported WIQL operator: ${operator}`);
|
|
35
|
+
}
|
|
36
|
+
function formatWiqlScalar(value) {
|
|
37
|
+
if (typeof value === "number") {
|
|
38
|
+
if (!Number.isFinite(value)) {
|
|
39
|
+
throw new Error(`Unsupported WIQL number: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
return `'${escapeWiqlLiteral(value)}'`;
|
|
44
|
+
}
|
|
45
|
+
function formatWiqlValue(value, operator) {
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
if (operator !== "IN") {
|
|
48
|
+
throw new Error(`WIQL operator ${operator} does not accept a list value.`);
|
|
49
|
+
}
|
|
50
|
+
if (value.length === 0) {
|
|
51
|
+
throw new Error("WIQL IN clauses require at least one value.");
|
|
52
|
+
}
|
|
53
|
+
return `(${value.map((item) => formatWiqlScalar(item)).join(", ")})`;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === "object" && value !== null && "macro" in value) {
|
|
56
|
+
if (!ALLOWED_WIQL_MACROS.has(value.macro)) {
|
|
57
|
+
throw new Error(`Unsupported WIQL macro: ${value.macro}`);
|
|
58
|
+
}
|
|
59
|
+
return value.macro;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
62
|
+
return formatWiqlScalar(value);
|
|
63
|
+
}
|
|
64
|
+
throw new Error("Unsupported WIQL value.");
|
|
65
|
+
}
|
|
66
|
+
function formatWiqlCondition(condition) {
|
|
67
|
+
const field = quoteWiqlField(condition.field);
|
|
68
|
+
const operator = normalizeWiqlOperator(condition.operator);
|
|
69
|
+
return `${field} ${operator} ${formatWiqlValue(condition.value, operator)}`;
|
|
70
|
+
}
|
|
71
|
+
export function buildWiqlQuery(query) {
|
|
72
|
+
if (query.select.length === 0) {
|
|
73
|
+
throw new Error("WIQL queries must select at least one field.");
|
|
74
|
+
}
|
|
75
|
+
if (query.where.length === 0) {
|
|
76
|
+
throw new Error("WIQL queries must include at least one filter.");
|
|
77
|
+
}
|
|
78
|
+
const selectClause = query.select.map((field) => quoteWiqlField(field)).join(", ");
|
|
79
|
+
const whereClause = query.where.map((condition) => formatWiqlCondition(condition)).join(" AND ");
|
|
80
|
+
return [
|
|
81
|
+
`SELECT ${selectClause}`,
|
|
82
|
+
"FROM WorkItems",
|
|
83
|
+
`WHERE ${whereClause}`,
|
|
84
|
+
].join(" ");
|
|
85
|
+
}
|
|
7
86
|
function getStringField(fields, fieldName) {
|
|
8
87
|
const value = fields?.[fieldName];
|
|
9
88
|
if (typeof value === "string") {
|
|
@@ -47,15 +126,17 @@ function toPercentComplete(currentValue, targetValue) {
|
|
|
47
126
|
return Math.max(0, Math.min(100, Math.round((currentValue / targetValue) * 100)));
|
|
48
127
|
}
|
|
49
128
|
function buildWiql(workItemType, period) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
: ""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
129
|
+
const where = [
|
|
130
|
+
{ field: "System.TeamProject", operator: "=", value: { macro: "@project" } },
|
|
131
|
+
{ field: "System.WorkItemType", operator: "=", value: workItemType },
|
|
132
|
+
];
|
|
133
|
+
if (period) {
|
|
134
|
+
where.push({ field: FIELDS.OKR_PERIOD, operator: "=", value: period });
|
|
135
|
+
}
|
|
136
|
+
return buildWiqlQuery({
|
|
137
|
+
select: ["System.Id"],
|
|
138
|
+
where,
|
|
139
|
+
});
|
|
59
140
|
}
|
|
60
141
|
function getWorkItemIds(result) {
|
|
61
142
|
return (result.workItems ?? [])
|
|
@@ -173,4 +173,60 @@ test("getOKRSummary computes percent complete from KR progress", async () => {
|
|
|
173
173
|
assert.equal(summary.objectives[0]?.keyResults[0]?.percentComplete, 75);
|
|
174
174
|
assert.equal(summary.objectives[0]?.keyResults[1]?.percentComplete, 50);
|
|
175
175
|
});
|
|
176
|
+
test("buildWiqlQuery rejects non-whitelisted field names", async () => {
|
|
177
|
+
const ado = await loadAdoClientModule();
|
|
178
|
+
assert.ok(ado, "ado client module should exist");
|
|
179
|
+
assert.throws(() => ado.buildWiqlQuery({
|
|
180
|
+
select: ["System.Id"],
|
|
181
|
+
where: [
|
|
182
|
+
{
|
|
183
|
+
field: "System.Title] OR [System.Id",
|
|
184
|
+
operator: "=",
|
|
185
|
+
value: "Ship SSO to all tenants",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
}), /Unsupported WIQL field/);
|
|
189
|
+
});
|
|
190
|
+
test("buildWiqlQuery rejects non-whitelisted operators", async () => {
|
|
191
|
+
const ado = await loadAdoClientModule();
|
|
192
|
+
assert.ok(ado, "ado client module should exist");
|
|
193
|
+
assert.throws(() => ado.buildWiqlQuery({
|
|
194
|
+
select: ["System.Id"],
|
|
195
|
+
where: [
|
|
196
|
+
{
|
|
197
|
+
field: "System.Title",
|
|
198
|
+
operator: "= '' OR [System.Id] > 0",
|
|
199
|
+
value: "Ship SSO to all tenants",
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}), /Unsupported WIQL operator/);
|
|
203
|
+
});
|
|
204
|
+
test("getKeyResults escapes WIQL literal values before querying", async () => {
|
|
205
|
+
const ado = await loadAdoClientModule();
|
|
206
|
+
assert.ok(ado, "ado client module should exist");
|
|
207
|
+
const queries = [];
|
|
208
|
+
const client = new ado.AdoClient({
|
|
209
|
+
org: "https://dev.azure.com/example-org",
|
|
210
|
+
project: "example-project",
|
|
211
|
+
pat: "test-pat",
|
|
212
|
+
workItemTrackingApi: {
|
|
213
|
+
async queryByWiql(wiql) {
|
|
214
|
+
queries.push(wiql.query ?? "");
|
|
215
|
+
return { workItems: [] };
|
|
216
|
+
},
|
|
217
|
+
async getWorkItems() {
|
|
218
|
+
throw new Error("not used in this test");
|
|
219
|
+
},
|
|
220
|
+
async updateWorkItem() {
|
|
221
|
+
throw new Error("not used in this test");
|
|
222
|
+
},
|
|
223
|
+
async addComment() {
|
|
224
|
+
throw new Error("not used in this test");
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
await client.getKeyResults("2026-Q2' OR [System.Id] > 0 OR 'x'='x");
|
|
229
|
+
assert.equal(queries.length, 1);
|
|
230
|
+
assert.match(queries[0] ?? "", /\[Custom\.OKRPeriod\] = '2026-Q2'' OR \[System\.Id\] > 0 OR ''x''=''x'/);
|
|
231
|
+
});
|
|
176
232
|
//# sourceMappingURL=ado-client.test.js.map
|
|
@@ -17,6 +17,7 @@ export class TeamPushClient {
|
|
|
17
17
|
this.getCurrentUser = options.getCurrentUser;
|
|
18
18
|
this.modeContext = new ModeContext({
|
|
19
19
|
...config,
|
|
20
|
+
chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
|
|
20
21
|
teamChapterhouseUrl: this.teamChapterhouseUrl,
|
|
21
22
|
standaloneMode: this.standaloneMode,
|
|
22
23
|
});
|
|
@@ -13,6 +13,7 @@ test("pushUpdate sends the expected payload to the team update endpoint", async
|
|
|
13
13
|
assert.ok(teamPushModule, "team push module should exist");
|
|
14
14
|
const requests = [];
|
|
15
15
|
const client = new teamPushModule.TeamPushClient({
|
|
16
|
+
chapterhouseMode: "team",
|
|
16
17
|
teamChapterhouseUrl: "https://team.example.com/",
|
|
17
18
|
standaloneMode: false,
|
|
18
19
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -61,6 +62,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
|
|
|
61
62
|
const teamPushModule = await loadTeamPushModule();
|
|
62
63
|
assert.ok(teamPushModule, "team push module should exist");
|
|
63
64
|
const unauthorizedClient = new teamPushModule.TeamPushClient({
|
|
65
|
+
chapterhouseMode: "team",
|
|
64
66
|
teamChapterhouseUrl: "https://team.example.com",
|
|
65
67
|
teamChapterhouseToken: "fallback-token",
|
|
66
68
|
standaloneMode: false,
|
|
@@ -73,6 +75,7 @@ test("pushUpdate throws descriptive errors for auth and network failures", async
|
|
|
73
75
|
});
|
|
74
76
|
await assert.rejects(unauthorizedClient.pushUpdate({ activity: "shipped auth refactor", krId: "O1-KR2", delta: 5 }), /Failed to push OKR update: unauthorized \(HTTP 401\)/i);
|
|
75
77
|
const networkClient = new teamPushModule.TeamPushClient({
|
|
78
|
+
chapterhouseMode: "team",
|
|
76
79
|
teamChapterhouseUrl: "https://team.example.com",
|
|
77
80
|
standaloneMode: false,
|
|
78
81
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -92,6 +95,7 @@ test("fetchOKRs returns raw OKR page content for the requested period", async ()
|
|
|
92
95
|
assert.ok(teamPushModule, "team push module should exist");
|
|
93
96
|
const requests = [];
|
|
94
97
|
const client = new teamPushModule.TeamPushClient({
|
|
98
|
+
chapterhouseMode: "team",
|
|
95
99
|
teamChapterhouseUrl: "https://team.example.com",
|
|
96
100
|
standaloneMode: false,
|
|
97
101
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -120,6 +124,7 @@ test("writePage PUTs shared wiki content to the team wiki endpoint", async () =>
|
|
|
120
124
|
assert.ok(teamPushModule, "team push module should exist");
|
|
121
125
|
const requests = [];
|
|
122
126
|
const client = new teamPushModule.TeamPushClient({
|
|
127
|
+
chapterhouseMode: "team",
|
|
123
128
|
teamChapterhouseUrl: "https://team.example.com/",
|
|
124
129
|
standaloneMode: false,
|
|
125
130
|
getAuthorizationHeader: () => "Bearer entra-token",
|
|
@@ -152,6 +157,7 @@ test("team push silently no-ops when team integration is disabled", async () =>
|
|
|
152
157
|
assert.ok(teamPushModule, "team push module should exist");
|
|
153
158
|
let called = false;
|
|
154
159
|
const client = new teamPushModule.TeamPushClient({
|
|
160
|
+
chapterhouseMode: "team",
|
|
155
161
|
teamChapterhouseUrl: "",
|
|
156
162
|
standaloneMode: false,
|
|
157
163
|
fetchImpl: async () => {
|
|
@@ -17,6 +17,7 @@ export class TeamsNotifier {
|
|
|
17
17
|
this.warn = options.warn ?? ((message) => log.warn(message));
|
|
18
18
|
this.modeContext = new ModeContext({
|
|
19
19
|
...config,
|
|
20
|
+
chapterhouseMode: options.chapterhouseMode ?? config.chapterhouseMode,
|
|
20
21
|
teamsWebhookUrl: this.webhookUrl,
|
|
21
22
|
teamsNotificationsEnabled: this.enabled,
|
|
22
23
|
});
|
|
@@ -13,6 +13,7 @@ test("sendMessage formats a Teams MessageCard payload", async () => {
|
|
|
13
13
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
14
14
|
const calls = [];
|
|
15
15
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
16
|
+
chapterhouseMode: "team",
|
|
16
17
|
webhookUrl: "https://teams.example.test/webhook",
|
|
17
18
|
enabled: true,
|
|
18
19
|
fetchImpl: async (input, init) => {
|
|
@@ -39,6 +40,7 @@ for (const milestone of [25, 50, 75, 100]) {
|
|
|
39
40
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
40
41
|
const calls = [];
|
|
41
42
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
43
|
+
chapterhouseMode: "team",
|
|
42
44
|
webhookUrl: "https://teams.example.test/webhook",
|
|
43
45
|
enabled: true,
|
|
44
46
|
fetchImpl: async (input, init) => {
|
|
@@ -70,6 +72,7 @@ test("sendMessage does not call Teams when notifications are disabled", async ()
|
|
|
70
72
|
let fetchCalls = 0;
|
|
71
73
|
const warnings = [];
|
|
72
74
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
75
|
+
chapterhouseMode: "team",
|
|
73
76
|
webhookUrl: "https://teams.example.test/webhook",
|
|
74
77
|
enabled: false,
|
|
75
78
|
fetchImpl: async () => {
|
|
@@ -89,6 +92,7 @@ test("notifyWeeklyHealthCheck sends an OKR summary card", async () => {
|
|
|
89
92
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
90
93
|
const calls = [];
|
|
91
94
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
95
|
+
chapterhouseMode: "team",
|
|
92
96
|
webhookUrl: "https://teams.example.test/webhook",
|
|
93
97
|
enabled: true,
|
|
94
98
|
fetchImpl: async (input, init) => {
|
|
@@ -114,6 +118,7 @@ test("notifyStandup sends the member update summary", async () => {
|
|
|
114
118
|
assert.ok(teamsNotify, "teams notifier module should exist");
|
|
115
119
|
const calls = [];
|
|
116
120
|
const notifier = new teamsNotify.TeamsNotifier({
|
|
121
|
+
chapterhouseMode: "team",
|
|
117
122
|
webhookUrl: "https://teams.example.test/webhook",
|
|
118
123
|
enabled: true,
|
|
119
124
|
fetchImpl: async (input, init) => {
|
|
@@ -34,7 +34,6 @@ test.after(async () => {
|
|
|
34
34
|
test("active scope can be set, read, and cleared without changing scope activation status", async () => {
|
|
35
35
|
const { dbModule, memoryModule } = await loadModules();
|
|
36
36
|
dbModule.getDb();
|
|
37
|
-
const getScope = getFunction(memoryModule, "getScope");
|
|
38
37
|
const getActiveScope = getFunction(memoryModule, "getActiveScope");
|
|
39
38
|
const setActiveScope = getFunction(memoryModule, "setActiveScope");
|
|
40
39
|
const deactivateScope = getFunction(memoryModule, "deactivateScope");
|
|
@@ -5,6 +5,7 @@ import { recordDecision } from "./decisions.js";
|
|
|
5
5
|
import { listEntities } from "./entities.js";
|
|
6
6
|
import { recordObservation, listObservations } from "./observations.js";
|
|
7
7
|
import { getScope } from "./scopes.js";
|
|
8
|
+
import { releaseScopeWriteLocks, tryAcquireScopeWriteLocks } from "./scope-lock.js";
|
|
8
9
|
import { listDecisions } from "./decisions.js";
|
|
9
10
|
import { buildCheckpointSystemPrompt, buildCheckpointUserPrompt, } from "./checkpoint-prompt.js";
|
|
10
11
|
const log = childLogger("memory.checkpoint");
|
|
@@ -48,29 +49,35 @@ function calculateSimilarity(left, right) {
|
|
|
48
49
|
return overlap / new Set([...leftTokens, ...rightTokens]).size;
|
|
49
50
|
}
|
|
50
51
|
function parseProposals(raw) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
return parsed.proposals.flatMap((proposal) => {
|
|
56
|
-
if (!proposal || typeof proposal !== "object") {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
const candidate = proposal;
|
|
60
|
-
if ((candidate.kind !== "observation" && candidate.kind !== "decision")
|
|
61
|
-
|| typeof candidate.content !== "string"
|
|
62
|
-
|| typeof candidate.confidence !== "number") {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
if (!Array.isArray(parsed.proposals)) {
|
|
63
55
|
return [];
|
|
64
56
|
}
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
57
|
+
return parsed.proposals.flatMap((proposal) => {
|
|
58
|
+
if (!proposal || typeof proposal !== "object") {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const candidate = proposal;
|
|
62
|
+
if ((candidate.kind !== "observation" && candidate.kind !== "decision")
|
|
63
|
+
|| typeof candidate.content !== "string"
|
|
64
|
+
|| typeof candidate.confidence !== "number") {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return [{
|
|
68
|
+
kind: candidate.kind,
|
|
69
|
+
title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
|
|
70
|
+
content: candidate.content.trim(),
|
|
71
|
+
scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
|
|
72
|
+
confidence: candidate.confidence,
|
|
73
|
+
decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
|
|
74
|
+
}];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
log.warn({ err }, "malformed checkpoint proposals response");
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
74
81
|
}
|
|
75
82
|
function resolveProposalScope(proposal, activeScope) {
|
|
76
83
|
if (!proposal.scope || proposal.scope === activeScope.slug) {
|
|
@@ -81,14 +88,15 @@ function resolveProposalScope(proposal, activeScope) {
|
|
|
81
88
|
function isDuplicateObservation(content, scopeId, batchContents) {
|
|
82
89
|
const existing = listObservations({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
|
|
83
90
|
const combined = [...existing.map((observation) => observation.content), ...batchContents];
|
|
84
|
-
return combined.some((candidate) => calculateSimilarity(candidate, content) >=
|
|
91
|
+
return combined.some((candidate) => calculateSimilarity(candidate, content) >= config.memoryCheckpointDuplicateThreshold);
|
|
85
92
|
}
|
|
86
93
|
function isDuplicateDecision(proposal, scopeId) {
|
|
87
94
|
const existing = listDecisions({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
|
|
88
95
|
return existing.some((decision) => {
|
|
89
96
|
const titleSimilarity = proposal.title ? calculateSimilarity(decision.title, proposal.title) : 0;
|
|
90
97
|
const rationaleSimilarity = calculateSimilarity(decision.rationale, proposal.content);
|
|
91
|
-
return titleSimilarity >=
|
|
98
|
+
return titleSimilarity >= config.memoryCheckpointDuplicateThreshold
|
|
99
|
+
|| rationaleSimilarity >= config.memoryCheckpointDuplicateThreshold;
|
|
92
100
|
});
|
|
93
101
|
}
|
|
94
102
|
export class CheckpointTracker {
|
|
@@ -96,12 +104,8 @@ export class CheckpointTracker {
|
|
|
96
104
|
cadence;
|
|
97
105
|
enabled;
|
|
98
106
|
constructor(options = {}) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const rawEnabled = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED?.trim();
|
|
102
|
-
const envEnabled = rawEnabled === "0" ? false : rawEnabled === "1" ? true : undefined;
|
|
103
|
-
this.cadence = options.turns ?? envTurns ?? config.memoryCheckpointTurns;
|
|
104
|
-
this.enabled = options.enabled ?? envEnabled ?? config.memoryCheckpointEnabled;
|
|
107
|
+
this.cadence = options.turns ?? config.memoryCheckpointTurns;
|
|
108
|
+
this.enabled = options.enabled ?? config.memoryCheckpointEnabled;
|
|
105
109
|
}
|
|
106
110
|
tickOrchestratorTurn() {
|
|
107
111
|
this.turnCountSinceLastFire++;
|
|
@@ -180,65 +184,78 @@ export async function runCheckpointExtraction(input) {
|
|
|
180
184
|
scope: input.activeScope.slug,
|
|
181
185
|
sessionKey: input.sessionKey ?? "default",
|
|
182
186
|
}, "memory.checkpoint.proposal_count");
|
|
187
|
+
const resolvedProposals = proposals.map((proposal) => ({
|
|
188
|
+
proposal,
|
|
189
|
+
scope: resolveProposalScope(proposal, input.activeScope),
|
|
190
|
+
}));
|
|
191
|
+
const lockedScopeIds = [...new Set(resolvedProposals.flatMap(({ scope }) => (scope ? [scope.id] : [])))];
|
|
192
|
+
if (lockedScopeIds.length > 0 && !tryAcquireScopeWriteLocks(lockedScopeIds)) {
|
|
193
|
+
log.info({ sessionKey: input.sessionKey ?? "default", scope: input.activeScope.slug }, "memory.checkpoint.scope_in_flight_skip");
|
|
194
|
+
return { written: 0, skipped: 0, errors };
|
|
195
|
+
}
|
|
183
196
|
let written = 0;
|
|
184
197
|
let skipped = 0;
|
|
185
198
|
const pendingObservationContents = [];
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (written >= MAX_WRITES_PER_CHECKPOINT) {
|
|
193
|
-
skipped++;
|
|
194
|
-
log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
const scope = resolveProposalScope(proposal, input.activeScope);
|
|
198
|
-
if (!scope) {
|
|
199
|
-
skipped++;
|
|
200
|
-
log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
|
|
204
|
-
skipped++;
|
|
205
|
-
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (proposal.kind === "decision") {
|
|
209
|
-
if (!proposal.title) {
|
|
199
|
+
try {
|
|
200
|
+
for (const { proposal, scope } of resolvedProposals) {
|
|
201
|
+
if (proposal.confidence < MIN_CONFIDENCE) {
|
|
210
202
|
skipped++;
|
|
211
|
-
log.info({ reason: "
|
|
203
|
+
log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
212
204
|
continue;
|
|
213
205
|
}
|
|
214
|
-
if (
|
|
206
|
+
if (written >= MAX_WRITES_PER_CHECKPOINT) {
|
|
207
|
+
skipped++;
|
|
208
|
+
log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!scope) {
|
|
212
|
+
skipped++;
|
|
213
|
+
log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
|
|
215
217
|
skipped++;
|
|
216
218
|
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
217
219
|
continue;
|
|
218
220
|
}
|
|
219
|
-
|
|
221
|
+
if (proposal.kind === "decision") {
|
|
222
|
+
if (!proposal.title) {
|
|
223
|
+
skipped++;
|
|
224
|
+
log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (isDuplicateDecision(proposal, scope.id)) {
|
|
228
|
+
skipped++;
|
|
229
|
+
log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const decision = recordDecision({
|
|
233
|
+
scope_id: scope.id,
|
|
234
|
+
title: proposal.title,
|
|
235
|
+
rationale: proposal.content,
|
|
236
|
+
decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
|
|
237
|
+
tier: "warm",
|
|
238
|
+
});
|
|
239
|
+
written++;
|
|
240
|
+
log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const observation = recordObservation({
|
|
220
244
|
scope_id: scope.id,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
|
|
245
|
+
content: proposal.content,
|
|
246
|
+
source: "checkpoint:orchestrator",
|
|
224
247
|
tier: "warm",
|
|
248
|
+
confidence: proposal.confidence,
|
|
225
249
|
});
|
|
250
|
+
pendingObservationContents.push(proposal.content);
|
|
226
251
|
written++;
|
|
227
|
-
log.info({ kind: "
|
|
228
|
-
continue;
|
|
252
|
+
log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
|
|
229
253
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
tier: "warm",
|
|
235
|
-
confidence: proposal.confidence,
|
|
236
|
-
});
|
|
237
|
-
pendingObservationContents.push(proposal.content);
|
|
238
|
-
written++;
|
|
239
|
-
log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
|
|
254
|
+
return { written, skipped, errors };
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
releaseScopeWriteLocks(lockedScopeIds);
|
|
240
258
|
}
|
|
241
|
-
return { written, skipped, errors };
|
|
242
259
|
}
|
|
243
260
|
catch (error) {
|
|
244
261
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -18,6 +18,24 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
|
|
|
18
18
|
const checkpointPromptModule = await import(new URL(`./checkpoint-prompt.js?case=${cacheBust}`, import.meta.url).href);
|
|
19
19
|
return { dbModule, memoryModule, checkpointModule, checkpointPromptModule };
|
|
20
20
|
}
|
|
21
|
+
async function loadModulesWithWarnSpy(t, cacheBust) {
|
|
22
|
+
const warnings = [];
|
|
23
|
+
t.mock.module("../util/logger.js", {
|
|
24
|
+
namedExports: {
|
|
25
|
+
childLogger: () => ({
|
|
26
|
+
info: () => { },
|
|
27
|
+
warn: (...args) => {
|
|
28
|
+
warnings.push(args);
|
|
29
|
+
},
|
|
30
|
+
error: () => { },
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
35
|
+
const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
|
|
36
|
+
const checkpointModule = await import(new URL(`./checkpoint.js?case=${cacheBust}`, import.meta.url).href);
|
|
37
|
+
return { dbModule, memoryModule, checkpointModule, warnings };
|
|
38
|
+
}
|
|
21
39
|
function getFunction(module, name) {
|
|
22
40
|
const value = module[name];
|
|
23
41
|
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
@@ -157,8 +175,8 @@ test("runCheckpointExtraction writes high-confidence memories, skips low-confide
|
|
|
157
175
|
assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Run checkpoint extraction asynchronously"), true);
|
|
158
176
|
assert.equal(listObservations({ scope_id: chapterhouse.id, limit: 20 }).filter((row) => row.content === "Base new memory implementation branches from origin/main after dependent PRs merge.").length, 1);
|
|
159
177
|
});
|
|
160
|
-
test("runCheckpointExtraction
|
|
161
|
-
const { dbModule, memoryModule, checkpointModule } = await
|
|
178
|
+
test("runCheckpointExtraction ignores malformed JSON responses and warns", async (t) => {
|
|
179
|
+
const { dbModule, memoryModule, checkpointModule, warnings } = await loadModulesWithWarnSpy(t, "malformed-json");
|
|
162
180
|
dbModule.getDb();
|
|
163
181
|
const getScope = getFunction(memoryModule, "getScope");
|
|
164
182
|
const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
|
|
@@ -171,7 +189,9 @@ test("runCheckpointExtraction handles malformed JSON responses without crashing"
|
|
|
171
189
|
callLLM: async () => "{ definitely-not-json",
|
|
172
190
|
});
|
|
173
191
|
assert.equal(result.written, 0);
|
|
174
|
-
assert.
|
|
192
|
+
assert.equal(result.skipped, 0);
|
|
193
|
+
assert.deepEqual(result.errors, []);
|
|
194
|
+
assert.equal(warnings.length, 1);
|
|
175
195
|
});
|
|
176
196
|
test("runCheckpointExtraction prevents overlapping executions with the in-flight guard", async () => {
|
|
177
197
|
const { dbModule, memoryModule, checkpointModule } = await loadModules();
|