chapterhouse 0.9.2 → 0.11.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.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/dist/api/auth.js +11 -1
  3. package/dist/api/auth.test.js +29 -0
  4. package/dist/api/errors.js +23 -0
  5. package/dist/api/route-coverage.test.js +61 -21
  6. package/dist/api/routes/agents.js +472 -0
  7. package/dist/api/routes/memory.js +299 -0
  8. package/dist/api/routes/projects.js +170 -0
  9. package/dist/api/routes/sessions.js +347 -0
  10. package/dist/api/routes/system.js +82 -0
  11. package/dist/api/routes/wiki.js +455 -0
  12. package/dist/api/routes/wiki.test.js +49 -0
  13. package/dist/api/send-json.js +16 -0
  14. package/dist/api/send-json.test.js +18 -0
  15. package/dist/api/server-runtime.js +45 -3
  16. package/dist/api/server.js +34 -1764
  17. package/dist/api/server.test.js +239 -8
  18. package/dist/api/sse-hub.js +37 -0
  19. package/dist/cli.js +1 -1
  20. package/dist/config.js +151 -58
  21. package/dist/config.test.js +29 -0
  22. package/dist/copilot/okr-mapper.js +2 -11
  23. package/dist/copilot/orchestrator.js +358 -352
  24. package/dist/copilot/orchestrator.test.js +139 -4
  25. package/dist/copilot/prompt-date.js +2 -1
  26. package/dist/copilot/session-manager.js +25 -23
  27. package/dist/copilot/session-manager.test.js +35 -1
  28. package/dist/copilot/standup.js +2 -2
  29. package/dist/copilot/task-event-log.js +7 -1
  30. package/dist/copilot/task-event-log.test.js +13 -0
  31. package/dist/copilot/tools/agent.js +608 -0
  32. package/dist/copilot/tools/index.js +19 -0
  33. package/dist/copilot/tools/memory.js +678 -0
  34. package/dist/copilot/tools/models.js +2 -0
  35. package/dist/copilot/tools/okr.js +171 -0
  36. package/dist/copilot/tools/wiki.js +333 -0
  37. package/dist/copilot/tools-deps.js +4 -0
  38. package/dist/copilot/tools.agent.test.js +10 -8
  39. package/dist/copilot/tools.inventory.test.js +76 -0
  40. package/dist/copilot/tools.js +1 -1780
  41. package/dist/copilot/tools.okr.test.js +31 -0
  42. package/dist/copilot/tools.wiki.test.js +6 -3
  43. package/dist/copilot/turn-event-log.js +31 -4
  44. package/dist/copilot/turn-event-log.test.js +24 -2
  45. package/dist/copilot/workiq-installer.test.js +2 -2
  46. package/dist/daemon-install.js +3 -2
  47. package/dist/daemon.js +9 -17
  48. package/dist/integrations/ado-client.js +90 -9
  49. package/dist/integrations/ado-client.test.js +56 -0
  50. package/dist/integrations/team-push.js +1 -0
  51. package/dist/integrations/team-push.test.js +6 -0
  52. package/dist/integrations/teams-notify.js +1 -0
  53. package/dist/integrations/teams-notify.test.js +5 -0
  54. package/dist/memory/active-scope.test.js +0 -1
  55. package/dist/memory/checkpoint.js +89 -72
  56. package/dist/memory/checkpoint.test.js +23 -3
  57. package/dist/memory/eot.js +87 -85
  58. package/dist/memory/eot.test.js +71 -3
  59. package/dist/memory/hooks.js +2 -4
  60. package/dist/memory/housekeeping-scheduler.js +1 -1
  61. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  62. package/dist/memory/housekeeping.js +100 -3
  63. package/dist/memory/housekeeping.test.js +33 -2
  64. package/dist/memory/reflect.test.js +2 -0
  65. package/dist/memory/scope-lock.js +26 -0
  66. package/dist/memory/scope-lock.test.js +118 -0
  67. package/dist/memory/scopes.test.js +0 -1
  68. package/dist/mode-context.js +58 -5
  69. package/dist/mode-context.test.js +68 -0
  70. package/dist/paths.js +1 -0
  71. package/dist/setup.js +3 -2
  72. package/dist/shared/api-schemas.js +48 -5
  73. package/dist/store/connection.js +96 -0
  74. package/dist/store/db.js +5 -1498
  75. package/dist/store/db.test.js +182 -1
  76. package/dist/store/migrations.js +460 -0
  77. package/dist/store/repositories/memory.js +281 -0
  78. package/dist/store/repositories/okr.js +3 -0
  79. package/dist/store/repositories/projects.js +5 -0
  80. package/dist/store/repositories/sessions.js +284 -0
  81. package/dist/store/repositories/wiki.js +60 -0
  82. package/dist/store/schema.js +501 -0
  83. package/dist/util/logger.js +3 -2
  84. package/dist/wiki/consolidation.js +50 -9
  85. package/dist/wiki/consolidation.test.js +45 -0
  86. package/dist/wiki/frontmatter.js +43 -13
  87. package/dist/wiki/frontmatter.test.js +24 -0
  88. package/dist/wiki/fs.js +16 -4
  89. package/dist/wiki/fs.test.js +84 -0
  90. package/dist/wiki/index-manager.js +30 -2
  91. package/dist/wiki/index-manager.test.js +43 -12
  92. package/dist/wiki/ingest.js +1 -1
  93. package/dist/wiki/lock.js +11 -1
  94. package/dist/wiki/log-manager.js +2 -7
  95. package/dist/wiki/migrate.js +44 -17
  96. package/dist/wiki/project-registry.js +10 -5
  97. package/dist/wiki/project-registry.test.js +14 -0
  98. package/dist/wiki/scheduler.js +1 -1
  99. package/dist/wiki/seed-team-wiki.js +2 -1
  100. package/dist/wiki/team-sync.js +31 -6
  101. package/dist/wiki/team-sync.test.js +81 -0
  102. package/package.json +1 -1
  103. package/web/dist/assets/WikiEdit-EBVoY1Pk.js +30 -0
  104. package/web/dist/assets/WikiEdit-EBVoY1Pk.js.map +1 -0
  105. package/web/dist/assets/WikiGraph-BUbbABq-.js +2 -0
  106. package/web/dist/assets/WikiGraph-BUbbABq-.js.map +1 -0
  107. package/web/dist/assets/icon-acolyte-cream.svg +10 -0
  108. package/web/dist/assets/icon-acolyte-dark.svg +10 -0
  109. package/web/dist/assets/icon-acolyte-gold.svg +10 -0
  110. package/web/dist/assets/icon-acolyte-ibad.svg +10 -0
  111. package/web/dist/assets/icon-acolyte-lit.svg +10 -0
  112. package/web/dist/assets/icon-acolyte-mono.svg +10 -0
  113. package/web/dist/assets/icon-acolyte.png +0 -0
  114. package/web/dist/assets/icon-acolyte.svg +10 -0
  115. package/web/dist/assets/index-BGLL9pgM.css +10 -0
  116. package/web/dist/assets/index-KFX8UmOb.js +250 -0
  117. package/web/dist/assets/index-KFX8UmOb.js.map +1 -0
  118. package/web/dist/index.html +6 -4
  119. package/web/dist/assets/index-5kz9aRU9.css +0 -10
  120. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  121. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -1,363 +1,48 @@
1
1
  import cors from "cors";
2
2
  import express from "express";
3
3
  import helmet from "helmet";
4
- import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
5
- import { join, dirname, resolve, sep } from "path";
4
+ import { existsSync } from "fs";
5
+ import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
- import { z } from "zod";
8
- import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey, getPersistentAgentSessionState, reloadPersistentAgent } from "../copilot/orchestrator.js";
9
- import { agentEventBus } from "../copilot/agent-event-bus.js";
10
- import { ensureDefaultAgents, getAgent, getAgentRegistry, isBuiltinAgent, loadAgents, notifyAgentSaved, parseAgentMd, parseAgentMdOrThrow, serializeAgentMd, setAgentSaveRuntimeHooks, SLUG_REGEX, } from "../copilot/agents.js";
11
- import { config, persistModel } from "../config.js";
7
+ import { getPersistentAgentSessionState, reloadPersistentAgent } from "../copilot/orchestrator.js";
8
+ import { setAgentSaveRuntimeHooks } from "../copilot/agents.js";
9
+ import { config } from "../config.js";
12
10
  import { ModeContext } from "../mode-context.js";
13
- import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
14
- import { searchIndex, parseIndex } from "../wiki/index-manager.js";
15
- import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
16
- import { assertAgentEditAccess } from "./agent-edit-access.js";
11
+ import { createAuthMiddleware } from "./auth.js";
17
12
  import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
18
13
  import { createTeamRouter } from "./team.js";
19
- import { readPage, writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, getWikiDir, } from "../wiki/fs.js";
20
- import { parseWikiFrontmatter } from "../wiki/frontmatter.js";
21
- import { normalizeWikiPath } from "../wiki/path-utils.js";
22
- import { loadRegistry, saveRegistry } from "../wiki/project-registry.js";
23
- import { getProjectRulesPath, listTopLevelSoftRules, loadProjectRules, loadProjectRuleSummary, renderInitialProjectRulesPage, saveProjectRulesHardFields, saveProjectRulesSoftRules, } from "../wiki/project-rules.js";
24
- import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
25
- import { withWikiWrite } from "../wiki/lock.js";
26
- import { listSkills, removeSkill } from "../copilot/skills.js";
27
- import { restartDaemon } from "../daemon.js";
28
- import { AGENTS_DIR, API_TOKEN_PATH } from "../paths.js";
29
- import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
30
- import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
31
- import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
32
- import { getStatus, onStatusChange } from "../status.js";
33
- import { formatSseData } from "./sse.js";
34
- import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
35
- import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
14
+ import { apiNotFoundHandler, createApiErrorHandler } from "./errors.js";
36
15
  import { childLogger } from "../util/logger.js";
37
- import { getActiveScope, setActiveScope } from "../memory/active-scope.js";
38
- import { createScope, getScope, listScopes } from "../memory/scopes.js";
39
- import { handleGitCommitHook, handlePrMergeHook } from "../memory/hooks.js";
40
- import { recordObservation } from "../memory/observations.js";
41
- import { recordDecision } from "../memory/decisions.js";
42
- import { upsertEntity } from "../memory/entities.js";
43
- import { getInboxItem, listPendingInboxItems, resolveInboxItem } from "../memory/inbox.js";
44
- import { ingestSource } from "../wiki/ingest.js";
45
- import { listKorgResearchSessions } from "./korg.js";
16
+ import { assertAuthenticationConfigured, getDisplayHost, shouldServeSpaPath } from "./server-runtime.js";
17
+ import { createAgentsRouter } from "./routes/agents.js";
18
+ import { createMemoryRouter } from "./routes/memory.js";
19
+ import { createProjectsRouter } from "./routes/projects.js";
20
+ import { createSessionsRouter } from "./routes/sessions.js";
21
+ import { createSystemRouter } from "./routes/system.js";
22
+ import { createWikiRouter } from "./routes/wiki.js";
23
+ import { broadcastSsePayload, broadcastSsePayloadToSession, broadcastToSSE } from "./sse-hub.js";
46
24
  const log = childLogger("server");
47
25
  const modeContext = new ModeContext(config);
48
- void searchIndex; // re-exported by index-manager; reference here documents the dep
49
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
50
- // Built SPA bundle (web/dist/), shipped alongside dist/
51
27
  const WEB_DIST_DIR = join(__dirname, "..", "..", "web", "dist");
52
28
  const WEB_INDEX_HTML = join(WEB_DIST_DIR, "index.html");
53
- const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
54
- const messageRequestSchema = z.object({
55
- prompt: requiredString("Missing 'prompt' in request body"),
56
- connectionId: requiredString("Missing or invalid 'connectionId'. Connect to /stream first."),
57
- projectPath: z.string().optional(),
58
- sessionKey: z.string().optional(),
59
- /** Optional client-generated correlation ID. Echoed back in the `queued` SSE event
60
- * so the frontend can match the event to the user message bubble it should update. */
61
- msgId: z.string().optional(),
62
- });
63
- const interruptRequestSchema = z.object({
64
- prompt: requiredString("Missing 'prompt' in request body"),
65
- connectionId: requiredString("Missing or invalid 'connectionId'. Connect to /stream first."),
66
- attachments: z.array(z.object({
67
- type: z.literal("file"),
68
- path: z.string(),
69
- displayName: z.string().optional(),
70
- })).optional(),
71
- });
72
- const modelRequestSchema = z.object({
73
- model: requiredString("Missing 'model' in request body"),
74
- }).strict();
75
- const autoRequestSchema = z.object({
76
- enabled: z.boolean().optional(),
77
- tierModels: z.object({
78
- fast: requiredString("tierModels.fast must be a non-empty string").optional(),
79
- standard: requiredString("tierModels.standard must be a non-empty string").optional(),
80
- premium: requiredString("tierModels.premium must be a non-empty string").optional(),
81
- }).strict().optional(),
82
- cooldownMessages: z.number({ error: "cooldownMessages must be a number" })
83
- .int("cooldownMessages must be an integer")
84
- .min(0, "cooldownMessages must be a non-negative integer")
85
- .optional(),
86
- }).strict();
87
- const wikiWriteSchema = z.object({
88
- content: z.string({ error: "Missing 'content' string in request body" }),
89
- }).strict();
90
- const agentPatchSchema = z.object({
91
- name: requiredString("name must be a non-empty string").optional(),
92
- description: requiredString("description must be a non-empty string").optional(),
93
- model: requiredString("model must be a non-empty string").optional(),
94
- systemPrompt: z.string().optional(),
95
- }).passthrough().superRefine((value, ctx) => {
96
- for (const key of ["slug", "scope", "tools", "skills"]) {
97
- if (Object.prototype.hasOwnProperty.call(value, key)) {
98
- ctx.addIssue({
99
- code: "custom",
100
- path: [key],
101
- message: `'${key}' is read-only`,
102
- });
103
- }
104
- }
105
- });
106
- const projectCreateSchema = z.object({
107
- slug: requiredString("Missing 'slug' in request body")
108
- .regex(/^[a-z0-9][a-z0-9-]*$/, "Project slug must be a lowercase slug"),
109
- cwd: requiredString("Missing 'cwd' in request body")
110
- .refine((value) => value.startsWith("/"), "Project cwd must be an absolute path"),
111
- }).strict();
112
- const scopeCreateSchema = z.object({
113
- slug: requiredString("Missing 'slug' in request body")
114
- .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Scope slug must be unique kebab-case"),
115
- title: requiredString("Missing 'title' in request body"),
116
- description: z.string().optional(),
117
- }).strict();
118
- const setActiveScopeSchema = z.object({
119
- scope: z.string().nullable(),
120
- });
121
- const memoryEntriesQuerySchema = z.object({
122
- store: z.enum(["observations", "decisions", "entities", "action_items"]).optional(),
123
- tier: z.enum(["hot", "warm", "cold"]).optional(),
124
- });
125
- const memoryRememberSchema = z.object({
126
- content: requiredString("Missing 'content' in request body"),
127
- kind: z.enum(["observation", "decision"]).optional(),
128
- entity_name: z.string().optional(),
129
- entity_kind: z.string().optional(),
130
- title: z.string().optional(),
131
- decided_at: z.string().optional(),
132
- tier: z.enum(["hot", "warm", "cold"]).optional(),
133
- });
134
- const inboxRouteSchema = z.object({
135
- action: z.enum(["accept", "reject", "route"]),
136
- reason: z.string().optional(),
137
- target_scope: z.string().optional(),
138
- });
139
- const gitCommitHookSchema = z.object({
140
- message: requiredString("Missing 'message' in request body"),
141
- stat: z.string().optional(),
142
- });
143
- const prMergeHookSchema = z.object({
144
- number: z.number({ error: "Missing or invalid 'number' in request body" }).int().positive(),
145
- title: requiredString("Missing 'title' in request body"),
146
- body: z.string().optional(),
147
- files_changed: z.array(z.string()).optional(),
148
- });
149
- const projectHardRulesSchema = z.object({
150
- hardRules: z.object({
151
- auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
152
- require_worktree: z.boolean({ error: "hardRules.require_worktree must be a boolean" }),
153
- pr_draft_default: z.boolean({ error: "hardRules.pr_draft_default must be a boolean" }),
154
- default_branch: requiredString("hardRules.default_branch must be a non-empty string"),
155
- commit_co_author: requiredString("hardRules.commit_co_author must be a non-empty string"),
156
- test_command: z.string({ error: "hardRules.test_command must be a string" }),
157
- build_command: z.string({ error: "hardRules.build_command must be a string" }),
158
- lint_command: z.string({ error: "hardRules.lint_command must be a string" }),
159
- require_clean_worktree: z.boolean({ error: "hardRules.require_clean_worktree must be a boolean" }),
160
- }).strict(),
161
- }).strict();
162
- const projectSoftRulesSchema = z.object({
163
- softRules: z.array(requiredString("softRules entries must be non-empty strings")),
164
- }).strict();
165
- function createWikiPagePayload(path, content) {
166
- const { parsed: frontmatter, body: renderedContent } = parseWikiFrontmatter(content);
167
- return {
168
- path,
169
- content,
170
- renderedContent,
171
- frontmatter,
172
- };
173
- }
174
- function createProjectDetailPayload(slug, cwd) {
175
- const rules = loadProjectRules(slug);
176
- if (!rules.found) {
177
- return {
178
- slug,
179
- cwd,
180
- rulesFound: false,
181
- hardRules: null,
182
- softRules: [],
183
- };
184
- }
185
- return {
186
- slug,
187
- cwd,
188
- rulesFound: true,
189
- hardRules: rules.hard,
190
- softRules: listTopLevelSoftRules(rules.soft),
191
- };
192
- }
193
- function coerceWikiPageUpdated(path, updated) {
194
- const normalized = updated?.trim();
195
- if (normalized) {
196
- return normalized;
197
- }
198
- try {
199
- return statSync(join(getWikiDir(), path)).mtime.toISOString();
200
- }
201
- catch {
202
- return "unknown";
203
- }
204
- }
205
- function wikiPathToBrowserSlug(path) {
206
- return path.replace(/^pages\//, "").replace(/\.md$/, "");
207
- }
208
- function entityTypeFromPath(path) {
209
- const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
210
- const segs = rest.split("/").filter(Boolean);
211
- if (segs.length <= 1)
212
- return (segs[0] || "pages").replace(/\.md$/i, "");
213
- return segs[0];
214
- }
215
- function normalizeOptionalQueryParam(value) {
216
- if (typeof value !== "string") {
217
- return undefined;
218
- }
219
- const trimmed = value.trim();
220
- return trimmed ? trimmed : undefined;
221
- }
222
- function matchesWikiBrowserFilters(page, filters) {
223
- if (filters.type && page.type !== filters.type) {
224
- return false;
225
- }
226
- if (!filters.q) {
227
- return true;
228
- }
229
- const query = filters.q.toLowerCase();
230
- return page.title.toLowerCase().includes(query) || page.summary.toLowerCase().includes(query);
231
- }
232
- function mapIndexEntryToBrowserPage(entry) {
233
- return {
234
- slug: wikiPathToBrowserSlug(entry.path),
235
- title: entry.title,
236
- summary: entry.summary,
237
- type: entry.section,
238
- last_updated: coerceWikiPageUpdated(entry.path, entry.updated),
239
- };
240
- }
241
- function listFallbackWikiBrowserPages(filters) {
242
- const entries = parseIndex();
243
- const indexed = new Set(entries.map((entry) => entry.path));
244
- const indexedResults = entries.map(mapIndexEntryToBrowserPage);
245
- const orphanResults = listPages()
246
- .filter((path) => !indexed.has(path))
247
- .map((path) => ({
248
- slug: wikiPathToBrowserSlug(path),
249
- title: path,
250
- summary: "",
251
- type: "Unindexed",
252
- last_updated: coerceWikiPageUpdated(path, undefined),
253
- }));
254
- return [...indexedResults, ...orphanResults].filter((page) => matchesWikiBrowserFilters(page, filters));
255
- }
256
- function listDbWikiBrowserPages(filters) {
257
- const db = getDb();
258
- const clauses = [];
259
- const params = [];
260
- if (filters.type) {
261
- clauses.push("(entity_type = ? OR (entity_type IS NULL AND path LIKE ?))");
262
- params.push(filters.type, `pages/${filters.type}/%`);
263
- }
264
- if (filters.q) {
265
- clauses.push("(title LIKE ? COLLATE NOCASE OR COALESCE(summary, '') LIKE ? COLLATE NOCASE)");
266
- params.push(`%${filters.q}%`, `%${filters.q}%`);
267
- }
268
- const rows = db.prepare(`
269
- SELECT path, title, summary, entity_type, last_updated, pinned
270
- FROM wiki_pages
271
- ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
272
- ORDER BY COALESCE(last_updated, '') DESC, title ASC
273
- `).all(...params);
274
- return rows.map((row) => ({
275
- slug: wikiPathToBrowserSlug(row.path),
276
- title: row.title,
277
- summary: row.summary ?? "",
278
- type: row.entity_type ?? entityTypeFromPath(row.path),
279
- last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
280
- ...(row.pinned ? { pinned: true } : {}),
281
- }));
282
- }
283
- function parseWikiSourcePages(value) {
284
- if (!value) {
285
- return [];
286
- }
287
- try {
288
- const parsed = JSON.parse(value);
289
- return Array.isArray(parsed)
290
- ? parsed.filter((entry) => typeof entry === "string").map((entry) => normalizeWikiPath(entry))
291
- : [];
292
- }
293
- catch {
294
- return [];
295
- }
296
- }
297
- function listWikiSources(page) {
298
- const rows = getDb().prepare(`
299
- SELECT source_type, origin, raw_path, pages_updated, status, session_id, ingested_at
300
- FROM wiki_sources
301
- ORDER BY ingested_at DESC, id ASC
302
- `).all();
303
- return rows
304
- .filter((row) => !page || parseWikiSourcePages(row.pages_updated).includes(page))
305
- .map((row) => ({
306
- source_url: row.origin,
307
- path: row.raw_path ?? undefined,
308
- kind: row.source_type,
309
- status: row.status,
310
- session_id: row.session_id,
311
- ingested_at: row.ingested_at,
312
- }));
313
- }
314
- function wikiPathToSlug(path) {
315
- const segments = normalizeWikiPath(path).split("/").filter(Boolean);
316
- const file = segments[segments.length - 1] ?? path;
317
- const base = file.endsWith(".md") ? file.slice(0, -3) : file;
318
- if (base === "index" && segments.length >= 2) {
319
- return segments[segments.length - 2] ?? base;
320
- }
321
- return base;
322
- }
323
- function listWikiLinks(page) {
324
- const rows = page
325
- ? getDb().prepare(`
326
- SELECT from_page, to_page, link_type
327
- FROM wiki_links
328
- WHERE from_page = ? OR to_page = ?
329
- ORDER BY from_page ASC, to_page ASC, link_type ASC
330
- `).all(page, page)
331
- : getDb().prepare(`
332
- SELECT from_page, to_page, link_type
333
- FROM wiki_links
334
- ORDER BY from_page ASC, to_page ASC, link_type ASC
335
- `).all();
336
- return rows.map((row) => ({
337
- source_slug: wikiPathToSlug(row.from_page),
338
- target_slug: wikiPathToSlug(row.to_page),
339
- link_type: row.link_type,
340
- ...(page ? { direction: row.from_page === page ? "outgoing" : "incoming" } : {}),
341
- }));
342
- }
343
- // Load a configured API token when present; startup validation below enforces auth.
344
29
  let apiToken = null;
345
30
  try {
346
- apiToken = resolveApiToken({
347
- envToken: process.env.API_TOKEN,
348
- tokenPath: API_TOKEN_PATH,
349
- });
31
+ apiToken = config.apiToken;
350
32
  assertAuthenticationConfigured({
351
33
  entraAuthEnabled: config.entraAuthEnabled,
352
34
  apiToken,
35
+ apiHost: config.apiHost,
353
36
  });
354
37
  }
355
38
  catch (err) {
356
39
  log.error({ err: err instanceof Error ? err.message : String(err) }, "Auth token error");
357
40
  process.exit(1);
358
41
  }
359
- if (modeContext.isPersonal() && config.standaloneMode) {
360
- log.warn("Running without authentication team features disabled");
42
+ if (config.standaloneMode) {
43
+ log.warn({ host: config.apiHost }, modeContext.isPersonal()
44
+ ? "Running without authentication on loopback — team features disabled"
45
+ : "Running without authentication on loopback — set API_TOKEN or configure Entra auth before exposing this service");
361
46
  }
362
47
  function isLoopbackHostname(hostname) {
363
48
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
@@ -395,7 +80,7 @@ app.use(cors({
395
80
  maxAge: 600,
396
81
  optionsSuccessStatus: 204,
397
82
  }));
398
- app.use(express.json({ limit: "2mb" }));
83
+ app.use(express.json({ limit: config.jsonBodyLimit }));
399
84
  function sendRateLimitResponse(res, retryAfterSeconds, message) {
400
85
  res.setHeader("Retry-After", String(retryAfterSeconds));
401
86
  res.status(429).json({ error: `${message} Retry after ${retryAfterSeconds} seconds.` });
@@ -437,1433 +122,27 @@ app.use("/api", apiRateLimit);
437
122
  app.use("/api/bootstrap", authRateLimit);
438
123
  app.use("/stream", authRateLimit, sseConcurrentLimit);
439
124
  app.use(authMiddleware);
440
- // Loopback-only origin gate for the bootstrap endpoint that hands the token to the SPA.
441
- function isLoopbackOrigin(req) {
442
- const origin = req.headers.origin || req.headers.referer;
443
- if (!origin) {
444
- // Same-origin fetches from the served SPA omit Origin; permit when the request comes
445
- // from the loopback socket itself.
446
- const remote = req.socket.remoteAddress || "";
447
- return remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
448
- }
449
- try {
450
- return isLoopbackHostname(new URL(origin).hostname);
451
- }
452
- catch {
453
- return false;
454
- }
455
- }
456
- function readPathParam(req) {
457
- const raw = req.query.path;
458
- if (typeof raw !== "string" || !raw) {
459
- throw new BadRequestError("Missing 'path' query param");
460
- }
461
- return raw;
462
- }
463
- function readOptionalPageFilter(req) {
464
- const raw = req.query.page;
465
- if (typeof raw !== "string" || !raw.trim()) {
466
- return undefined;
467
- }
468
- return normalizeWikiPath(raw.trim());
469
- }
470
- function assertValidPagePath(path) {
471
- try {
472
- assertPagePath(path);
473
- return path;
474
- }
475
- catch (error) {
476
- asBadRequest(error);
477
- }
478
- }
479
- function getWikiPageScope(path) {
480
- return modeContext.canSyncTeamWiki() && teamWikiSync.isTeamPath(path) ? "team" : "personal";
481
- }
482
- function getEmptyWikiWelcomeContent(today = new Date()) {
483
- return `---
484
- title: Wiki
485
- summary: Empty wiki — get started.
486
- updated: ${today.toISOString().slice(0, 10)}
487
- ---
488
-
489
- # Wiki
490
-
491
- Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
492
-
493
- Create your first page via the wiki UI or by editing files under \`pages/\`.
494
- `;
495
- }
496
- // Active SSE connections
497
- const sseClients = new Map();
498
- const pendingSseMessages = [];
499
- let connectionCounter = 0;
500
- function broadcastSsePayload(payload) {
501
- for (const [, res] of sseClients) {
502
- res.write(formatSseData(payload));
503
- }
504
- }
505
125
  setAgentSaveRuntimeHooks({
506
126
  getPersistentSessionState: getPersistentAgentSessionState,
507
127
  reloadPersistentSession: reloadPersistentAgent,
508
128
  emitAgentReloadEvent: (event) => {
509
- broadcastSsePayload(event);
510
- },
511
- });
512
- // ---------------------------------------------------------------------------
513
- // Bootstrap — hands the API token to the same-origin SPA on first load.
514
- // Loopback-only by IP / Origin check.
515
- // ---------------------------------------------------------------------------
516
- app.get("/api/bootstrap", (req, res) => {
517
- if (!isLoopbackOrigin(req)) {
518
- throw new ForbiddenError("Bootstrap is loopback-only");
519
- }
520
- res.json(getBootstrapAuthResponse(apiToken, {
521
- entraAuthEnabled: config.entraAuthEnabled,
522
- standaloneMode: config.standaloneMode,
523
- entraTenantId: config.entraTenantId,
524
- entraClientId: config.entraClientId,
525
- entraRequiredRole: config.entraRequiredRole,
526
- entraTeamLeadId: config.entraTeamLeadId,
527
- }));
528
- });
529
- app.get("/api/config/public", (_req, res) => {
530
- res.json(createPublicConfigPayload({
531
- entraAuthEnabled: config.entraAuthEnabled,
532
- standaloneMode: config.standaloneMode,
533
- entraClientId: config.entraClientId,
534
- entraTenantId: config.entraTenantId,
535
- chatSseEnabled: config.chatSseEnabled,
536
- }));
537
- });
538
- // Health check — intentionally unauthenticated, returns no sensitive data
539
- const handleHealth = (_req, res) => {
540
- res.json(createHealthPayload());
541
- };
542
- app.get("/status", handleHealth);
543
- app.get("/health", handleHealth);
544
- function getLoadedAgents() {
545
- let agents = getAgentRegistry();
546
- if (agents.length === 0) {
547
- ensureDefaultAgents();
548
- agents = loadAgents();
549
- }
550
- return agents;
551
- }
552
- function getAgentLastEdited(slug) {
553
- try {
554
- return statSync(join(AGENTS_DIR, `${slug}.agent.md`)).mtime.toISOString();
555
- }
556
- catch {
557
- return null;
558
- }
559
- }
560
- // ---------------------------------------------------------------------------
561
- // Workers / agents
562
- // ---------------------------------------------------------------------------
563
- app.get("/api/agents", (_req, res) => {
564
- const agents = getLoadedAgents()
565
- .map((agent) => ({
566
- name: agent.name,
567
- slug: agent.slug,
568
- description: agent.description,
569
- model: agent.model,
570
- scope: agent.scope ?? null,
571
- type: isBuiltinAgent(agent.slug) ? "builtin" : "custom",
572
- lastEdited: getAgentLastEdited(agent.slug),
573
- }))
574
- .sort((left, right) => {
575
- if (left.type !== right.type) {
576
- return left.type === "builtin" ? -1 : 1;
577
- }
578
- return left.name.localeCompare(right.name);
579
- });
580
- res.json(agents);
581
- });
582
- app.get("/api/agents/:slug", (req, res, next) => {
583
- const slugParam = req.params.slug;
584
- const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
585
- if (slug === "stream") {
586
- next();
587
- return;
588
- }
589
- if (!SLUG_REGEX.test(slug)) {
590
- res.status(400).json({ error: "Invalid slug" });
591
- return;
592
- }
593
- getLoadedAgents();
594
- const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
595
- const resolvedAgentsDir = resolve(AGENTS_DIR);
596
- const resolvedFilePath = resolve(filePath);
597
- if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
598
- res.status(403).json({ error: "Access denied" });
599
- return;
600
- }
601
- let content;
602
- try {
603
- content = readFileSync(filePath, "utf-8");
604
- }
605
- catch {
606
- res.status(404).json({ error: `Agent '${slug}' not found` });
607
- return;
608
- }
609
- const agent = parseAgentMd(content, slug);
610
- if (!agent) {
611
- res.status(500).json({ error: `Agent '${slug}' could not be parsed` });
612
- return;
613
- }
614
- const builtin = isBuiltinAgent(slug);
615
- res.json({
616
- name: agent.name,
617
- slug: agent.slug,
618
- description: agent.description,
619
- model: agent.model,
620
- scope: agent.scope ?? null,
621
- persistent: agent.persistent ?? false,
622
- skills: agent.skills ?? [],
623
- type: builtin ? "builtin" : "custom",
624
- editable: !builtin,
625
- systemPrompt: agent.systemMessage,
626
- });
627
- });
628
- app.patch("/api/agents/:slug", async (req, res) => {
629
- const slugParam = req.params.slug;
630
- const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
631
- if (!SLUG_REGEX.test(slug)) {
632
- throw new BadRequestError("Invalid slug");
633
- }
634
- if (modeContext.isTeam() && req.user?.role !== "team-lead") {
635
- throw new ForbiddenError("Forbidden");
636
- }
637
- const patch = parseRequest(agentPatchSchema, req.body ?? {});
638
- const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
639
- const resolvedAgentsDir = resolve(AGENTS_DIR);
640
- const resolvedFilePath = resolve(filePath);
641
- if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
642
- throw new ForbiddenError("Access denied");
643
- }
644
- assertAgentEditAccess({ entraAuthEnabled: config.entraAuthEnabled }, req.user);
645
- if (isBuiltinAgent(slug)) {
646
- throw new ForbiddenError("Built-in agents are read-only");
647
- }
648
- if (!existsSync(filePath)) {
649
- throw new NotFoundError("Agent not found");
650
- }
651
- try {
652
- const current = parseAgentMdOrThrow(readFileSync(filePath, "utf-8"), slug);
653
- const updated = {
654
- ...current,
655
- name: patch.name ?? current.name,
656
- description: patch.description ?? current.description,
657
- model: patch.model ?? current.model,
658
- systemMessage: patch.systemPrompt ?? current.systemMessage,
659
- };
660
- const nextContent = serializeAgentMd(updated);
661
- writeFileSync(filePath, nextContent, "utf-8");
662
- await notifyAgentSaved(slug, updated);
663
- res.json({
664
- name: updated.name,
665
- slug: updated.slug,
666
- description: updated.description,
667
- model: updated.model,
668
- scope: updated.scope ?? null,
669
- persistent: updated.persistent ?? false,
670
- skills: updated.skills ?? [],
671
- type: "custom",
672
- editable: true,
673
- systemPrompt: updated.systemMessage,
674
- });
675
- }
676
- catch (error) {
677
- const message = error instanceof Error ? error.message : String(error);
678
- throw new BadRequestError(`Invalid content: ${message}`);
679
- }
680
- });
681
- app.get("/api/channels", (_req, res) => {
682
- const agents = getLoadedAgents();
683
- const persistentAgentChannels = agents
684
- .filter((agent) => agent.persistent)
685
- .map((agent) => ({
686
- key: `agent:${agent.slug}`,
687
- label: `# ${agent.slug}`,
688
- slug: agent.slug,
689
- name: agent.name,
690
- description: agent.description,
691
- ...(agent.scope ? { scope: agent.scope } : {}),
692
- }))
693
- .sort((a, b) => a.label.localeCompare(b.label));
694
- res.json([
695
- {
696
- key: "default",
697
- label: "# chapterhouse",
698
- name: "Chapterhouse",
699
- description: "Orchestrator",
700
- },
701
- ...persistentAgentChannels,
702
- ]);
703
- });
704
- // List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
705
- // dispatched subagents remain visible after they finish, not just in-flight ones.
706
- app.get("/api/workers", (_req, res) => {
707
- const rows = getDb()
708
- .prepare(`SELECT task_id, agent_slug, description, status, started_at, completed_at
709
- FROM agent_tasks
710
- WHERE started_at >= datetime('now', '-24 hours')
711
- ORDER BY started_at DESC
712
- LIMIT 100`)
713
- .all();
714
- const registry = getAgentRegistry();
715
- res.json(rows.map((row) => {
716
- const agent = registry.find((a) => a.slug === row.agent_slug);
717
- return {
718
- taskId: row.task_id,
719
- slug: row.agent_slug,
720
- name: agent?.name || row.agent_slug,
721
- model: agent?.model || "unknown",
722
- description: row.description,
723
- status: row.status,
724
- startedAt: row.started_at,
725
- completedAt: row.completed_at,
726
- };
727
- }));
728
- });
729
- // Detailed worker row: include task status, description, and any captured result/output.
730
- app.get("/api/workers/:taskId", (req, res) => {
731
- const taskId = req.params.taskId;
732
- const row = getDb()
733
- .prepare(`SELECT task_id, agent_slug, description, prompt, status, result, started_at, completed_at
734
- FROM agent_tasks WHERE task_id = ?`)
735
- .get(taskId);
736
- if (!row) {
737
- throw new NotFoundError("Task not found");
738
- }
739
- const registry = getAgentRegistry();
740
- const agent = registry.find((a) => a.slug === row.agent_slug);
741
- res.json({
742
- taskId: row.task_id,
743
- agentSlug: row.agent_slug,
744
- name: agent?.name || row.agent_slug,
745
- description: row.description,
746
- prompt: row.prompt,
747
- status: row.status,
748
- result: row.result,
749
- startedAt: row.started_at,
750
- completedAt: row.completed_at,
751
- });
752
- });
753
- const TERMINAL_TASK_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
754
- // SSE stream for per-task tool-call activity.
755
- // Replays buffered/persisted backlog on connect, then streams live events until
756
- // the task reaches a terminal state.
757
- app.get("/api/workers/:taskId/events", (req, res) => {
758
- const taskId = req.params.taskId;
759
- const taskRow = getDb()
760
- .prepare(`SELECT task_id FROM agent_tasks WHERE task_id = ?`)
761
- .get(taskId);
762
- if (!taskRow) {
763
- throw new NotFoundError("Task not found");
764
- }
765
- res.setHeader("Content-Type", "text/event-stream");
766
- res.setHeader("Cache-Control", "no-cache");
767
- res.setHeader("Connection", "keep-alive");
768
- res.setHeader("X-Accel-Buffering", "no");
769
- res.flushHeaders();
770
- const rawLastId = req.headers["last-event-id"];
771
- const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
772
- ? parseInt(rawLastId.trim(), 10)
773
- : undefined;
774
- const sendEvent = (event) => {
775
- let payload;
776
- if (event.kind === "output_delta") {
777
- payload = {
778
- type: "output_delta",
779
- taskId: event.taskId,
780
- seq: event.seq,
781
- text: event.text ?? "",
782
- };
783
- }
784
- else if (event.kind === "task_status") {
785
- payload = {
786
- type: "task_status",
787
- taskId: event.taskId,
788
- seq: event.seq,
789
- status: event.status ?? "running",
790
- summary: event.summary,
791
- };
792
- }
793
- else {
794
- payload = {
795
- taskId: event.taskId,
796
- seq: event.seq,
797
- ts: event.ts,
798
- kind: event.kind,
799
- toolName: event.toolName,
800
- summary: event.summary,
801
- };
802
- }
803
- res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
804
- };
805
- let replayHighSeq = lastSeq;
806
- if (lastSeq !== undefined) {
807
- const bufferedEvents = getTaskLogEvents(taskId);
808
- const oldestBufferedSeq = bufferedEvents[0]?.seq;
809
- const bufferMissesRange = oldestBufferedSeq === undefined || oldestBufferedSeq > lastSeq + 1;
810
- if (bufferMissesRange) {
811
- const dbEvents = getTaskEvents(taskId, lastSeq);
812
- for (const event of dbEvents) {
813
- sendEvent(event);
814
- if (replayHighSeq === undefined || event.seq > replayHighSeq) {
815
- replayHighSeq = event.seq;
816
- }
817
- }
818
- }
819
- }
820
- const replayEvents = getTaskLogEvents(taskId, replayHighSeq ?? 0);
821
- const backlog = replayEvents.length > 0 ? replayEvents : getTaskEvents(taskId, replayHighSeq ?? 0);
822
- for (const event of backlog) {
823
- sendEvent(event);
824
- }
825
- const isTerminal = () => {
826
- const row = getDb()
827
- .prepare(`SELECT status FROM agent_tasks WHERE task_id = ?`)
828
- .get(taskId);
829
- return row ? TERMINAL_TASK_STATUSES.has(row.status) : true;
830
- };
831
- res.write(`: connected task=${taskId}\n\n`);
832
- if (isTerminal()) {
833
- res.end();
834
- return;
835
- }
836
- const heartbeat = setInterval(() => {
837
- res.write(`: keep-alive\n\n`);
838
- }, 15_000);
839
- let cleaned = false;
840
- let unsubscribeTaskLog;
841
- let unsubscribeDestroyed;
842
- let unsubscribeError;
843
- const cleanup = () => {
844
- if (cleaned)
845
- return;
846
- cleaned = true;
847
- clearInterval(heartbeat);
848
- unsubscribeTaskLog?.();
849
- unsubscribeDestroyed?.();
850
- unsubscribeError?.();
851
- };
852
- unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
853
- sendEvent(event);
854
- // Close SSE when a terminal task_status event arrives
855
- if (event.kind === "task_status" && event.status && TERMINAL_TASK_STATUSES.has(event.status)) {
856
- cleanup();
857
- res.end();
858
- }
859
- });
860
- unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
861
- if (event.sessionId === taskId && isTerminal()) {
862
- cleanup();
863
- res.end();
864
- }
865
- });
866
- unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
867
- if (event.sessionId === taskId && isTerminal()) {
868
- cleanup();
869
- res.end();
870
- }
871
- });
872
- req.on("close", () => {
873
- cleanup();
874
- });
875
- });
876
- // ---------------------------------------------------------------------------
877
- // Global agent EventBus SSE stream — thin pass-through of AgentEvents.
878
- // Replaces the 4-second poll in the Workers frontend: clients subscribe once
879
- // and receive push notifications on session:created / session:destroyed /
880
- // session:error so the worker list updates in real time.
881
- // Chat-specific events (delta, message, queued) are NOT emitted here.
882
- // ---------------------------------------------------------------------------
883
- app.get("/api/agents/stream", (req, res) => {
884
- res.writeHead(200, {
885
- "Content-Type": "text/event-stream",
886
- "Cache-Control": "no-cache",
887
- Connection: "keep-alive",
888
- });
889
- res.write(formatSseData({ type: "connected" }));
890
- const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
891
- const unsub = agentEventBus.subscribeAll((event) => {
892
- res.write(formatSseData({ type: "agent_event", agentEvent: event }));
893
- });
894
- req.on("close", () => {
895
- clearInterval(heartbeat);
896
- unsub();
897
- });
898
- });
899
- app.get("/stream", (req, res) => {
900
- const connectionId = `web-${++connectionCounter}`;
901
- res.writeHead(200, {
902
- "Content-Type": "text/event-stream",
903
- "Cache-Control": "no-cache",
904
- Connection: "keep-alive",
905
- });
906
- res.write(formatSseData({ type: "connected", connectionId }));
907
- while (pendingSseMessages.length > 0) {
908
- const queued = pendingSseMessages.shift();
909
- if (!queued) {
910
- continue;
911
- }
912
- res.write(formatSseData({ type: "message", content: queued }));
913
- }
914
- sseClients.set(connectionId, res);
915
- const unsubscribeStatus = onStatusChange((status, message) => {
916
- res.write(formatSseData({ type: "status", status, message }));
917
- });
918
- const currentStatus = getStatus();
919
- if (currentStatus.status !== "idle") {
920
- res.write(formatSseData({ type: "status", ...currentStatus }));
921
- }
922
- const heartbeat = setInterval(() => {
923
- res.write(`:ping\n\n`);
924
- }, 20_000);
925
- req.on("close", () => {
926
- clearInterval(heartbeat);
927
- unsubscribeStatus();
928
- sseClients.delete(connectionId);
929
- });
930
- });
931
- // ---------------------------------------------------------------------------
932
- // Send a message to the orchestrator
933
- // ---------------------------------------------------------------------------
934
- app.post("/api/message", (req, res) => {
935
- const { prompt, connectionId, projectPath, sessionKey: requestedSessionKey, msgId } = parseRequest(messageRequestSchema, req.body);
936
- const effectiveSessionKey = requestedSessionKey || "default";
937
- if (!sseClients.has(connectionId)) {
938
- throw new BadRequestError("Missing or invalid 'connectionId'. Connect to /stream first.");
939
- }
940
- sendToOrchestrator(prompt, {
941
- type: "web",
942
- connectionId,
943
- user: req.user,
944
- authorizationHeader: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined,
945
- projectPath: projectPath || undefined,
946
- }, (text, done, turnId) => {
947
- const sseRes = sseClients.get(connectionId);
948
- if (sseRes) {
949
- const event = {
950
- type: done ? "message" : "delta",
951
- content: text,
952
- sessionKey: effectiveSessionKey,
953
- turnId,
954
- };
955
- if (done) {
956
- const routeResult = getLastRouteResult();
957
- if (routeResult) {
958
- event.route = {
959
- model: routeResult.model,
960
- routerMode: routeResult.routerMode,
961
- ...(routeResult.tier !== null ? { tier: routeResult.tier } : {}),
962
- ...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
963
- };
964
- }
965
- }
966
- sseRes.write(formatSseData(event));
967
- }
968
- }, undefined, (activity, turnId) => {
969
- const sseRes = sseClients.get(connectionId);
970
- if (sseRes) {
971
- sseRes.write(formatSseData({ type: "activity", ...activity, sessionKey: effectiveSessionKey, ...(turnId ? { turnId } : {}) }));
972
- }
973
- }, (position, turnId) => {
974
- const sseRes = sseClients.get(connectionId);
975
- if (sseRes) {
976
- sseRes.write(formatSseData({
977
- type: "queued",
978
- position,
979
- sessionKey: effectiveSessionKey,
980
- turnId,
981
- ...(msgId ? { msgId } : {}),
982
- }));
983
- }
984
- }, (remainingLength) => {
985
- const sseRes = sseClients.get(connectionId);
986
- if (sseRes) {
987
- sseRes.write(formatSseData({
988
- type: "queue-advance",
989
- length: remainingLength,
990
- sessionKey: effectiveSessionKey,
991
- }));
992
- }
993
- });
994
- res.json({ status: "queued" });
995
- });
996
- // Cancel the current in-flight message
997
- app.post("/api/cancel", async (_req, res) => {
998
- const sessionKey = getCurrentSessionKey();
999
- const cancelled = await cancelCurrentMessage();
1000
- for (const [, sseRes] of sseClients) {
1001
- sseRes.write(formatSseData({ type: "cancelled", sessionKey }));
1002
- }
1003
- res.json({ status: "ok", cancelled });
1004
- });
1005
- app.post("/api/agents/:slug/reload-confirm", async (req, res) => {
1006
- const slugParam = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
1007
- const slug = slugParam?.trim() || "";
1008
- const agent = getAgent(slug);
1009
- if (!agent?.persistent) {
1010
- throw new NotFoundError("Agent not found");
1011
- }
1012
- const sessionKey = `agent:${agent.slug}`;
1013
- await interruptSessionTurn(sessionKey);
1014
- const reloadResult = await reloadPersistentAgent(agent.slug);
1015
- if (reloadResult === "reloaded") {
1016
- broadcastSsePayload({ type: "agent_reloaded", slug: agent.slug, reason: "confirmed_restart" });
1017
- }
1018
- res.json({ status: "ok" });
1019
- });
1020
- // Cancel the active turn for one session key without touching other channels.
1021
- app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
1022
- const sessionKey = Array.isArray(req.params.sessionKey)
1023
- ? req.params.sessionKey[0]
1024
- : req.params.sessionKey;
1025
- if (!sessionKey)
1026
- throw new BadRequestError("Missing sessionKey");
1027
- const cancelled = await interruptSessionTurn(sessionKey);
1028
- res.json({ status: "ok", cancelled });
1029
- });
1030
- // Interrupt the active turn on a specific session and start a replacement turn.
1031
- // POST /api/sessions/:sessionKey/interrupt
1032
- // Body: { prompt, connectionId, attachments? }
1033
- app.post("/api/sessions/:sessionKey/interrupt", (req, res) => {
1034
- const sessionKey = Array.isArray(req.params.sessionKey)
1035
- ? req.params.sessionKey[0]
1036
- : req.params.sessionKey;
1037
- if (!sessionKey)
1038
- throw new BadRequestError("Missing sessionKey");
1039
- const { prompt, connectionId, attachments } = parseRequest(interruptRequestSchema, req.body);
1040
- if (!sseClients.has(connectionId)) {
1041
- throw new BadRequestError("Missing or invalid 'connectionId'. Connect to /stream first.");
1042
- }
1043
- const source = {
1044
- type: "web",
1045
- connectionId,
1046
- user: req.user,
1047
- authorizationHeader: typeof req.headers.authorization === "string" ? req.headers.authorization : undefined,
1048
- };
1049
- interruptCurrentTurn(sessionKey, prompt, source, (text, done, turnId) => {
1050
- const sseRes = sseClients.get(connectionId);
1051
- if (sseRes) {
1052
- const event = {
1053
- type: done ? "message" : "delta",
1054
- content: text,
1055
- sessionKey,
1056
- turnId,
1057
- };
1058
- if (done) {
1059
- const routeResult = getLastRouteResult();
1060
- if (routeResult) {
1061
- event.route = {
1062
- model: routeResult.model,
1063
- routerMode: routeResult.routerMode,
1064
- ...(routeResult.tier !== null ? { tier: routeResult.tier } : {}),
1065
- ...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
1066
- };
1067
- }
1068
- }
1069
- sseRes.write(formatSseData(event));
1070
- }
1071
- }, attachments, (activity, turnId) => {
1072
- const sseRes = sseClients.get(connectionId);
1073
- if (sseRes) {
1074
- sseRes.write(formatSseData({ type: "activity", ...activity, sessionKey, ...(turnId ? { turnId } : {}) }));
1075
- }
1076
- }, (abortedTurnId) => {
1077
- const sseRes = sseClients.get(connectionId);
1078
- if (sseRes) {
1079
- sseRes.write(formatSseData({ type: "turn-interrupted", abortedTurnId, sessionKey }));
1080
- }
1081
- });
1082
- res.json({ status: "interrupting" });
1083
- });
1084
- // ---------------------------------------------------------------------------
1085
- // Chat POST→SSE endpoints — enabled by default, overridable via CHAPTERHOUSE_CHAT_SSE (#130)
1086
- // ---------------------------------------------------------------------------
1087
- const turnRequestSchema = z.object({
1088
- prompt: z.string().trim().min(1, "Missing prompt"),
1089
- source: z.string().optional(),
1090
- attachments: z
1091
- .array(z.object({
1092
- type: z.literal("file"),
1093
- path: z.string(),
1094
- displayName: z.string().optional(),
1095
- }))
1096
- .optional(),
1097
- interrupt: z.boolean().optional(),
1098
- });
1099
- if (config.chatSseEnabled) {
1100
- /**
1101
- * POST /api/sessions/:key/turn
1102
- *
1103
- * Submit a new turn to the given session. Returns `{ turnId }` immediately;
1104
- * the turn runs asynchronously and emits events on the per-session SSE stream.
1105
- *
1106
- * Body: `{ prompt, source?, attachments?, interrupt? }`
1107
- * Response: `{ turnId: string }`
1108
- */
1109
- app.post("/api/sessions/:key/turn", (req, res) => {
1110
- const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
1111
- if (!sessionKey)
1112
- throw new BadRequestError("Missing session key");
1113
- const { prompt, attachments, interrupt } = parseRequest(turnRequestSchema, req.body);
1114
- const authUser = req.user;
1115
- const authHeader = req.headers.authorization ?? undefined;
1116
- const turnId = enqueueForSse({
1117
- sessionKey,
1118
- prompt,
1119
- attachments,
1120
- authUser,
1121
- authHeader,
1122
- interrupt: interrupt ?? false,
1123
- });
1124
- res.json({ turnId });
1125
- });
1126
- /**
1127
- * GET /api/sessions/:key/stream
1128
- *
1129
- * Per-session SSE channel. Delivers turn events:
1130
- * `turn:started`, `turn:delta`, `turn:queued`, `turn:interrupted`,
1131
- * `turn:complete`, `turn:error`
1132
- *
1133
- * Supports `Last-Event-ID` for reconnect replay:
1134
- * - If the session ring buffer covers the requested range, replays from memory.
1135
- * - Otherwise falls back to SQLite (covers completed-turn replay).
1136
- *
1137
- * Keep-alive comments are sent every 15 s.
1138
- * Multiple simultaneous subscribers (tabs) are supported.
1139
- */
1140
- app.get("/api/sessions/:key/stream", (req, res) => {
1141
- const sessionKey = Array.isArray(req.params.key) ? req.params.key[0] : req.params.key;
1142
- if (!sessionKey)
1143
- throw new BadRequestError("Missing session key");
1144
- const includeHistorical = req.query.include === "all";
1145
- res.setHeader("Content-Type", "text/event-stream");
1146
- res.setHeader("Cache-Control", "no-cache");
1147
- res.setHeader("Connection", "keep-alive");
1148
- res.setHeader("X-Accel-Buffering", "no");
1149
- res.flushHeaders();
1150
- // Parse Last-Event-ID for reconnect replay
1151
- const rawLastId = req.headers["last-event-id"];
1152
- const lastSeq = rawLastId && !Array.isArray(rawLastId) && /^\d+$/.test(rawLastId.trim())
1153
- ? parseInt(rawLastId.trim(), 10)
1154
- : undefined;
1155
- const maxCurrentSeq = getSessionMaxSeqFromDb(sessionKey, { includeHistorical });
1156
- const effectiveLastSeq = maxCurrentSeq !== undefined && lastSeq !== undefined && lastSeq > maxCurrentSeq
1157
- ? 0
1158
- : lastSeq;
1159
- // Helper: send a named SSE event with an id: field
1160
- const sendEvent = (event, seq) => {
1161
- const payload = JSON.stringify(event);
1162
- res.write(`id: ${seq}\ndata: ${payload}\n\n`);
1163
- };
1164
- // If Last-Event-ID is present and the session ring buffer doesn't cover it,
1165
- // fall back to SQLite for replay of completed turns.
1166
- let replayHighSeq = effectiveLastSeq;
1167
- if (effectiveLastSeq !== undefined) {
1168
- const oldestBuf = oldestSessionSeq(sessionKey);
1169
- const bufferMissesRange = oldestBuf === undefined || oldestBuf > effectiveLastSeq + 1;
1170
- if (bufferMissesRange) {
1171
- // Replay from SQLite (completed turns)
1172
- const dbEvents = getSessionEventsFromDb(sessionKey, effectiveLastSeq, { includeHistorical });
1173
- for (const e of dbEvents) {
1174
- sendEvent(e, e._seq);
1175
- if (replayHighSeq === undefined || e._seq > replayHighSeq)
1176
- replayHighSeq = e._seq;
1177
- }
1178
- }
1179
- }
1180
- // Subscribe to session events (replays ring buffer for afterSeq, then live).
1181
- // Use replayHighSeq (not lastSeq) so ring-buffer replay starts after any DB
1182
- // events we already sent — avoids double-replay overlap (Fix 5).
1183
- const unsub = subscribeSession(sessionKey, (e) => {
1184
- sendEvent(e, e._seq);
1185
- }, replayHighSeq);
1186
- // Send connected event
1187
- res.write(`: connected session=${sessionKey} run=${getCurrentRunId()}\n\n`);
1188
- // Keep-alive every 15 s
1189
- const keepAlive = setInterval(() => {
1190
- res.write(`: keep-alive\n\n`);
1191
- }, 15_000);
1192
- req.on("close", () => {
1193
- clearInterval(keepAlive);
1194
- unsub();
1195
- });
1196
- });
1197
- }
1198
- else {
1199
- // Feature flag off — return 404 for both endpoints
1200
- app.post("/api/sessions/:key/turn", (_req, res) => {
1201
- res.status(404).json({ error: "Chat SSE not enabled. Set CHAPTERHOUSE_CHAT_SSE=1." });
1202
- });
1203
- app.get("/api/sessions/:key/stream", (_req, res) => {
1204
- res.status(404).json({ error: "Chat SSE not enabled. Set CHAPTERHOUSE_CHAT_SSE=1." });
1205
- });
1206
- }
1207
- // ---------------------------------------------------------------------------
1208
- // Model & router
1209
- // ---------------------------------------------------------------------------
1210
- app.get("/api/model", (_req, res) => {
1211
- res.json({ model: config.copilotModel });
1212
- });
1213
- app.post("/api/model", async (req, res) => {
1214
- const { model } = parseRequest(modelRequestSchema, req.body);
1215
- try {
1216
- const { getClient } = await import("../copilot/client.js");
1217
- const client = await getClient();
1218
- const models = await client.listModels();
1219
- const match = models.find((m) => m.id === model);
1220
- if (!match) {
1221
- const suggestions = models
1222
- .filter((m) => m.id.includes(model) || m.id.toLowerCase().includes(model.toLowerCase()))
1223
- .map((m) => m.id);
1224
- const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
1225
- throw new BadRequestError(`Model '${model}' not found.${hint}`);
1226
- }
1227
- }
1228
- catch (error) {
1229
- if (error instanceof BadRequestError) {
1230
- throw error;
1231
- }
1232
- // If we can't validate (client not ready), allow the switch — it'll fail on next message if wrong
1233
- }
1234
- const previous = config.copilotModel;
1235
- config.copilotModel = model;
1236
- persistModel(model);
1237
- res.json({ previous, current: model });
1238
- });
1239
- app.get("/api/models", async (_req, res) => {
1240
- try {
1241
- const { getClient } = await import("../copilot/client.js");
1242
- const client = await getClient();
1243
- const models = await client.listModels();
1244
- res.json({ models: models.map((m) => m.id), current: config.copilotModel });
1245
- }
1246
- catch (error) {
1247
- log.error({ err: error instanceof Error ? error.message : error }, "Failed to list models");
1248
- throw new InternalServerError();
1249
- }
1250
- });
1251
- app.get("/api/auto", (_req, res) => {
1252
- const routerConfig = getRouterConfig();
1253
- const lastRoute = getLastRouteResult();
1254
- res.json({
1255
- ...routerConfig,
1256
- currentModel: config.copilotModel,
1257
- lastRoute: lastRoute || null,
1258
- });
1259
- });
1260
- app.get("/api/memory/active-scope", (_req, res) => {
1261
- const activeScope = getActiveScope();
1262
- if (!activeScope) {
1263
- res.json(null);
1264
- return;
1265
- }
1266
- res.json({
1267
- slug: activeScope.slug,
1268
- title: activeScope.title,
1269
- });
1270
- });
1271
- app.post("/api/memory/active-scope", (req, res) => {
1272
- const body = parseRequest(setActiveScopeSchema, req.body ?? {});
1273
- try {
1274
- const scope = setActiveScope(body.scope);
1275
- res.json({ ok: true, scope: scope?.slug ?? null });
1276
- }
1277
- catch (err) {
1278
- res.status(404).json({ error: err instanceof Error ? err.message : String(err) });
1279
- }
1280
- });
1281
- app.get("/api/memory/scopes", (_req, res) => {
1282
- const db = getDb();
1283
- const activeScope = getActiveScope();
1284
- const scopes = listScopes();
1285
- const result = scopes.map((scope) => {
1286
- const counts = {
1287
- observations: db.prepare(`SELECT COUNT(*) AS count FROM mem_observations WHERE scope_id = ?`).get(scope.id).count,
1288
- decisions: db.prepare(`SELECT COUNT(*) AS count FROM mem_decisions WHERE scope_id = ?`).get(scope.id).count,
1289
- entities: db.prepare(`SELECT COUNT(*) AS count FROM mem_entities WHERE scope_id = ?`).get(scope.id).count,
1290
- action_items: db.prepare(`SELECT COUNT(*) AS count FROM mem_action_items WHERE scope_id = ?`).get(scope.id).count,
1291
- };
1292
- return {
1293
- slug: scope.slug,
1294
- title: scope.title,
1295
- description: scope.description,
1296
- active: activeScope?.slug === scope.slug,
1297
- counts,
1298
- };
1299
- });
1300
- res.json({ scopes: result });
1301
- });
1302
- app.get("/api/memory/inbox", (_req, res) => {
1303
- const items = listPendingInboxItems();
1304
- const result = items.map((item) => ({
1305
- id: item.id,
1306
- scope_slug: item.scopeId
1307
- ? getDb().prepare(`SELECT slug FROM mem_scopes WHERE id = ?`).get(item.scopeId)?.slug ?? null
1308
- : null,
1309
- kind: item.kind,
1310
- payload: item.payload,
1311
- source_agent: item.sourceAgent,
1312
- created_at: item.createdAt,
1313
- }));
1314
- res.json({ items: result, total: result.length });
1315
- });
1316
- app.post("/api/memory/inbox/:id/route", (req, res) => {
1317
- const id = Number(req.params.id);
1318
- if (!Number.isInteger(id) || id <= 0) {
1319
- res.status(400).json({ error: "Invalid inbox item id" });
1320
- return;
1321
- }
1322
- const body = parseRequest(inboxRouteSchema, req.body ?? {});
1323
- const item = getInboxItem(id);
1324
- if (!item) {
1325
- res.status(404).json({ error: `Inbox item '${id}' not found` });
1326
- return;
1327
- }
1328
- if (item.status !== "pending") {
1329
- res.status(409).json({ error: `Inbox item '${id}' is already resolved` });
1330
- return;
1331
- }
1332
- const status = body.action === "accept" ? "accepted" : "rejected";
1333
- const reason = body.reason ?? (body.action === "accept" ? "Accepted via web UI" : "Rejected via web UI");
1334
- resolveInboxItem(id, status, reason);
1335
- log.info({ id, action: body.action }, "inbox item routed via web UI");
1336
- res.json({ ok: true });
1337
- });
1338
- app.get("/api/memory/:scope", (req, res) => {
1339
- const scopeSlug = String(req.params.scope);
1340
- const scope = getScope(scopeSlug);
1341
- if (!scope) {
1342
- res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
1343
- return;
1344
- }
1345
- const query = parseRequest(memoryEntriesQuerySchema, req.query);
1346
- const store = query.store ?? "observations";
1347
- const tier = query.tier;
1348
- const db = getDb();
1349
- let entries;
1350
- let total;
1351
- if (store === "observations") {
1352
- if (tier) {
1353
- entries = db.prepare(`SELECT id, content, source, tier, entity_id, created_at FROM mem_observations WHERE scope_id = ? AND tier = ? AND archived_at IS NULL ORDER BY id DESC LIMIT 100`).all(scope.id, tier);
1354
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_observations WHERE scope_id = ? AND tier = ? AND archived_at IS NULL`).get(scope.id, tier).n;
1355
- }
1356
- else {
1357
- entries = db.prepare(`SELECT id, content, source, tier, entity_id, created_at FROM mem_observations WHERE scope_id = ? AND archived_at IS NULL ORDER BY id DESC LIMIT 100`).all(scope.id);
1358
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_observations WHERE scope_id = ? AND archived_at IS NULL`).get(scope.id).n;
1359
- }
1360
- }
1361
- else if (store === "decisions") {
1362
- if (tier) {
1363
- entries = db.prepare(`SELECT id, title, rationale, tier, entity_id, decided_at, created_at FROM mem_decisions WHERE scope_id = ? AND tier = ? AND archived_at IS NULL ORDER BY decided_at DESC, id DESC LIMIT 100`).all(scope.id, tier);
1364
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_decisions WHERE scope_id = ? AND tier = ? AND archived_at IS NULL`).get(scope.id, tier).n;
1365
- }
1366
- else {
1367
- entries = db.prepare(`SELECT id, title, rationale, tier, entity_id, decided_at, created_at FROM mem_decisions WHERE scope_id = ? AND archived_at IS NULL ORDER BY decided_at DESC, id DESC LIMIT 100`).all(scope.id);
1368
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_decisions WHERE scope_id = ? AND archived_at IS NULL`).get(scope.id).n;
1369
- }
1370
- }
1371
- else if (store === "entities") {
1372
- if (tier) {
1373
- entries = db.prepare(`SELECT id, kind, name, summary, tier, created_at, updated_at FROM mem_entities WHERE scope_id = ? AND tier = ? ORDER BY updated_at DESC, id DESC LIMIT 100`).all(scope.id, tier);
1374
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
1375
- }
1376
- else {
1377
- entries = db.prepare(`SELECT id, kind, name, summary, tier, created_at, updated_at FROM mem_entities WHERE scope_id = ? ORDER BY updated_at DESC, id DESC LIMIT 100`).all(scope.id);
1378
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ?`).get(scope.id).n;
1379
- }
1380
- }
1381
- else {
1382
- if (tier) {
1383
- entries = db.prepare(`SELECT id, title, detail, status, tier, due_at, entity_id, created_at FROM mem_action_items WHERE scope_id = ? AND tier = ? ORDER BY created_at DESC, id DESC LIMIT 100`).all(scope.id, tier);
1384
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
1385
- }
1386
- else {
1387
- entries = db.prepare(`SELECT id, title, detail, status, tier, due_at, entity_id, created_at FROM mem_action_items WHERE scope_id = ? ORDER BY created_at DESC, id DESC LIMIT 100`).all(scope.id);
1388
- total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ?`).get(scope.id).n;
1389
- }
1390
- }
1391
- res.json({ entries, total });
1392
- });
1393
- app.post("/api/memory/:scope/remember", (req, res) => {
1394
- const scopeSlug = String(req.params.scope);
1395
- const scope = getScope(scopeSlug);
1396
- if (!scope) {
1397
- res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
1398
- return;
1399
- }
1400
- const body = parseRequest(memoryRememberSchema, req.body ?? {});
1401
- if (body.entity_name && !body.entity_kind) {
1402
- res.status(400).json({ error: "entity_kind is required when entity_name is provided" });
1403
- return;
1404
- }
1405
- const kind = body.kind ?? "observation";
1406
- const entity = body.entity_name
1407
- ? upsertEntity({
1408
- scope_id: scope.id,
1409
- kind: body.entity_kind,
1410
- name: body.entity_name,
1411
- tier: body.tier ?? "warm",
1412
- })
1413
- : undefined;
1414
- if (kind === "decision") {
1415
- if (!body.title) {
1416
- res.status(400).json({ error: "title is required when kind='decision'" });
1417
- return;
1418
- }
1419
- const decision = recordDecision({
1420
- scope_id: scope.id,
1421
- entity_id: entity?.id,
1422
- title: body.title,
1423
- rationale: body.content,
1424
- decided_at: body.decided_at ?? new Date().toISOString().slice(0, 10),
1425
- tier: body.tier ?? "warm",
1426
- });
1427
- log.info({ id: decision.id, scope: scopeSlug, kind }, "memory written via web UI");
1428
- res.json({ ok: true, id: String(decision.id) });
1429
- return;
1430
- }
1431
- const observation = recordObservation({
1432
- scope_id: scope.id,
1433
- entity_id: entity?.id,
1434
- content: body.content,
1435
- source: "agent:web-ui",
1436
- tier: body.tier ?? "warm",
1437
- });
1438
- log.info({ id: observation.id, scope: scopeSlug, kind }, "memory written via web UI");
1439
- res.json({ ok: true, id: String(observation.id) });
1440
- });
1441
- app.post("/api/memory/hooks/git-commit", authMiddleware, (req, res, next) => {
1442
- const body = parseRequest(gitCommitHookSchema, req.body ?? {});
1443
- handleGitCommitHook({ message: body.message, stat: body.stat })
1444
- .then((result) => res.json({ ok: true, observation_id: result.observation_id }))
1445
- .catch(next);
1446
- });
1447
- app.post("/api/memory/hooks/pr-merge", authMiddleware, (req, res, next) => {
1448
- const body = parseRequest(prMergeHookSchema, req.body ?? {});
1449
- handlePrMergeHook({
1450
- number: body.number,
1451
- title: body.title,
1452
- body: body.body,
1453
- files_changed: body.files_changed,
1454
- })
1455
- .then((result) => res.json({ ok: true, observation_id: result.observation_id }))
1456
- .catch(next);
1457
- });
1458
- app.post("/api/scopes", (req, res) => {
1459
- const body = parseRequest(scopeCreateSchema, req.body ?? {});
1460
- if (getScope(body.slug)) {
1461
- res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
1462
- return;
1463
- }
1464
- const scope = createScope({
1465
- slug: body.slug,
1466
- title: body.title,
1467
- description: body.description ?? "",
1468
- keywords: [body.slug],
1469
- });
1470
- res.status(201).json({
1471
- slug: scope.slug,
1472
- title: scope.title,
1473
- description: scope.description,
1474
- active: scope.active,
1475
- });
1476
- });
1477
- app.post("/api/auto", (req, res) => {
1478
- const body = parseRequest(autoRequestSchema, req.body ?? {});
1479
- const updated = updateRouterConfig(body);
1480
- log.info({ enabled: updated.enabled }, "Auto-routing updated");
1481
- res.json(updated);
1482
- });
1483
- app.get("/api/projects", (_req, res) => {
1484
- ensureWikiStructure();
1485
- const projects = Object.entries(loadRegistry())
1486
- .sort(([left], [right]) => left.localeCompare(right))
1487
- .map(([slug, cwd]) => {
1488
- const summary = loadProjectRuleSummary(slug);
1489
- return {
1490
- slug,
1491
- cwd,
1492
- hardRuleCount: summary.hardRuleCount,
1493
- softRuleCount: summary.softRuleCount,
1494
- };
1495
- });
1496
- res.json(projects);
1497
- });
1498
- app.get("/api/projects/:slug", (req, res) => {
1499
- ensureWikiStructure();
1500
- const slugParam = req.params.slug;
1501
- const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
1502
- const cwd = loadRegistry()[slug];
1503
- if (!cwd) {
1504
- res.status(404).json({ error: "Project not found" });
1505
- return;
1506
- }
1507
- res.json(createProjectDetailPayload(slug, cwd));
1508
- });
1509
- app.post("/api/projects", async (req, res) => {
1510
- ensureWikiStructure();
1511
- const { slug, cwd } = parseRequest(projectCreateSchema, req.body ?? {});
1512
- const rulesPath = getProjectRulesPath(slug);
1513
- await withWikiWrite(() => {
1514
- const registry = loadRegistry();
1515
- if (registry[slug]) {
1516
- throw new BadRequestError(`Project '${slug}' already exists`);
1517
- }
1518
- if (pageExists(rulesPath)) {
1519
- throw new BadRequestError(`Project rules page '${rulesPath}' already exists`);
1520
- }
1521
- saveRegistry({
1522
- ...registry,
1523
- [slug]: cwd,
1524
- });
1525
- writePage(rulesPath, renderInitialProjectRulesPage(slug));
1526
- });
1527
- res.status(201).json(createProjectDetailPayload(slug, cwd));
1528
- });
1529
- app.delete("/api/projects/:slug", async (req, res) => {
1530
- ensureWikiStructure();
1531
- const slugParam = req.params.slug;
1532
- const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
1533
- const registry = loadRegistry();
1534
- if (!registry[slug]) {
1535
- throw new NotFoundError("Project not found");
1536
- }
1537
- const rulesPath = getProjectRulesPath(slug);
1538
- await withWikiWrite(() => {
1539
- const nextRegistry = { ...loadRegistry() };
1540
- delete nextRegistry[slug];
1541
- saveRegistry(nextRegistry);
1542
- deletePage(rulesPath);
1543
- });
1544
- res.json({ ok: true, slug });
1545
- });
1546
- app.put("/api/projects/:slug/rules/hard", async (req, res) => {
1547
- ensureWikiStructure();
1548
- const slugParam = req.params.slug;
1549
- const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
1550
- const cwd = loadRegistry()[slug];
1551
- if (!cwd) {
1552
- throw new NotFoundError("Project not found");
1553
- }
1554
- if (!pageExists(getProjectRulesPath(slug))) {
1555
- throw new NotFoundError("Project rules not found");
1556
- }
1557
- const { hardRules } = parseRequest(projectHardRulesSchema, req.body ?? {});
1558
- await withWikiWrite(() => {
1559
- saveProjectRulesHardFields(slug, hardRules);
1560
- });
1561
- res.json(createProjectDetailPayload(slug, cwd));
1562
- });
1563
- app.put("/api/projects/:slug/rules/soft", async (req, res) => {
1564
- ensureWikiStructure();
1565
- const slugParam = req.params.slug;
1566
- const slug = Array.isArray(slugParam) ? (slugParam[0] ?? "") : (slugParam ?? "");
1567
- const cwd = loadRegistry()[slug];
1568
- if (!cwd) {
1569
- throw new NotFoundError("Project not found");
1570
- }
1571
- if (!pageExists(getProjectRulesPath(slug))) {
1572
- throw new NotFoundError("Project rules not found");
1573
- }
1574
- const { softRules } = parseRequest(projectSoftRulesSchema, req.body ?? {});
1575
- await withWikiWrite(() => {
1576
- saveProjectRulesSoftRules(slug, softRules);
1577
- });
1578
- res.json(createProjectDetailPayload(slug, cwd));
1579
- });
1580
- // ---------------------------------------------------------------------------
1581
- // Wiki: list, read, write, delete
1582
- // ---------------------------------------------------------------------------
1583
- app.get("/api/wiki/pages", async (req, res) => {
1584
- ensureWikiStructure();
1585
- // Sync team wiki pages if connected, using the caller's auth token
1586
- if (modeContext.canSyncTeamWiki() && teamWikiSync.isEnabled()) {
1587
- const authorizationHeader = typeof req.headers.authorization === "string"
1588
- ? req.headers.authorization
1589
- : undefined;
1590
- try {
1591
- await teamWikiSync.syncAll({ authorizationHeader });
1592
- }
1593
- catch {
1594
- // Non-fatal: list local pages even if team sync fails
1595
- }
1596
- }
1597
- const entries = parseIndex();
1598
- // Index entries first (rich metadata), then any pages on disk that aren't yet indexed.
1599
- const indexed = new Set(entries.map((e) => e.path));
1600
- const indexedResults = entries.map((e) => ({
1601
- path: e.path,
1602
- title: e.title,
1603
- summary: e.summary,
1604
- section: e.section,
1605
- tags: e.tags || [],
1606
- updated: coerceWikiPageUpdated(e.path, e.updated),
1607
- scope: getWikiPageScope(e.path),
1608
- }));
1609
- const orphanResults = listPages()
1610
- .filter((p) => !indexed.has(p))
1611
- .map((p) => ({
1612
- path: p,
1613
- title: p,
1614
- summary: "",
1615
- section: "Unindexed",
1616
- tags: [],
1617
- updated: coerceWikiPageUpdated(p, undefined),
1618
- scope: getWikiPageScope(p),
1619
- }));
1620
- res.json([...indexedResults, ...orphanResults]);
1621
- });
1622
- app.get("/api/wiki/browser-pages", async (req, res) => {
1623
- ensureWikiStructure();
1624
- const filters = {
1625
- q: normalizeOptionalQueryParam(req.query.q),
1626
- type: normalizeOptionalQueryParam(req.query.type),
1627
- };
1628
- const db = getDb();
1629
- const { count } = db.prepare("SELECT COUNT(*) AS count FROM wiki_pages").get();
1630
- const pages = count > 0
1631
- ? listDbWikiBrowserPages(filters)
1632
- : listFallbackWikiBrowserPages(filters);
1633
- res.json({ pages });
1634
- });
1635
- app.get("/api/wiki/sources", async (req, res) => {
1636
- ensureWikiStructure();
1637
- res.json({ sources: listWikiSources(readOptionalPageFilter(req)) });
1638
- });
1639
- app.get("/api/wiki/links", async (req, res) => {
1640
- ensureWikiStructure();
1641
- res.json({ links: listWikiLinks(readOptionalPageFilter(req)) });
1642
- });
1643
- app.get("/api/wiki/page", async (req, res) => {
1644
- const slugParam = normalizeOptionalQueryParam(req.query.slug);
1645
- if (slugParam) {
1646
- const path = `pages/${slugParam}.md`;
1647
- assertValidPagePath(path);
1648
- const db = getDb();
1649
- const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
1650
- const authorizationHeader = typeof req.headers.authorization === "string"
1651
- ? req.headers.authorization
1652
- : undefined;
1653
- const rawContent = await readWikiPage(path, { authorizationHeader });
1654
- if (rawContent === undefined) {
1655
- throw new NotFoundError("Page not found");
1656
- }
1657
- const { parsed: frontmatter } = parseWikiFrontmatter(rawContent);
1658
- res.json({
1659
- page: {
1660
- slug: slugParam,
1661
- title: row?.title ?? slugParam,
1662
- type: row?.entity_type ?? "topics",
1663
- last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
1664
- compiled_truth: rawContent,
1665
- pinned: Boolean(row?.pinned),
1666
- frontmatter,
1667
- },
1668
- });
1669
- return;
1670
- }
1671
- const path = assertValidPagePath(readPathParam(req));
1672
- const authorizationHeader = typeof req.headers.authorization === "string"
1673
- ? req.headers.authorization
1674
- : undefined;
1675
- const content = await readWikiPage(path, { authorizationHeader });
1676
- if (content === undefined) {
1677
- if (path === "pages/index.md") {
1678
- res.json(createWikiPagePayload(path, getEmptyWikiWelcomeContent()));
129
+ const slug = typeof event.slug === "string" ? event.slug : undefined;
130
+ if (slug) {
131
+ broadcastSsePayloadToSession(`agent:${slug}`, event);
1679
132
  return;
1680
133
  }
1681
- throw new NotFoundError("Page not found");
1682
- }
1683
- res.json(createWikiPagePayload(path, content));
1684
- });
1685
- app.put("/api/wiki/page", async (req, res) => {
1686
- const path = assertValidPagePath(readPathParam(req));
1687
- const { content } = parseRequest(wikiWriteSchema, req.body);
1688
- const created = await withWikiWrite(() => {
1689
- const isCreated = !pageExists(path);
1690
- writePage(path, content);
1691
- return isCreated;
1692
- });
1693
- res.json({ ok: true, created, path });
1694
- });
1695
- const wikiUpdateSchema = z.object({
1696
- slug: requiredString("Missing 'slug' in request body"),
1697
- compiled_truth: z.string().optional(),
1698
- frontmatter: z.record(z.string(), z.unknown()).optional(),
1699
- });
1700
- app.post("/api/wiki/update", authMiddleware, async (req, res) => {
1701
- const { slug, compiled_truth, frontmatter: newFrontmatter } = parseRequest(wikiUpdateSchema, req.body);
1702
- const path = assertValidPagePath(`pages/${slug}.md`);
1703
- let finalContent = compiled_truth;
1704
- if (newFrontmatter !== undefined) {
1705
- const existing = readPage(path);
1706
- const base = finalContent ?? existing ?? "";
1707
- const { parsed: existingFrontmatter, body } = parseWikiFrontmatter(base);
1708
- const merged = { ...existingFrontmatter, ...newFrontmatter };
1709
- const fmLines = Object.entries(merged).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n");
1710
- finalContent = `---\n${fmLines}\n---\n${body}`;
1711
- }
1712
- if (finalContent !== undefined) {
1713
- await withWikiWrite(() => writePage(path, finalContent));
1714
- }
1715
- const db = getDb();
1716
- const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
1717
- const content = readPage(path) ?? finalContent ?? "";
1718
- const { parsed: frontmatter } = parseWikiFrontmatter(content);
1719
- res.json({
1720
- ok: true,
1721
- page: {
1722
- slug,
1723
- title: row?.title ?? slug,
1724
- type: row?.entity_type ?? "topics",
1725
- last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
1726
- compiled_truth: content,
1727
- pinned: Boolean(row?.pinned),
1728
- frontmatter,
1729
- },
1730
- });
1731
- });
1732
- const wikiPinSchema = z.object({
1733
- slug: requiredString("Missing 'slug' in request body"),
1734
- pinned: z.boolean({ error: "Missing 'pinned' in request body" }),
1735
- });
1736
- app.post("/api/wiki/page/pin", authMiddleware, async (req, res) => {
1737
- const { slug, pinned } = parseRequest(wikiPinSchema, req.body);
1738
- const path = assertValidPagePath(`pages/${slug}.md`);
1739
- const db = getDb();
1740
- db.prepare("UPDATE wiki_pages SET pinned = ? WHERE path = ?").run(pinned ? 1 : 0, path);
1741
- res.json({ ok: true, pinned: Boolean(pinned) });
1742
- });
1743
- app.delete("/api/wiki/page", async (req, res) => {
1744
- const path = assertValidPagePath(readPathParam(req));
1745
- const removed = await withWikiWrite(() => deletePage(path));
1746
- res.json({ ok: removed, path });
1747
- });
1748
- app.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
1749
- res.json({ sessions: listKorgResearchSessions(getDb()) });
1750
- });
1751
- const wikiIngestSchema = z.object({
1752
- source_url: z.string().optional(),
1753
- path: z.string().optional(),
1754
- topic: z.string().optional(),
1755
- });
1756
- app.post("/api/wiki/ingest", authMiddleware, async (req, res) => {
1757
- const { source_url, path: ingestPath, topic } = parseRequest(wikiIngestSchema, req.body ?? {});
1758
- const source = source_url ?? ingestPath;
1759
- if (!source) {
1760
- throw new BadRequestError("Missing 'source_url' or 'path' in request body");
1761
- }
1762
- const type = source_url ? "url" : "text";
1763
- await withWikiWrite(() => ingestSource(source, type, topic));
1764
- res.json({ ok: true });
1765
- });
1766
- const wikiSearchSchema = z.object({
1767
- q: z.string().optional(),
1768
- type: z.string().optional(),
1769
- });
1770
- app.get("/api/wiki/search", authMiddleware, async (req, res) => {
1771
- ensureWikiStructure();
1772
- const q = normalizeOptionalQueryParam(req.query.q);
1773
- const type = normalizeOptionalQueryParam(req.query.type);
1774
- const db = getDb();
1775
- let rows;
1776
- try {
1777
- const clauses = ["wiki_pages_fts MATCH ?"];
1778
- const params = [q ? `${q}*` : "*"];
1779
- if (type) {
1780
- clauses.push("entity_type = ?");
1781
- params.push(type);
1782
- }
1783
- rows = db.prepare(`
1784
- SELECT path, title, entity_type, last_updated
1785
- FROM wiki_pages_fts
1786
- WHERE ${clauses.join(" AND ")}
1787
- ORDER BY rank
1788
- LIMIT 50
1789
- `).all(...params);
1790
- }
1791
- catch {
1792
- const clauses = [];
1793
- const params = [];
1794
- if (q) {
1795
- clauses.push("title LIKE ? COLLATE NOCASE");
1796
- params.push(`%${q}%`);
1797
- }
1798
- if (type) {
1799
- clauses.push("entity_type = ?");
1800
- params.push(type);
1801
- }
1802
- rows = db.prepare(`
1803
- SELECT path, title, entity_type, last_updated
1804
- FROM wiki_pages
1805
- ${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
1806
- LIMIT 50
1807
- `).all(...params);
1808
- }
1809
- const pages = rows.map((row) => ({
1810
- slug: wikiPathToBrowserSlug(row.path),
1811
- title: row.title,
1812
- type: row.entity_type ?? "topics",
1813
- last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
1814
- }));
1815
- res.json({ pages });
1816
- });
1817
- // ---------------------------------------------------------------------------
1818
- // Skills
1819
- // ---------------------------------------------------------------------------
1820
- app.get("/api/skills", (_req, res) => {
1821
- res.json(listSkills());
1822
- });
1823
- app.delete("/api/skills/:slug", (req, res) => {
1824
- const slug = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
1825
- const result = removeSkill(slug);
1826
- if (!result.ok) {
1827
- throw new BadRequestError(result.message);
1828
- }
1829
- res.json({ ok: true, message: result.message });
1830
- });
1831
- // Restart daemon
1832
- app.post("/api/restart", (_req, res) => {
1833
- res.json({ status: "restarting" });
1834
- setTimeout(() => {
1835
- restartDaemon().catch((err) => {
1836
- log.error({ err: err instanceof Error ? err.message : err }, "Restart failed");
1837
- });
1838
- }, 500);
1839
- });
1840
- // ---------------------------------------------------------------------------
1841
- // Session messages — frontend rehydration on reload
1842
- // ---------------------------------------------------------------------------
1843
- app.get("/api/session/:sessionKey/messages", (req, res) => {
1844
- const sessionKey = Array.isArray(req.params.sessionKey)
1845
- ? req.params.sessionKey[0]
1846
- : req.params.sessionKey;
1847
- if (!sessionKey) {
1848
- throw new BadRequestError("Missing sessionKey");
1849
- }
1850
- const rawLimit = req.query.limit;
1851
- const limit = rawLimit !== undefined ? parseInt(String(rawLimit), 10) : undefined;
1852
- if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
1853
- throw new BadRequestError("'limit' must be a positive integer");
1854
- }
1855
- const includeHistorical = req.query.include === "all";
1856
- const messages = getSessionMessages(sessionKey, limit, { includeHistorical });
1857
- res.json({ sessionKey, messages });
134
+ broadcastSsePayload(event);
135
+ },
1858
136
  });
137
+ app.use(createSystemRouter({ apiToken }));
138
+ app.use(createAgentsRouter({ modeContext }));
139
+ app.use(createMemoryRouter({ authMiddleware }));
140
+ app.use(createProjectsRouter({ modeContext }));
141
+ app.use(createSessionsRouter());
142
+ app.use(createWikiRouter({ authMiddleware, modeContext }));
1859
143
  app.use(apiNotFoundHandler);
1860
- // ---------------------------------------------------------------------------
1861
- // Static SPA + fallback. Mounted last so API routes win.
1862
- // ---------------------------------------------------------------------------
1863
144
  if (existsSync(WEB_DIST_DIR)) {
1864
145
  app.use(express.static(WEB_DIST_DIR, { index: false, maxAge: "1h" }));
1865
- // SPA fallback for client-side routing. Everything not under /api/ or the
1866
- // public transport endpoints gets index.html.
1867
146
  app.use((req, res, next) => {
1868
147
  if (req.method !== "GET" || !shouldServeSpaPath(req.path)) {
1869
148
  next();
@@ -1901,14 +180,5 @@ export function startApiServer() {
1901
180
  });
1902
181
  });
1903
182
  }
1904
- /** Broadcast a proactive message to all connected SSE clients (for background task completions). */
1905
- export function broadcastToSSE(text) {
1906
- if (sseClients.size === 0) {
1907
- pendingSseMessages.push(text);
1908
- return;
1909
- }
1910
- for (const [, res] of sseClients) {
1911
- res.write(formatSseData({ type: "message", content: text }));
1912
- }
1913
- }
183
+ export { broadcastToSSE };
1914
184
  //# sourceMappingURL=server.js.map