chapterhouse 0.3.26 → 0.4.1

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.
Files changed (53) hide show
  1. package/dist/api/server.js +12 -0
  2. package/dist/api/server.test.js +39 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.test.js +109 -0
  5. package/dist/copilot/agents.js +32 -6
  6. package/dist/copilot/agents.test.js +41 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +224 -3
  9. package/dist/copilot/orchestrator.test.js +380 -0
  10. package/dist/copilot/prompt-date.js +8 -0
  11. package/dist/copilot/system-message.js +8 -0
  12. package/dist/copilot/system-message.test.js +58 -0
  13. package/dist/copilot/tools.agent.test.js +24 -0
  14. package/dist/copilot/tools.js +351 -4
  15. package/dist/copilot/tools.memory.test.js +297 -0
  16. package/dist/copilot/turn-event-log-env.test.js +19 -0
  17. package/dist/copilot/turn-event-log.js +22 -23
  18. package/dist/copilot/turn-event-log.test.js +61 -2
  19. package/dist/memory/active-scope.js +69 -0
  20. package/dist/memory/active-scope.test.js +76 -0
  21. package/dist/memory/checkpoint-prompt.js +71 -0
  22. package/dist/memory/checkpoint.js +257 -0
  23. package/dist/memory/checkpoint.test.js +255 -0
  24. package/dist/memory/decisions.js +53 -0
  25. package/dist/memory/decisions.test.js +92 -0
  26. package/dist/memory/entities.js +59 -0
  27. package/dist/memory/entities.test.js +65 -0
  28. package/dist/memory/eot.js +219 -0
  29. package/dist/memory/eot.test.js +263 -0
  30. package/dist/memory/hot-tier.js +187 -0
  31. package/dist/memory/hot-tier.test.js +197 -0
  32. package/dist/memory/housekeeping.js +352 -0
  33. package/dist/memory/housekeeping.test.js +280 -0
  34. package/dist/memory/inbox.js +73 -0
  35. package/dist/memory/index.js +11 -0
  36. package/dist/memory/observations.js +46 -0
  37. package/dist/memory/observations.test.js +86 -0
  38. package/dist/memory/recall.js +210 -0
  39. package/dist/memory/recall.test.js +238 -0
  40. package/dist/memory/scopes.js +89 -0
  41. package/dist/memory/scopes.test.js +201 -0
  42. package/dist/memory/tiering.js +193 -0
  43. package/dist/memory/types.js +2 -0
  44. package/dist/paths.js +7 -1
  45. package/dist/store/db.js +412 -8
  46. package/dist/store/db.test.js +83 -0
  47. package/dist/test/setup-env.js +16 -0
  48. package/dist/test/setup-env.test.js +4 -0
  49. package/package.json +1 -1
  50. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  51. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -31,6 +31,7 @@ import { formatSseData, formatSseEvent } from "./sse.js";
31
31
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
32
32
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
33
33
  import { childLogger } from "../util/logger.js";
34
+ import { getActiveScope } from "../memory/active-scope.js";
34
35
  const log = childLogger("server");
35
36
  void searchIndex; // re-exported by index-manager; reference here documents the dep
36
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -838,6 +839,17 @@ app.get("/api/auto", (_req, res) => {
838
839
  lastRoute: lastRoute || null,
839
840
  });
840
841
  });
842
+ app.get("/api/memory/active-scope", (_req, res) => {
843
+ const activeScope = getActiveScope();
844
+ if (!activeScope) {
845
+ res.json(null);
846
+ return;
847
+ }
848
+ res.json({
849
+ slug: activeScope.slug,
850
+ title: activeScope.title,
851
+ });
852
+ });
841
853
  app.post("/api/auto", (req, res) => {
842
854
  const body = parseRequest(autoRequestSchema, req.body ?? {});
843
855
  const updated = updateRouterConfig(body);
@@ -124,6 +124,25 @@ function readProjectRegistryRows(testRoot) {
124
124
  db.close();
125
125
  }
126
126
  }
127
+ function setMemoryActiveScope(testRoot, slug) {
128
+ const db = new Database(getProjectDbPath(testRoot));
129
+ try {
130
+ if (slug === null) {
131
+ db.prepare(`DELETE FROM mem_settings WHERE key = ?`).run("current_scope_slug");
132
+ return;
133
+ }
134
+ const scope = db.prepare(`SELECT slug FROM mem_scopes WHERE slug = ?`).get(slug);
135
+ assert.ok(scope, `expected seeded memory scope '${slug}' to exist`);
136
+ db.prepare(`
137
+ INSERT INTO mem_settings (key, value)
138
+ VALUES (?, ?)
139
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
140
+ `).run("current_scope_slug", slug);
141
+ }
142
+ finally {
143
+ db.close();
144
+ }
145
+ }
127
146
  // Standalone mode triggers a full daemon init via the server→daemon circular import
128
147
  // (server.ts imports restartDaemon from daemon.ts, which has module-level main()).
129
148
  // The Copilot SDK's client.start() then blocks the event loop while authenticating,
@@ -208,6 +227,26 @@ test("server runs in standalone mode without auth", async () => {
208
227
  API_TOKEN: "",
209
228
  }, STANDALONE_API_SERVER_STARTUP_TIMEOUT_MS);
210
229
  });
230
+ test("server exposes the active memory scope API and requires auth", async () => {
231
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
232
+ const unauthorized = await fetch(`${baseUrl}/api/memory/active-scope`);
233
+ assert.equal(unauthorized.status, 401);
234
+ const noScope = await fetch(`${baseUrl}/api/memory/active-scope`, {
235
+ headers: { authorization: authHeader },
236
+ });
237
+ assert.equal(noScope.status, 200);
238
+ assert.equal(await noScope.json(), null);
239
+ setMemoryActiveScope(testRoot, "chapterhouse");
240
+ const activeScope = await fetch(`${baseUrl}/api/memory/active-scope`, {
241
+ headers: { authorization: authHeader },
242
+ });
243
+ assert.equal(activeScope.status, 200);
244
+ assert.deepEqual(await activeScope.json(), {
245
+ slug: "chapterhouse",
246
+ title: "Chapterhouse",
247
+ });
248
+ });
249
+ });
211
250
  test("server bootstrap rejects non-loopback origins", async () => {
212
251
  await withStartedServer(async ({ baseUrl }) => {
213
252
  const response = await fetch(`${baseUrl}/api/bootstrap`, {
package/dist/config.js CHANGED
@@ -46,8 +46,24 @@ const configSchema = z.object({
46
46
  API_RATE_LIMIT_GENERAL_MAX: z.string().optional(),
47
47
  API_RATE_LIMIT_AUTH_MAX: z.string().optional(),
48
48
  API_RATE_LIMIT_SSE_MAX_CONNECTIONS: z.string().optional(),
49
+ CHAPTERHOUSE_SSE_BUFFER_CAPACITY: z.string().optional(),
50
+ CHAPTERHOUSE_SSE_REPLAY_LIMIT: z.string().optional(),
49
51
  CHAPTERHOUSE_WORKIQ_AUTO_INSTALL: z.string().optional(),
50
52
  CHAPTERHOUSE_CHAT_SSE: z.string().optional(),
53
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED: z.string().optional(),
54
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS: z.string().optional(),
55
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE: z.string().optional(),
56
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE: z.string().optional(),
57
+ CHAPTERHOUSE_MEMORY_INJECT: z.string().optional(),
58
+ CHAPTERHOUSE_MEMORY_AUTO_ACCEPT: z.string().optional(),
59
+ CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED: z.string().optional(),
60
+ CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED: z.string().optional(),
61
+ CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS: z.string().optional(),
62
+ CHAPTERHOUSE_MEMORY_DECAY_DAYS: z.string().optional(),
63
+ CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS: z.string().optional(),
64
+ CHAPTERHOUSE_MEMORY_TIERING_ENABLED: z.string().optional(),
65
+ CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST: z.string().optional(),
66
+ CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS: z.string().optional(),
51
67
  });
52
68
  export const DEFAULT_MODEL = "claude-sonnet-4.6";
53
69
  export const DEFAULT_TEAM_WIKI_CACHE_TTL_MINUTES = 60;
@@ -60,6 +76,8 @@ export const DEFAULT_API_RATE_LIMIT_WINDOW_MS = 60_000;
60
76
  export const DEFAULT_API_RATE_LIMIT_GENERAL_MAX = 100;
61
77
  export const DEFAULT_API_RATE_LIMIT_AUTH_MAX = 10;
62
78
  export const DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS = 5;
79
+ export const DEFAULT_SSE_BUFFER_CAPACITY = 2_000;
80
+ export const DEFAULT_SSE_REPLAY_LIMIT = 10_000;
63
81
  function parseZeroOneEnv(name, rawValue, defaultValue) {
64
82
  const normalized = rawValue?.trim();
65
83
  if (!normalized) {
@@ -110,6 +128,17 @@ function parsePositiveIntegerEnv(name, rawValue, defaultValue) {
110
128
  }
111
129
  return parsed;
112
130
  }
131
+ function parsePositiveNumberEnv(name, rawValue, defaultValue) {
132
+ const normalized = rawValue?.trim();
133
+ if (!normalized) {
134
+ return defaultValue;
135
+ }
136
+ const parsed = Number(normalized);
137
+ if (!Number.isFinite(parsed) || parsed <= 0) {
138
+ throw new Error(`${name} must be a positive number, got: "${rawValue}"`);
139
+ }
140
+ return parsed;
141
+ }
113
142
  function parseCorsAllowedOrigins(rawValue) {
114
143
  const normalized = rawValue?.trim();
115
144
  if (!normalized) {
@@ -200,6 +229,15 @@ export function parseRuntimeConfig(env, options = {}) {
200
229
  const apiRateLimitGeneralMax = parsePositiveIntegerEnv("API_RATE_LIMIT_GENERAL_MAX", raw.API_RATE_LIMIT_GENERAL_MAX, DEFAULT_API_RATE_LIMIT_GENERAL_MAX);
201
230
  const apiRateLimitAuthMax = parsePositiveIntegerEnv("API_RATE_LIMIT_AUTH_MAX", raw.API_RATE_LIMIT_AUTH_MAX, DEFAULT_API_RATE_LIMIT_AUTH_MAX);
202
231
  const apiRateLimitSseMaxConnections = parsePositiveIntegerEnv("API_RATE_LIMIT_SSE_MAX_CONNECTIONS", raw.API_RATE_LIMIT_SSE_MAX_CONNECTIONS, DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS);
232
+ const sseBufferCapacity = parsePositiveIntegerEnv("CHAPTERHOUSE_SSE_BUFFER_CAPACITY", raw.CHAPTERHOUSE_SSE_BUFFER_CAPACITY, DEFAULT_SSE_BUFFER_CAPACITY);
233
+ const sseReplayLimit = parsePositiveIntegerEnv("CHAPTERHOUSE_SSE_REPLAY_LIMIT", raw.CHAPTERHOUSE_SSE_REPLAY_LIMIT, DEFAULT_SSE_REPLAY_LIMIT);
234
+ const memoryCheckpointTurns = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS, 5);
235
+ const memoryCheckpointMinTurnsForScopeFire = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE, 2);
236
+ const memoryHousekeepingTurns = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS", raw.CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS, 50);
237
+ const memoryDecayDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_DECAY_DAYS", raw.CHAPTERHOUSE_MEMORY_DECAY_DAYS, 30);
238
+ const memoryInboxRetentionDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS", raw.CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS, 7);
239
+ const memoryHotRecallBoost = parsePositiveNumberEnv("CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST", raw.CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST, 1.5);
240
+ const memoryHotAgeDays = parsePositiveIntegerEnv("CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS", raw.CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS, 30);
203
241
  if (entraAuthEnabled && (!entraTenantId || !entraClientId)) {
204
242
  throw new Error("ENTRA_AUTH_ENABLED=true requires ENTRA_TENANT_ID and ENTRA_CLIENT_ID");
205
243
  }
@@ -239,8 +277,24 @@ export function parseRuntimeConfig(env, options = {}) {
239
277
  apiRateLimitGeneralMax,
240
278
  apiRateLimitAuthMax,
241
279
  apiRateLimitSseMaxConnections,
280
+ sseBufferCapacity,
281
+ sseReplayLimit,
242
282
  workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
243
283
  chatSseEnabled: parseZeroOneEnv("CHAPTERHOUSE_CHAT_SSE", raw.CHAPTERHOUSE_CHAT_SSE, true),
284
+ memoryCheckpointEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED, true),
285
+ memoryCheckpointTurns,
286
+ memoryCheckpointOnScopeChange: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE", raw.CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE, true),
287
+ memoryCheckpointMinTurnsForScopeFire,
288
+ memoryInjectEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_INJECT", raw.CHAPTERHOUSE_MEMORY_INJECT, true),
289
+ memoryAutoAcceptEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_AUTO_ACCEPT", raw.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT, true),
290
+ memoryEndOfTaskHookEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED", raw.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED, true),
291
+ memoryHousekeepingEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED", raw.CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED, true),
292
+ memoryHousekeepingTurns,
293
+ memoryDecayDays,
294
+ memoryInboxRetentionDays,
295
+ memoryTieringEnabled: parseZeroOneEnv("CHAPTERHOUSE_MEMORY_TIERING_ENABLED", raw.CHAPTERHOUSE_MEMORY_TIERING_ENABLED, true),
296
+ memoryHotRecallBoost,
297
+ memoryHotAgeDays,
244
298
  };
245
299
  }
246
300
  const runtimeConfig = parseRuntimeConfig(process.env);
@@ -280,8 +334,24 @@ export const config = {
280
334
  apiRateLimitGeneralMax: runtimeConfig.apiRateLimitGeneralMax,
281
335
  apiRateLimitAuthMax: runtimeConfig.apiRateLimitAuthMax,
282
336
  apiRateLimitSseMaxConnections: runtimeConfig.apiRateLimitSseMaxConnections,
337
+ sseBufferCapacity: runtimeConfig.sseBufferCapacity,
338
+ sseReplayLimit: runtimeConfig.sseReplayLimit,
283
339
  workiqAutoInstall: runtimeConfig.workiqAutoInstall,
284
340
  chatSseEnabled: runtimeConfig.chatSseEnabled,
341
+ memoryCheckpointEnabled: runtimeConfig.memoryCheckpointEnabled,
342
+ memoryCheckpointTurns: runtimeConfig.memoryCheckpointTurns,
343
+ memoryCheckpointOnScopeChange: runtimeConfig.memoryCheckpointOnScopeChange,
344
+ memoryCheckpointMinTurnsForScopeFire: runtimeConfig.memoryCheckpointMinTurnsForScopeFire,
345
+ memoryInjectEnabled: runtimeConfig.memoryInjectEnabled,
346
+ memoryAutoAcceptEnabled: runtimeConfig.memoryAutoAcceptEnabled,
347
+ memoryEndOfTaskHookEnabled: runtimeConfig.memoryEndOfTaskHookEnabled,
348
+ memoryHousekeepingEnabled: runtimeConfig.memoryHousekeepingEnabled,
349
+ memoryHousekeepingTurns: runtimeConfig.memoryHousekeepingTurns,
350
+ memoryDecayDays: runtimeConfig.memoryDecayDays,
351
+ memoryInboxRetentionDays: runtimeConfig.memoryInboxRetentionDays,
352
+ memoryTieringEnabled: runtimeConfig.memoryTieringEnabled,
353
+ memoryHotRecallBoost: runtimeConfig.memoryHotRecallBoost,
354
+ memoryHotAgeDays: runtimeConfig.memoryHotAgeDays,
285
355
  copilotAuthToken: runtimeConfig.copilotAuthToken,
286
356
  get copilotModel() {
287
357
  return _copilotModel;
@@ -109,6 +109,95 @@ test("defaults chat SSE on and still honors explicit CHAPTERHOUSE_CHAT_SSE overr
109
109
  assert.equal(parsedDisabled.chatSseEnabled, false);
110
110
  assert.equal(parsedEnabled.chatSseEnabled, true);
111
111
  });
112
+ test("defaults memory checkpoint turns to 5 and parses integer overrides", async () => {
113
+ const configModule = await import("./config.js");
114
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
115
+ const parsedDefault = configModule.parseRuntimeConfig({});
116
+ const parsedThree = configModule.parseRuntimeConfig({
117
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS: "3",
118
+ });
119
+ const parsedTen = configModule.parseRuntimeConfig({
120
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS: "10",
121
+ });
122
+ assert.equal(parsedDefault.memoryCheckpointTurns, 5);
123
+ assert.equal(parsedThree.memoryCheckpointTurns, 3);
124
+ assert.equal(parsedTen.memoryCheckpointTurns, 10);
125
+ });
126
+ test("defaults memory injection on and still honors explicit CHAPTERHOUSE_MEMORY_INJECT overrides", async () => {
127
+ const configModule = await import("./config.js");
128
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
129
+ const parsedDefault = configModule.parseRuntimeConfig({});
130
+ const parsedDisabled = configModule.parseRuntimeConfig({
131
+ CHAPTERHOUSE_MEMORY_INJECT: "0",
132
+ });
133
+ const parsedEnabled = configModule.parseRuntimeConfig({
134
+ CHAPTERHOUSE_MEMORY_INJECT: "1",
135
+ });
136
+ assert.equal(parsedDefault.memoryInjectEnabled, true);
137
+ assert.equal(parsedDisabled.memoryInjectEnabled, false);
138
+ assert.equal(parsedEnabled.memoryInjectEnabled, true);
139
+ });
140
+ test("defaults memory checkpoint extraction on and still honors explicit CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED overrides", async () => {
141
+ const configModule = await import("./config.js");
142
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
143
+ const parsedDefault = configModule.parseRuntimeConfig({});
144
+ const parsedDisabled = configModule.parseRuntimeConfig({
145
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED: "0",
146
+ });
147
+ const parsedEnabled = configModule.parseRuntimeConfig({
148
+ CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED: "1",
149
+ });
150
+ assert.equal(parsedDefault.memoryCheckpointEnabled, true);
151
+ assert.equal(parsedDisabled.memoryCheckpointEnabled, false);
152
+ assert.equal(parsedEnabled.memoryCheckpointEnabled, true);
153
+ });
154
+ test("defaults end-of-task memory processing on and still honors explicit CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED overrides", async () => {
155
+ const configModule = await import("./config.js");
156
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
157
+ const parsedDefault = configModule.parseRuntimeConfig({});
158
+ const parsedDisabled = configModule.parseRuntimeConfig({
159
+ CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED: "0",
160
+ });
161
+ const parsedEnabled = configModule.parseRuntimeConfig({
162
+ CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED: "1",
163
+ });
164
+ assert.equal(parsedDefault.memoryEndOfTaskHookEnabled, true);
165
+ assert.equal(parsedDisabled.memoryEndOfTaskHookEnabled, false);
166
+ assert.equal(parsedEnabled.memoryEndOfTaskHookEnabled, true);
167
+ });
168
+ test("parses memory housekeeping config defaults and overrides", async () => {
169
+ const configModule = await import("./config.js");
170
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
171
+ const parsedDefault = configModule.parseRuntimeConfig({});
172
+ const parsedOverride = configModule.parseRuntimeConfig({
173
+ CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED: "0",
174
+ CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS: "12",
175
+ CHAPTERHOUSE_MEMORY_DECAY_DAYS: "45",
176
+ CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS: "14",
177
+ });
178
+ assert.equal(parsedDefault.memoryHousekeepingEnabled, true);
179
+ assert.equal(parsedDefault.memoryHousekeepingTurns, 50);
180
+ assert.equal(parsedDefault.memoryDecayDays, 30);
181
+ assert.equal(parsedDefault.memoryInboxRetentionDays, 7);
182
+ assert.equal(parsedOverride.memoryHousekeepingEnabled, false);
183
+ assert.equal(parsedOverride.memoryHousekeepingTurns, 12);
184
+ assert.equal(parsedOverride.memoryDecayDays, 45);
185
+ assert.equal(parsedOverride.memoryInboxRetentionDays, 14);
186
+ });
187
+ test("defaults automatic proposal acceptance on and still honors explicit CHAPTERHOUSE_MEMORY_AUTO_ACCEPT overrides", async () => {
188
+ const configModule = await import("./config.js");
189
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
190
+ const parsedDefault = configModule.parseRuntimeConfig({});
191
+ const parsedDisabled = configModule.parseRuntimeConfig({
192
+ CHAPTERHOUSE_MEMORY_AUTO_ACCEPT: "0",
193
+ });
194
+ const parsedEnabled = configModule.parseRuntimeConfig({
195
+ CHAPTERHOUSE_MEMORY_AUTO_ACCEPT: "1",
196
+ });
197
+ assert.equal(parsedDefault.memoryAutoAcceptEnabled, true);
198
+ assert.equal(parsedDisabled.memoryAutoAcceptEnabled, false);
199
+ assert.equal(parsedEnabled.memoryAutoAcceptEnabled, true);
200
+ });
112
201
  test("prefers COPILOT_TOKEN over GITHUB_TOKEN for Copilot SDK auth", async () => {
113
202
  const configModule = await import("./config.js");
114
203
  assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
@@ -159,4 +248,24 @@ test("rejects invalid rate limiting settings", async () => {
159
248
  API_RATE_LIMIT_GENERAL_MAX: "0",
160
249
  }), /API_RATE_LIMIT_GENERAL_MAX must be a positive integer/);
161
250
  });
251
+ test("parses SSE replay settings and defaults", async () => {
252
+ const configModule = await import("./config.js");
253
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
254
+ const parsedDefault = configModule.parseRuntimeConfig({});
255
+ const parsedExplicit = configModule.parseRuntimeConfig({
256
+ CHAPTERHOUSE_SSE_BUFFER_CAPACITY: "2500",
257
+ CHAPTERHOUSE_SSE_REPLAY_LIMIT: "20000",
258
+ });
259
+ assert.equal(parsedDefault.sseBufferCapacity, 2000);
260
+ assert.equal(parsedDefault.sseReplayLimit, 10000);
261
+ assert.equal(parsedExplicit.sseBufferCapacity, 2500);
262
+ assert.equal(parsedExplicit.sseReplayLimit, 20000);
263
+ });
264
+ test("rejects invalid SSE replay settings", async () => {
265
+ const configModule = await import("./config.js");
266
+ assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
267
+ assert.throws(() => configModule.parseRuntimeConfig({
268
+ CHAPTERHOUSE_SSE_BUFFER_CAPACITY: "0",
269
+ }), /CHAPTERHOUSE_SSE_BUFFER_CAPACITY must be a positive integer/);
270
+ });
162
271
  //# sourceMappingURL=config.test.js.map
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, rmSync, copyFileSync } from "fs";
2
3
  import { createHash } from "crypto";
3
4
  import { join, dirname, sep } from "path";
@@ -7,9 +8,12 @@ import { approveAll } from "@github/copilot-sdk";
7
8
  import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
8
9
  import { getState, setState } from "../store/db.js";
9
10
  import { loadMcpConfig } from "./mcp-config.js";
11
+ import { getCurrentDateSystemLine } from "./prompt-date.js";
10
12
  import { getSkillDirectories } from "./skills.js";
11
13
  import { childLogger } from "../util/logger.js";
12
14
  const log = childLogger("agents");
15
+ const toolAgentContext = new AsyncLocalStorage();
16
+ const toolTaskContext = new AsyncLocalStorage();
13
17
  // Frontmatter schema
14
18
  const agentFrontmatterSchema = z.object({
15
19
  name: z.string().min(1),
@@ -213,12 +217,18 @@ let taskCounter = 0;
213
217
  function nextTaskId() {
214
218
  return `task-${++taskCounter}-${Date.now().toString(36)}`;
215
219
  }
220
+ export function createTaskId() {
221
+ return nextTaskId();
222
+ }
216
223
  /** Shared base prompt injected into all agent sessions. */
217
224
  function getAgentBasePrompt() {
218
225
  return `## Runtime Context
219
226
 
220
227
  You are an agent within Chapterhouse, a team-level AI assistant for engineering teams. You run on the user's local machine.
221
228
 
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\`, and a named system/tool/person can be proposed as an \`entity\`.
231
+
222
232
  ### Shared Wiki
223
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.
224
234
 
@@ -239,11 +249,13 @@ Invoke \`wiki-conventions\` before wiki writes or restructuring work. Treat \`wi
239
249
  export function composeAgentSystemMessage(agent, rosterInfo) {
240
250
  const base = getAgentBasePrompt();
241
251
  const agentPrompt = agent.systemMessage;
252
+ const currentDateLine = getCurrentDateSystemLine();
253
+ const currentDateBlock = currentDateLine ? `${currentDateLine}\n\n` : "";
242
254
  // For @chapterhouse, inject the agent roster
243
255
  if (agent.slug === "chapterhouse" && rosterInfo) {
244
- return agentPrompt.replace("{agent_roster}", rosterInfo);
256
+ return `${currentDateBlock}${agentPrompt.replace("{agent_roster}", rosterInfo)}`;
245
257
  }
246
- return `${agentPrompt}\n\n${base}`;
258
+ return `${currentDateBlock}${agentPrompt}\n\n${base}`;
247
259
  }
248
260
  /** Build a roster description of all agents for @chapterhouse's system prompt. */
249
261
  export function buildAgentRoster() {
@@ -263,6 +275,7 @@ export function buildAgentRoster() {
263
275
  const WIKI_TOOL_NAMES = new Set([
264
276
  "wiki_search", "wiki_read", "wiki_update", "remember", "recall", "forget",
265
277
  "wiki_ingest", "wiki_lint", "wiki_rebuild_index",
278
+ "memory_recall", "memory_propose",
266
279
  ]);
267
280
  // Management tools that only @chapterhouse should have
268
281
  const MANAGEMENT_TOOL_NAMES = new Set([
@@ -271,7 +284,20 @@ const MANAGEMENT_TOOL_NAMES = new Set([
271
284
  "switch_model", "toggle_auto", "list_models",
272
285
  "restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
273
286
  "list_machine_sessions", "attach_machine_session",
287
+ "memory_remember", "memory_set_scope", "memory_housekeep", "memory_promote", "memory_demote",
274
288
  ]);
289
+ export function getCurrentToolAgentSlug() {
290
+ return toolAgentContext.getStore();
291
+ }
292
+ export function getCurrentToolTaskId() {
293
+ return toolTaskContext.getStore();
294
+ }
295
+ export function bindToolsToAgent(agentSlug, allTools, taskId) {
296
+ return allTools.map((tool) => ({
297
+ ...tool,
298
+ handler: (args, invocation) => toolAgentContext.run(agentSlug, () => toolTaskContext.run(taskId, () => tool.handler(args, invocation))),
299
+ }));
300
+ }
275
301
  /** Filter tools based on agent config. */
276
302
  export function filterToolsForAgent(agent, allTools) {
277
303
  if (agent.tools && agent.tools.length > 0) {
@@ -286,7 +312,7 @@ export function filterToolsForAgent(agent, allTools) {
286
312
  return allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
287
313
  }
288
314
  /** Create an ephemeral session for an agent. Always creates a fresh session — caller is responsible for destroying it. */
289
- export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix) {
315
+ export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix, taskId) {
290
316
  const agent = getAgent(slug);
291
317
  if (!agent)
292
318
  throw new Error(`Agent '${slug}' not found in registry.`);
@@ -295,7 +321,7 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
295
321
  const model = (modelOverride && modelOverride.length > 0)
296
322
  ? modelOverride
297
323
  : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model);
298
- const tools = filterToolsForAgent(agent, allTools);
324
+ const tools = bindToolsToAgent(agent.slug, filterToolsForAgent(agent, allTools), taskId);
299
325
  const mcpServers = loadMcpConfig();
300
326
  const skillDirectories = getSkillDirectories();
301
327
  const baseSystemMessage = composeAgentSystemMessage(agent);
@@ -342,9 +368,9 @@ export function getTask(taskId) {
342
368
  return activeTasks.get(taskId);
343
369
  }
344
370
  /** Register a new task. */
345
- export function registerTask(agentSlug, description, originChannel) {
371
+ export function registerTask(agentSlug, description, originChannel, taskId = nextTaskId()) {
346
372
  const task = {
347
- taskId: nextTaskId(),
373
+ taskId,
348
374
  agentSlug,
349
375
  description,
350
376
  status: "running",
@@ -10,6 +10,40 @@ function makeAgent(slug) {
10
10
  systemMessage: `You are ${slug}.`,
11
11
  };
12
12
  }
13
+ function withEnv(key, value, fn) {
14
+ const previous = process.env[key];
15
+ if (value === undefined) {
16
+ delete process.env[key];
17
+ }
18
+ else {
19
+ process.env[key] = value;
20
+ }
21
+ try {
22
+ return fn();
23
+ }
24
+ finally {
25
+ if (previous === undefined) {
26
+ delete process.env[key];
27
+ }
28
+ else {
29
+ process.env[key] = previous;
30
+ }
31
+ }
32
+ }
33
+ function currentDateLinePattern() {
34
+ const today = new Date().toISOString().slice(0, 10);
35
+ return new RegExp(`Today's date is ${today}\\. The current ISO timestamp is \\d{4}-\\d{2}-\\d{2}T[^\\s]+Z\\.`);
36
+ }
37
+ test("composeAgentSystemMessage includes the current date near the top", () => {
38
+ const message = withEnv("CHAPTERHOUSE_INJECT_DATE", undefined, () => composeAgentSystemMessage(makeAgent("coder")));
39
+ assert.match(message, currentDateLinePattern());
40
+ assert.ok(message.indexOf("Today's date is") < message.indexOf("## Runtime Context"));
41
+ });
42
+ test("composeAgentSystemMessage omits the current date when date injection is disabled", () => {
43
+ const message = withEnv("CHAPTERHOUSE_INJECT_DATE", "0", () => composeAgentSystemMessage(makeAgent("coder")));
44
+ assert.doesNotMatch(message, /Today's date is \d{4}-\d{2}-\d{2}\./);
45
+ assert.doesNotMatch(message, /The current ISO timestamp is \d{4}-\d{2}-\d{2}T/);
46
+ });
13
47
  test("composeAgentSystemMessage steers wiki-capable agents to wiki-conventions", () => {
14
48
  for (const slug of ["coder", "general-purpose"]) {
15
49
  const message = composeAgentSystemMessage(makeAgent(slug));
@@ -19,4 +53,11 @@ test("composeAgentSystemMessage steers wiki-capable agents to wiki-conventions",
19
53
  assert.match(message, /scan the last 20-30 entries of `pages\/_meta\/log\.md`/i);
20
54
  }
21
55
  });
56
+ test("composeAgentSystemMessage teaches subagents the three-tier memory model and directs them to memory_propose", () => {
57
+ const message = composeAgentSystemMessage(makeAgent("coder"));
58
+ assert.match(message, /three-tier memory model|read, propose, write/i);
59
+ assert.match(message, /memory_recall/i);
60
+ assert.match(message, /memory_propose/i);
61
+ assert.match(message, /do not call `memory_remember` directly|should not call `memory_remember` directly/i);
62
+ });
22
63
  //# sourceMappingURL=agents.test.js.map
@@ -0,0 +1,54 @@
1
+ import { approveAll } from "@github/copilot-sdk";
2
+ import { config } from "../config.js";
3
+ import { SESSIONS_DIR } from "../paths.js";
4
+ import { childLogger } from "../util/logger.js";
5
+ const log = childLogger("copilot.oneshot");
6
+ const DEFAULT_ONE_SHOT_TIMEOUT_MS = 60_000;
7
+ export async function runOneShotPrompt(input) {
8
+ const model = input.model ?? config.copilotModel;
9
+ const timeoutMs = input.timeoutMs ?? DEFAULT_ONE_SHOT_TIMEOUT_MS;
10
+ const maxAttempts = input.expectJson ? 2 : 1;
11
+ let prompt = input.user;
12
+ let lastContent = "";
13
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
14
+ const session = await input.client.createSession({
15
+ model,
16
+ configDir: SESSIONS_DIR,
17
+ streaming: false,
18
+ systemMessage: { content: input.system },
19
+ onPermissionRequest: approveAll,
20
+ });
21
+ try {
22
+ const response = await session.sendAndWait({ prompt }, timeoutMs);
23
+ lastContent = response?.data?.content?.trim() ?? "";
24
+ if (!input.expectJson) {
25
+ return { content: lastContent, model, attempts: attempt };
26
+ }
27
+ try {
28
+ JSON.parse(lastContent);
29
+ return { content: lastContent, model, attempts: attempt };
30
+ }
31
+ catch (error) {
32
+ if (attempt >= maxAttempts) {
33
+ throw error;
34
+ }
35
+ prompt = [
36
+ input.user,
37
+ "",
38
+ "Your previous reply was not valid JSON.",
39
+ "Return only valid JSON matching the requested schema. Do not wrap it in markdown fences.",
40
+ ].join("\n");
41
+ }
42
+ }
43
+ finally {
44
+ try {
45
+ await session.disconnect();
46
+ }
47
+ catch (error) {
48
+ log.warn({ err: error instanceof Error ? error.message : error, model }, "one-shot disconnect failed");
49
+ }
50
+ }
51
+ }
52
+ return { content: lastContent, model, attempts: maxAttempts };
53
+ }
54
+ //# sourceMappingURL=oneshot.js.map