chapterhouse 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/api/auth.js +11 -1
- package/dist/api/auth.test.js +29 -0
- package/dist/api/errors.js +23 -0
- package/dist/api/route-coverage.test.js +61 -21
- package/dist/api/routes/agents.js +472 -0
- package/dist/api/routes/memory.js +299 -0
- package/dist/api/routes/projects.js +170 -0
- package/dist/api/routes/sessions.js +347 -0
- package/dist/api/routes/system.js +82 -0
- package/dist/api/routes/wiki.js +455 -0
- package/dist/api/routes/wiki.test.js +49 -0
- package/dist/api/send-json.js +16 -0
- package/dist/api/send-json.test.js +18 -0
- package/dist/api/server-runtime.js +45 -3
- package/dist/api/server.js +34 -1764
- package/dist/api/server.test.js +239 -8
- package/dist/api/sse-hub.js +37 -0
- package/dist/cli.js +1 -1
- package/dist/config.js +151 -58
- package/dist/config.test.js +29 -0
- package/dist/copilot/okr-mapper.js +2 -11
- package/dist/copilot/orchestrator.js +358 -352
- package/dist/copilot/orchestrator.test.js +139 -4
- package/dist/copilot/prompt-date.js +2 -1
- package/dist/copilot/session-manager.js +25 -23
- package/dist/copilot/session-manager.test.js +35 -1
- package/dist/copilot/standup.js +2 -2
- package/dist/copilot/task-event-log.js +7 -1
- package/dist/copilot/task-event-log.test.js +13 -0
- package/dist/copilot/tools/agent.js +608 -0
- package/dist/copilot/tools/index.js +19 -0
- package/dist/copilot/tools/memory.js +678 -0
- package/dist/copilot/tools/models.js +2 -0
- package/dist/copilot/tools/okr.js +171 -0
- package/dist/copilot/tools/wiki.js +333 -0
- package/dist/copilot/tools-deps.js +4 -0
- package/dist/copilot/tools.agent.test.js +10 -8
- package/dist/copilot/tools.inventory.test.js +76 -0
- package/dist/copilot/tools.js +1 -1780
- package/dist/copilot/tools.okr.test.js +31 -0
- package/dist/copilot/tools.wiki.test.js +6 -3
- package/dist/copilot/turn-event-log.js +31 -4
- package/dist/copilot/turn-event-log.test.js +24 -2
- package/dist/copilot/workiq-installer.test.js +2 -2
- package/dist/daemon-install.js +3 -2
- package/dist/daemon.js +9 -17
- package/dist/integrations/ado-client.js +90 -9
- package/dist/integrations/ado-client.test.js +56 -0
- package/dist/integrations/team-push.js +1 -0
- package/dist/integrations/team-push.test.js +6 -0
- package/dist/integrations/teams-notify.js +1 -0
- package/dist/integrations/teams-notify.test.js +5 -0
- package/dist/memory/active-scope.test.js +0 -1
- package/dist/memory/checkpoint.js +89 -72
- package/dist/memory/checkpoint.test.js +23 -3
- package/dist/memory/eot.js +87 -85
- package/dist/memory/eot.test.js +71 -3
- package/dist/memory/hooks.js +2 -4
- package/dist/memory/housekeeping-scheduler.js +1 -1
- package/dist/memory/housekeeping-scheduler.test.js +1 -2
- package/dist/memory/housekeeping.js +100 -3
- package/dist/memory/housekeeping.test.js +33 -2
- package/dist/memory/reflect.test.js +2 -0
- package/dist/memory/scope-lock.js +26 -0
- package/dist/memory/scope-lock.test.js +118 -0
- package/dist/memory/scopes.test.js +0 -1
- package/dist/mode-context.js +58 -5
- package/dist/mode-context.test.js +68 -0
- package/dist/paths.js +1 -0
- package/dist/setup.js +3 -2
- package/dist/shared/api-schemas.js +48 -5
- package/dist/store/connection.js +96 -0
- package/dist/store/db.js +5 -1498
- package/dist/store/db.test.js +182 -1
- package/dist/store/migrations.js +460 -0
- package/dist/store/repositories/memory.js +281 -0
- package/dist/store/repositories/okr.js +3 -0
- package/dist/store/repositories/projects.js +5 -0
- package/dist/store/repositories/sessions.js +284 -0
- package/dist/store/repositories/wiki.js +60 -0
- package/dist/store/schema.js +501 -0
- package/dist/util/logger.js +3 -2
- package/dist/wiki/consolidation.js +50 -9
- package/dist/wiki/consolidation.test.js +45 -0
- package/dist/wiki/frontmatter.js +43 -13
- package/dist/wiki/frontmatter.test.js +24 -0
- package/dist/wiki/fs.js +16 -4
- package/dist/wiki/fs.test.js +84 -0
- package/dist/wiki/index-manager.js +30 -2
- package/dist/wiki/index-manager.test.js +43 -12
- package/dist/wiki/ingest.js +1 -1
- package/dist/wiki/lock.js +11 -1
- package/dist/wiki/log-manager.js +2 -7
- package/dist/wiki/migrate.js +44 -17
- package/dist/wiki/project-registry.js +10 -5
- package/dist/wiki/project-registry.test.js +14 -0
- package/dist/wiki/scheduler.js +1 -1
- package/dist/wiki/seed-team-wiki.js +2 -1
- package/dist/wiki/team-sync.js +31 -6
- package/dist/wiki/team-sync.test.js +81 -0
- package/package.json +1 -1
- package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
- package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
- package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
- package/web/dist/assets/index-CUm2Wbuh.js +250 -0
- package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-iQrv3lQN.js +0 -286
- package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
package/dist/api/server.js
CHANGED
|
@@ -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
|
|
5
|
-
import { join, dirname
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { join, dirname } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
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 =
|
|
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 (
|
|
360
|
-
log.warn(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|