chapterhouse 0.8.2 → 0.9.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/dist/api/korg.js +5 -5
- package/dist/api/korg.test.js +3 -3
- package/dist/api/route-coverage.test.js +225 -0
- package/dist/api/server.js +231 -5
- package/dist/api/server.test.js +88 -2
- package/dist/shared/api-schemas.js +618 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BbX9RKf3.js → index-tBfBbEk5.js} +156 -93
- package/web/dist/assets/index-tBfBbEk5.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BbX9RKf3.js.map +0 -1
package/dist/api/korg.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { sendToAgentSession } from "../copilot/orchestrator.js";
|
|
2
2
|
export async function routeKorgMessage(input) {
|
|
3
3
|
const message = input.message.trim();
|
|
4
|
-
const sessionId = input.
|
|
5
|
-
const sessionName = input.
|
|
4
|
+
const sessionId = input.sessionKey?.trim() || `korg-${Date.now()}`;
|
|
5
|
+
const sessionName = input.sessionKey?.trim() || message.slice(0, 80).trim() || sessionId;
|
|
6
6
|
const prompt = [
|
|
7
7
|
"Handle this request as Korg via the API.",
|
|
8
8
|
`Research session id: ${sessionId}`,
|
|
@@ -13,7 +13,7 @@ export async function routeKorgMessage(input) {
|
|
|
13
13
|
message,
|
|
14
14
|
].join("\n");
|
|
15
15
|
const reply = await sendToAgentSession("korg", prompt);
|
|
16
|
-
return { ok: true,
|
|
16
|
+
return { ok: true, sessionKey: sessionId, reply };
|
|
17
17
|
}
|
|
18
18
|
export function listKorgResearchSessions(db) {
|
|
19
19
|
return db.prepare(`
|
|
@@ -21,8 +21,8 @@ export function listKorgResearchSessions(db) {
|
|
|
21
21
|
session_id AS id,
|
|
22
22
|
COALESCE(NULLIF(TRIM(session_name), ''), session_id) AS name,
|
|
23
23
|
COUNT(*) AS source_count,
|
|
24
|
-
0 AS
|
|
25
|
-
MAX(ingested_at) AS
|
|
24
|
+
0 AS open_questions_count,
|
|
25
|
+
MAX(ingested_at) AS last_activity_at
|
|
26
26
|
FROM wiki_sources
|
|
27
27
|
WHERE status = 'active'
|
|
28
28
|
AND session_id IS NOT NULL
|
package/dist/api/korg.test.js
CHANGED
|
@@ -13,7 +13,7 @@ test("routeKorgMessage creates a research session id and delegates to the persis
|
|
|
13
13
|
const mod = await import(new URL(`./korg.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
14
14
|
const result = await mod.routeKorgMessage({ message: "Research local-first PKB patterns." });
|
|
15
15
|
assert.equal(result.ok, true);
|
|
16
|
-
assert.match(result.
|
|
16
|
+
assert.match(result.sessionKey, /^korg-/);
|
|
17
17
|
assert.equal(result.reply, "Korg reply");
|
|
18
18
|
assert.deepEqual(calls, [{
|
|
19
19
|
slug: "korg",
|
|
@@ -33,8 +33,8 @@ test("routeKorgMessage preserves an existing research session id in the delegate
|
|
|
33
33
|
},
|
|
34
34
|
});
|
|
35
35
|
const mod = await import(new URL(`./korg.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
36
|
-
const result = await mod.routeKorgMessage({ message: "Add two more sources.",
|
|
37
|
-
assert.equal(result.
|
|
36
|
+
const result = await mod.routeKorgMessage({ message: "Add two more sources.", sessionKey: "compiler-research" });
|
|
37
|
+
assert.equal(result.sessionKey, "compiler-research");
|
|
38
38
|
assert.equal(result.reply, "Continuing session");
|
|
39
39
|
assert.match(prompt, /compiler-research/);
|
|
40
40
|
assert.match(prompt, /Add two more sources\./);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Route & SSE coverage — static analysis CI safeguard (P0 remediation, issue #369)
|
|
2
|
+
//
|
|
3
|
+
// Coverage status as of 2026-05-15:
|
|
4
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// GET /api/wiki/pages — tested (server.test.ts: coerced updated, frontmatter)
|
|
6
|
+
// GET /api/wiki/browser-pages — tested (server.test.ts: browser contract, orphan fallback)
|
|
7
|
+
// GET /api/wiki/sources — tested (server.test.ts: empty payload, page filter)
|
|
8
|
+
// GET /api/wiki/links — tested (server.test.ts: all links, empty, page filter)
|
|
9
|
+
// GET /api/wiki/page — tested (server.test.ts: CRUD, welcome page synthesis, 404)
|
|
10
|
+
// PUT /api/wiki/page — tested (server.test.ts: wiki CRUD create flow)
|
|
11
|
+
// DELETE /api/wiki/page — tested (server.test.ts: wiki CRUD delete flow)
|
|
12
|
+
// GET /api/wiki/search — NOT tested (add in Phase 3c)
|
|
13
|
+
// POST /api/wiki/update — static auth guard tested below
|
|
14
|
+
// POST /api/wiki/page/pin — auth + traversal guard tested
|
|
15
|
+
// POST /api/wiki/ingest — NOT tested (add in Phase 3c)
|
|
16
|
+
// GET /api/wiki/korg/sessions — NOT tested (add in Phase 3c)
|
|
17
|
+
// POST /api/wiki/korg — NOT tested (add in Phase 3c)
|
|
18
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
import { describe, test } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
25
|
+
const ROOT = join(__dirname, "..", "..");
|
|
26
|
+
const FRONTEND_API_PATH = join(ROOT, "web", "src", "api.ts");
|
|
27
|
+
const SERVER_TS_PATH = join(ROOT, "src", "api", "server.ts");
|
|
28
|
+
const WEB_SCHEMAS_PATH = join(ROOT, "src", "shared", "api-schemas.ts");
|
|
29
|
+
/**
|
|
30
|
+
* Normalise a URL path for comparison:
|
|
31
|
+
* - Replace complete template-literal expressions ${...} with :param
|
|
32
|
+
* - Strip incomplete trailing template expressions (e.g. `${query ` cut by `?`)
|
|
33
|
+
* - Replace Express named params :slug with :param
|
|
34
|
+
* - Strip trailing slashes
|
|
35
|
+
*/
|
|
36
|
+
function normalizePath(raw) {
|
|
37
|
+
return raw
|
|
38
|
+
.replace(/\$\{[^}]*\}/g, ":param") // complete ${...} → :param
|
|
39
|
+
.replace(/\$\{[^}]*$/, "") // trailing incomplete ${... (cut by `?` in regex)
|
|
40
|
+
.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, ":param") // :slug → :param
|
|
41
|
+
.replace(/\/+$/, "") // strip trailing slash
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Extract all unique normalised API paths called via authedFetch() in the
|
|
46
|
+
* frontend api.ts. Only static string / template-literal arguments are
|
|
47
|
+
* detected — calls that pass a pre-constructed variable are skipped.
|
|
48
|
+
*/
|
|
49
|
+
function extractFrontendPaths(src) {
|
|
50
|
+
// Matches authedFetch( then an opening quote/backtick then /api/...
|
|
51
|
+
// Stops at the same quote character, or at `?` (query string start).
|
|
52
|
+
const re = /authedFetch\(["'`](\/api\/[^"'`?]+)/g;
|
|
53
|
+
const paths = new Set();
|
|
54
|
+
for (const m of src.matchAll(re)) {
|
|
55
|
+
const normalised = normalizePath(m[1]);
|
|
56
|
+
if (normalised) {
|
|
57
|
+
paths.add(normalised);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return paths;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extract all unique normalised API paths registered via app.METHOD() in
|
|
64
|
+
* server.ts. Only /api/ prefixed paths are considered.
|
|
65
|
+
*/
|
|
66
|
+
function extractServerPaths(src) {
|
|
67
|
+
const re = /app\.(get|post|put|delete|patch)\(["'`](\/api\/[^"'`?]+)/g;
|
|
68
|
+
const paths = new Set();
|
|
69
|
+
for (const m of src.matchAll(re)) {
|
|
70
|
+
const normalised = normalizePath(m[2]);
|
|
71
|
+
if (normalised) {
|
|
72
|
+
paths.add(normalised);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return paths;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Extract SSE type literals emitted inline via formatSseData({ type: "X", ... })
|
|
79
|
+
* in server.ts. Pass-through calls like formatSseData(event) are not detected.
|
|
80
|
+
*/
|
|
81
|
+
function extractSseDataTypes(src) {
|
|
82
|
+
// Allow up to ~200 chars between `formatSseData({` and `type: "X"` to
|
|
83
|
+
// handle multi-line object literals (queued, queue-advance, etc.).
|
|
84
|
+
const re = /formatSseData\(\{[\s\S]{0,200}?type:\s*["']([^"']+)["']/g;
|
|
85
|
+
const types = new Set();
|
|
86
|
+
for (const m of src.matchAll(re)) {
|
|
87
|
+
types.add(m[1]);
|
|
88
|
+
}
|
|
89
|
+
return types;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Extract the first argument (event name) from all formatSseEvent("name", ...)
|
|
93
|
+
* calls in server.ts.
|
|
94
|
+
*/
|
|
95
|
+
function extractSseEventNames(src) {
|
|
96
|
+
const re = /formatSseEvent\(["']([^"']+)["']/g;
|
|
97
|
+
const names = new Set();
|
|
98
|
+
for (const m of src.matchAll(re)) {
|
|
99
|
+
names.add(m[1]);
|
|
100
|
+
}
|
|
101
|
+
return names;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Extract the `type` literal values from StreamEventSchema in web/src/api-schemas.ts
|
|
105
|
+
* by scanning for `type: z.literal("X")` patterns.
|
|
106
|
+
*
|
|
107
|
+
* These are the only valid SSE frame types the frontend will parse via the
|
|
108
|
+
* default `message` EventSource handler.
|
|
109
|
+
*/
|
|
110
|
+
function extractStreamEventSchemaTypes(src) {
|
|
111
|
+
// Locate the StreamEventSchema block first so we don't accidentally pick up
|
|
112
|
+
// literals from other schemas.
|
|
113
|
+
const startIdx = src.indexOf("export const StreamEventSchema");
|
|
114
|
+
if (startIdx === -1) {
|
|
115
|
+
throw new Error("StreamEventSchema not found in src/shared/api-schemas.ts");
|
|
116
|
+
}
|
|
117
|
+
// Take a generous slice (10 000 chars) — the schema is large but finite.
|
|
118
|
+
const block = src.slice(startIdx, startIdx + 10_000);
|
|
119
|
+
const re = /type:\s*z\.literal\(["']([^"']+)["']\)/g;
|
|
120
|
+
const types = new Set();
|
|
121
|
+
for (const m of block.matchAll(re)) {
|
|
122
|
+
types.add(m[1]);
|
|
123
|
+
}
|
|
124
|
+
return types;
|
|
125
|
+
}
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// Tests
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
describe("route coverage — static analysis", () => {
|
|
130
|
+
test("all frontend authedFetch paths have a matching server route registration", () => {
|
|
131
|
+
const frontendSrc = readFileSync(FRONTEND_API_PATH, "utf8");
|
|
132
|
+
const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
|
|
133
|
+
const frontendPaths = extractFrontendPaths(frontendSrc);
|
|
134
|
+
const serverPaths = extractServerPaths(serverSrc);
|
|
135
|
+
const missing = [];
|
|
136
|
+
for (const fp of [...frontendPaths].sort()) {
|
|
137
|
+
if (!serverPaths.has(fp)) {
|
|
138
|
+
missing.push(fp);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
assert.ok(missing.length === 0, `Frontend calls ${missing.length} route(s) with no matching server registration:\n` +
|
|
142
|
+
missing.map((p) => ` MISSING: ${p}`).join("\n") +
|
|
143
|
+
"\n\nAdd the missing route(s) to src/api/server.ts");
|
|
144
|
+
});
|
|
145
|
+
test("server routes cover every unique normalised path (no obvious duplicates leaked)", () => {
|
|
146
|
+
const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
|
|
147
|
+
const serverPaths = extractServerPaths(serverSrc);
|
|
148
|
+
// Sanity: the server must expose at least 30 /api/ routes.
|
|
149
|
+
assert.ok(serverPaths.size >= 30, `Expected ≥30 server routes, found ${serverPaths.size}. Check extractServerPaths regex.`);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe("wiki write route safeguards — static analysis", () => {
|
|
153
|
+
test("wiki update and pin routes declare authMiddleware explicitly", () => {
|
|
154
|
+
const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
|
|
155
|
+
assert.match(serverSrc, /app\.post\("\/api\/wiki\/update",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
|
|
156
|
+
assert.match(serverSrc, /app\.post\("\/api\/wiki\/page\/pin",\s*authMiddleware,\s*async \(req: Request, res: Response\) => \{/);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe("SSE exhaustiveness — static analysis", () => {
|
|
160
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
161
|
+
// REGRESSION CANARY for issues #367 / #368:
|
|
162
|
+
//
|
|
163
|
+
// `formatSseEvent("status", payload)` generates an SSE *named event*:
|
|
164
|
+
// event: status\ndata: {...}\n\n
|
|
165
|
+
//
|
|
166
|
+
// The frontend's EventSource only handles the default `message` event.
|
|
167
|
+
// Named events are silently dropped — the frontend never sees them.
|
|
168
|
+
//
|
|
169
|
+
// If any future code adds `formatSseEvent("X", ...)` where "X" is also a
|
|
170
|
+
// type in StreamEventSchema, this test FAILS, preventing the regression.
|
|
171
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
172
|
+
test("formatSseEvent is not used with names that belong in the StreamEventSchema data channel", () => {
|
|
173
|
+
const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
|
|
174
|
+
const schemaSrc = readFileSync(WEB_SCHEMAS_PATH, "utf8");
|
|
175
|
+
const namedEventNames = extractSseEventNames(serverSrc);
|
|
176
|
+
const schemaTypes = extractStreamEventSchemaTypes(schemaSrc);
|
|
177
|
+
const conflicts = [];
|
|
178
|
+
for (const name of namedEventNames) {
|
|
179
|
+
if (schemaTypes.has(name)) {
|
|
180
|
+
conflicts.push(name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
assert.ok(conflicts.length === 0, `formatSseEvent() is being used with name(s) that are also StreamEventSchema types:\n` +
|
|
184
|
+
conflicts.map((n) => ` CONFLICT: formatSseEvent("${n}", ...) — must use formatSseData({ type: "${n}", ... }) instead`).join("\n") +
|
|
185
|
+
"\n\nNamed SSE events are silently dropped by the frontend's default 'message' handler (issues #367/#368).");
|
|
186
|
+
});
|
|
187
|
+
test("all inline formatSseData type literals are known StreamEventSchema types or agent-stream-only types", () => {
|
|
188
|
+
const serverSrc = readFileSync(SERVER_TS_PATH, "utf8");
|
|
189
|
+
const schemaSrc = readFileSync(WEB_SCHEMAS_PATH, "utf8");
|
|
190
|
+
const emittedTypes = extractSseDataTypes(serverSrc);
|
|
191
|
+
const schemaTypes = extractStreamEventSchemaTypes(schemaSrc);
|
|
192
|
+
// Types emitted only on /api/agents/stream — not part of the main chat
|
|
193
|
+
// StreamEventSchema but intentional (received by a separate subscriber).
|
|
194
|
+
const agentStreamOnlyTypes = new Set(["agent_event"]);
|
|
195
|
+
const unknown = [];
|
|
196
|
+
for (const t of [...emittedTypes].sort()) {
|
|
197
|
+
if (!schemaTypes.has(t) && !agentStreamOnlyTypes.has(t)) {
|
|
198
|
+
unknown.push(t);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
assert.ok(unknown.length === 0, `formatSseData emits type(s) not present in StreamEventSchema:\n` +
|
|
202
|
+
unknown.map((t) => ` UNKNOWN: "${t}"`).join("\n") +
|
|
203
|
+
"\n\nEither add these to StreamEventSchema in src/shared/api-schemas.ts, " +
|
|
204
|
+
"or add them to the agentStreamOnlyTypes allowlist in this test.");
|
|
205
|
+
});
|
|
206
|
+
test("StreamEventSchema covers at least the core chat stream types", () => {
|
|
207
|
+
const schemaSrc = readFileSync(WEB_SCHEMAS_PATH, "utf8");
|
|
208
|
+
const schemaTypes = extractStreamEventSchemaTypes(schemaSrc);
|
|
209
|
+
const required = [
|
|
210
|
+
"connected",
|
|
211
|
+
"delta",
|
|
212
|
+
"message",
|
|
213
|
+
"cancelled",
|
|
214
|
+
"status",
|
|
215
|
+
"queued",
|
|
216
|
+
"activity",
|
|
217
|
+
"queue-advance",
|
|
218
|
+
"turn-interrupted",
|
|
219
|
+
];
|
|
220
|
+
const missing = required.filter((t) => !schemaTypes.has(t));
|
|
221
|
+
assert.ok(missing.length === 0, `StreamEventSchema is missing expected type(s): ${missing.join(", ")}\n` +
|
|
222
|
+
"These types were removed or renamed — update the schema and this test together.");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
//# sourceMappingURL=route-coverage.test.js.map
|
package/dist/api/server.js
CHANGED
|
@@ -16,7 +16,7 @@ import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
|
|
|
16
16
|
import { assertAgentEditAccess } from "./agent-edit-access.js";
|
|
17
17
|
import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
|
|
18
18
|
import { createTeamRouter } from "./team.js";
|
|
19
|
-
import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, getWikiDir, } from "../wiki/fs.js";
|
|
19
|
+
import { readPage, writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, getWikiDir, } from "../wiki/fs.js";
|
|
20
20
|
import { parseWikiFrontmatter } from "../wiki/frontmatter.js";
|
|
21
21
|
import { normalizeWikiPath } from "../wiki/path-utils.js";
|
|
22
22
|
import { loadRegistry, saveRegistry } from "../wiki/project-registry.js";
|
|
@@ -30,7 +30,7 @@ import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../st
|
|
|
30
30
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
31
31
|
import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
32
32
|
import { getStatus, onStatusChange } from "../status.js";
|
|
33
|
-
import { formatSseData
|
|
33
|
+
import { formatSseData } from "./sse.js";
|
|
34
34
|
import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
|
|
35
35
|
import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
|
|
36
36
|
import { childLogger } from "../util/logger.js";
|
|
@@ -41,6 +41,7 @@ import { recordObservation } from "../memory/observations.js";
|
|
|
41
41
|
import { recordDecision } from "../memory/decisions.js";
|
|
42
42
|
import { upsertEntity } from "../memory/entities.js";
|
|
43
43
|
import { getInboxItem, listPendingInboxItems, resolveInboxItem } from "../memory/inbox.js";
|
|
44
|
+
import { ingestSource } from "../wiki/ingest.js";
|
|
44
45
|
import { listKorgResearchSessions, routeKorgMessage } from "./korg.js";
|
|
45
46
|
const log = childLogger("server");
|
|
46
47
|
const modeContext = new ModeContext(config);
|
|
@@ -147,7 +148,7 @@ const prMergeHookSchema = z.object({
|
|
|
147
148
|
});
|
|
148
149
|
const korgRequestSchema = z.object({
|
|
149
150
|
message: requiredString("Missing 'message' in request body"),
|
|
150
|
-
|
|
151
|
+
sessionKey: z.string().trim().min(1).optional(),
|
|
151
152
|
}).strict();
|
|
152
153
|
const projectHardRulesSchema = z.object({
|
|
153
154
|
hardRules: z.object({
|
|
@@ -205,6 +206,77 @@ function coerceWikiPageUpdated(path, updated) {
|
|
|
205
206
|
return "unknown";
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
function wikiPathToBrowserSlug(path) {
|
|
210
|
+
return path.replace(/^pages\//, "").replace(/\.md$/, "");
|
|
211
|
+
}
|
|
212
|
+
function normalizeOptionalQueryParam(value) {
|
|
213
|
+
if (typeof value !== "string") {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const trimmed = value.trim();
|
|
217
|
+
return trimmed ? trimmed : undefined;
|
|
218
|
+
}
|
|
219
|
+
function matchesWikiBrowserFilters(page, filters) {
|
|
220
|
+
if (filters.type && page.type !== filters.type) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
if (!filters.q) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
const query = filters.q.toLowerCase();
|
|
227
|
+
return page.title.toLowerCase().includes(query) || page.summary.toLowerCase().includes(query);
|
|
228
|
+
}
|
|
229
|
+
function mapIndexEntryToBrowserPage(entry) {
|
|
230
|
+
return {
|
|
231
|
+
slug: wikiPathToBrowserSlug(entry.path),
|
|
232
|
+
title: entry.title,
|
|
233
|
+
summary: entry.summary,
|
|
234
|
+
type: entry.section,
|
|
235
|
+
last_updated: coerceWikiPageUpdated(entry.path, entry.updated),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function listFallbackWikiBrowserPages(filters) {
|
|
239
|
+
const entries = parseIndex();
|
|
240
|
+
const indexed = new Set(entries.map((entry) => entry.path));
|
|
241
|
+
const indexedResults = entries.map(mapIndexEntryToBrowserPage);
|
|
242
|
+
const orphanResults = listPages()
|
|
243
|
+
.filter((path) => !indexed.has(path))
|
|
244
|
+
.map((path) => ({
|
|
245
|
+
slug: wikiPathToBrowserSlug(path),
|
|
246
|
+
title: path,
|
|
247
|
+
summary: "",
|
|
248
|
+
type: "Unindexed",
|
|
249
|
+
last_updated: coerceWikiPageUpdated(path, undefined),
|
|
250
|
+
}));
|
|
251
|
+
return [...indexedResults, ...orphanResults].filter((page) => matchesWikiBrowserFilters(page, filters));
|
|
252
|
+
}
|
|
253
|
+
function listDbWikiBrowserPages(filters) {
|
|
254
|
+
const db = getDb();
|
|
255
|
+
const clauses = [];
|
|
256
|
+
const params = [];
|
|
257
|
+
if (filters.type) {
|
|
258
|
+
clauses.push("entity_type = ?");
|
|
259
|
+
params.push(filters.type);
|
|
260
|
+
}
|
|
261
|
+
if (filters.q) {
|
|
262
|
+
clauses.push("(title LIKE ? COLLATE NOCASE OR COALESCE(summary, '') LIKE ? COLLATE NOCASE)");
|
|
263
|
+
params.push(`%${filters.q}%`, `%${filters.q}%`);
|
|
264
|
+
}
|
|
265
|
+
const rows = db.prepare(`
|
|
266
|
+
SELECT path, title, summary, entity_type, last_updated, pinned
|
|
267
|
+
FROM wiki_pages
|
|
268
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
269
|
+
ORDER BY COALESCE(last_updated, '') DESC, title ASC
|
|
270
|
+
`).all(...params);
|
|
271
|
+
return rows.map((row) => ({
|
|
272
|
+
slug: wikiPathToBrowserSlug(row.path),
|
|
273
|
+
title: row.title,
|
|
274
|
+
summary: row.summary ?? "",
|
|
275
|
+
type: row.entity_type ?? "topics",
|
|
276
|
+
last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
|
|
277
|
+
...(row.pinned ? { pinned: true } : {}),
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
208
280
|
function parseWikiSourcePages(value) {
|
|
209
281
|
if (!value) {
|
|
210
282
|
return [];
|
|
@@ -838,11 +910,11 @@ app.get("/stream", (req, res) => {
|
|
|
838
910
|
}
|
|
839
911
|
sseClients.set(connectionId, res);
|
|
840
912
|
const unsubscribeStatus = onStatusChange((status, message) => {
|
|
841
|
-
res.write(
|
|
913
|
+
res.write(formatSseData({ type: "status", status, message }));
|
|
842
914
|
});
|
|
843
915
|
const currentStatus = getStatus();
|
|
844
916
|
if (currentStatus.status !== "idle") {
|
|
845
|
-
res.write(
|
|
917
|
+
res.write(formatSseData({ type: "status", ...currentStatus }));
|
|
846
918
|
}
|
|
847
919
|
const heartbeat = setInterval(() => {
|
|
848
920
|
res.write(`:ping\n\n`);
|
|
@@ -1544,6 +1616,19 @@ app.get("/api/wiki/pages", async (req, res) => {
|
|
|
1544
1616
|
}));
|
|
1545
1617
|
res.json([...indexedResults, ...orphanResults]);
|
|
1546
1618
|
});
|
|
1619
|
+
app.get("/api/wiki/browser-pages", async (req, res) => {
|
|
1620
|
+
ensureWikiStructure();
|
|
1621
|
+
const filters = {
|
|
1622
|
+
q: normalizeOptionalQueryParam(req.query.q),
|
|
1623
|
+
type: normalizeOptionalQueryParam(req.query.type),
|
|
1624
|
+
};
|
|
1625
|
+
const db = getDb();
|
|
1626
|
+
const { count } = db.prepare("SELECT COUNT(*) AS count FROM wiki_pages").get();
|
|
1627
|
+
const pages = count > 0
|
|
1628
|
+
? listDbWikiBrowserPages(filters)
|
|
1629
|
+
: listFallbackWikiBrowserPages(filters);
|
|
1630
|
+
res.json({ pages });
|
|
1631
|
+
});
|
|
1547
1632
|
app.get("/api/wiki/sources", async (req, res) => {
|
|
1548
1633
|
ensureWikiStructure();
|
|
1549
1634
|
res.json({ sources: listWikiSources(readOptionalPageFilter(req)) });
|
|
@@ -1553,6 +1638,33 @@ app.get("/api/wiki/links", async (req, res) => {
|
|
|
1553
1638
|
res.json({ links: listWikiLinks(readOptionalPageFilter(req)) });
|
|
1554
1639
|
});
|
|
1555
1640
|
app.get("/api/wiki/page", async (req, res) => {
|
|
1641
|
+
const slugParam = normalizeOptionalQueryParam(req.query.slug);
|
|
1642
|
+
if (slugParam) {
|
|
1643
|
+
const path = `pages/${slugParam}.md`;
|
|
1644
|
+
assertValidPagePath(path);
|
|
1645
|
+
const db = getDb();
|
|
1646
|
+
const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
|
|
1647
|
+
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
1648
|
+
? req.headers.authorization
|
|
1649
|
+
: undefined;
|
|
1650
|
+
const rawContent = await readWikiPage(path, { authorizationHeader });
|
|
1651
|
+
if (rawContent === undefined) {
|
|
1652
|
+
throw new NotFoundError("Page not found");
|
|
1653
|
+
}
|
|
1654
|
+
const { parsed: frontmatter } = parseWikiFrontmatter(rawContent);
|
|
1655
|
+
res.json({
|
|
1656
|
+
page: {
|
|
1657
|
+
slug: slugParam,
|
|
1658
|
+
title: row?.title ?? slugParam,
|
|
1659
|
+
type: row?.entity_type ?? "topics",
|
|
1660
|
+
last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
|
|
1661
|
+
compiled_truth: rawContent,
|
|
1662
|
+
pinned: Boolean(row?.pinned),
|
|
1663
|
+
frontmatter,
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1556
1668
|
const path = assertValidPagePath(readPathParam(req));
|
|
1557
1669
|
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
1558
1670
|
? req.headers.authorization
|
|
@@ -1577,6 +1689,54 @@ app.put("/api/wiki/page", async (req, res) => {
|
|
|
1577
1689
|
});
|
|
1578
1690
|
res.json({ ok: true, created, path });
|
|
1579
1691
|
});
|
|
1692
|
+
const wikiUpdateSchema = z.object({
|
|
1693
|
+
slug: requiredString("Missing 'slug' in request body"),
|
|
1694
|
+
compiled_truth: z.string().optional(),
|
|
1695
|
+
frontmatter: z.record(z.string(), z.unknown()).optional(),
|
|
1696
|
+
});
|
|
1697
|
+
app.post("/api/wiki/update", authMiddleware, async (req, res) => {
|
|
1698
|
+
const { slug, compiled_truth, frontmatter: newFrontmatter } = parseRequest(wikiUpdateSchema, req.body);
|
|
1699
|
+
const path = assertValidPagePath(`pages/${slug}.md`);
|
|
1700
|
+
let finalContent = compiled_truth;
|
|
1701
|
+
if (newFrontmatter !== undefined) {
|
|
1702
|
+
const existing = readPage(path);
|
|
1703
|
+
const base = finalContent ?? existing ?? "";
|
|
1704
|
+
const { parsed: existingFrontmatter, body } = parseWikiFrontmatter(base);
|
|
1705
|
+
const merged = { ...existingFrontmatter, ...newFrontmatter };
|
|
1706
|
+
const fmLines = Object.entries(merged).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n");
|
|
1707
|
+
finalContent = `---\n${fmLines}\n---\n${body}`;
|
|
1708
|
+
}
|
|
1709
|
+
if (finalContent !== undefined) {
|
|
1710
|
+
await withWikiWrite(() => writePage(path, finalContent));
|
|
1711
|
+
}
|
|
1712
|
+
const db = getDb();
|
|
1713
|
+
const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
|
|
1714
|
+
const content = readPage(path) ?? finalContent ?? "";
|
|
1715
|
+
const { parsed: frontmatter } = parseWikiFrontmatter(content);
|
|
1716
|
+
res.json({
|
|
1717
|
+
ok: true,
|
|
1718
|
+
page: {
|
|
1719
|
+
slug,
|
|
1720
|
+
title: row?.title ?? slug,
|
|
1721
|
+
type: row?.entity_type ?? "topics",
|
|
1722
|
+
last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
|
|
1723
|
+
compiled_truth: content,
|
|
1724
|
+
pinned: Boolean(row?.pinned),
|
|
1725
|
+
frontmatter,
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
const wikiPinSchema = z.object({
|
|
1730
|
+
slug: requiredString("Missing 'slug' in request body"),
|
|
1731
|
+
pinned: z.boolean({ error: "Missing 'pinned' in request body" }),
|
|
1732
|
+
});
|
|
1733
|
+
app.post("/api/wiki/page/pin", authMiddleware, async (req, res) => {
|
|
1734
|
+
const { slug, pinned } = parseRequest(wikiPinSchema, req.body);
|
|
1735
|
+
const path = assertValidPagePath(`pages/${slug}.md`);
|
|
1736
|
+
const db = getDb();
|
|
1737
|
+
db.prepare("UPDATE wiki_pages SET pinned = ? WHERE path = ?").run(pinned ? 1 : 0, path);
|
|
1738
|
+
res.json({ ok: true, pinned: Boolean(pinned) });
|
|
1739
|
+
});
|
|
1580
1740
|
app.delete("/api/wiki/page", async (req, res) => {
|
|
1581
1741
|
const path = assertValidPagePath(readPathParam(req));
|
|
1582
1742
|
const removed = await withWikiWrite(() => deletePage(path));
|
|
@@ -1590,6 +1750,72 @@ app.post("/api/wiki/korg", authMiddleware, async (req, res) => {
|
|
|
1590
1750
|
app.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
|
|
1591
1751
|
res.json({ sessions: listKorgResearchSessions(getDb()) });
|
|
1592
1752
|
});
|
|
1753
|
+
const wikiIngestSchema = z.object({
|
|
1754
|
+
source_url: z.string().optional(),
|
|
1755
|
+
path: z.string().optional(),
|
|
1756
|
+
topic: z.string().optional(),
|
|
1757
|
+
});
|
|
1758
|
+
app.post("/api/wiki/ingest", authMiddleware, async (req, res) => {
|
|
1759
|
+
const { source_url, path: ingestPath, topic } = parseRequest(wikiIngestSchema, req.body ?? {});
|
|
1760
|
+
const source = source_url ?? ingestPath;
|
|
1761
|
+
if (!source) {
|
|
1762
|
+
throw new BadRequestError("Missing 'source_url' or 'path' in request body");
|
|
1763
|
+
}
|
|
1764
|
+
const type = source_url ? "url" : "text";
|
|
1765
|
+
await withWikiWrite(() => ingestSource(source, type, topic));
|
|
1766
|
+
res.json({ ok: true });
|
|
1767
|
+
});
|
|
1768
|
+
const wikiSearchSchema = z.object({
|
|
1769
|
+
q: z.string().optional(),
|
|
1770
|
+
type: z.string().optional(),
|
|
1771
|
+
});
|
|
1772
|
+
app.get("/api/wiki/search", authMiddleware, async (req, res) => {
|
|
1773
|
+
ensureWikiStructure();
|
|
1774
|
+
const q = normalizeOptionalQueryParam(req.query.q);
|
|
1775
|
+
const type = normalizeOptionalQueryParam(req.query.type);
|
|
1776
|
+
const db = getDb();
|
|
1777
|
+
let rows;
|
|
1778
|
+
try {
|
|
1779
|
+
const clauses = ["wiki_pages_fts MATCH ?"];
|
|
1780
|
+
const params = [q ? `${q}*` : "*"];
|
|
1781
|
+
if (type) {
|
|
1782
|
+
clauses.push("entity_type = ?");
|
|
1783
|
+
params.push(type);
|
|
1784
|
+
}
|
|
1785
|
+
rows = db.prepare(`
|
|
1786
|
+
SELECT path, title, entity_type, last_updated
|
|
1787
|
+
FROM wiki_pages_fts
|
|
1788
|
+
WHERE ${clauses.join(" AND ")}
|
|
1789
|
+
ORDER BY rank
|
|
1790
|
+
LIMIT 50
|
|
1791
|
+
`).all(...params);
|
|
1792
|
+
}
|
|
1793
|
+
catch {
|
|
1794
|
+
const clauses = [];
|
|
1795
|
+
const params = [];
|
|
1796
|
+
if (q) {
|
|
1797
|
+
clauses.push("title LIKE ? COLLATE NOCASE");
|
|
1798
|
+
params.push(`%${q}%`);
|
|
1799
|
+
}
|
|
1800
|
+
if (type) {
|
|
1801
|
+
clauses.push("entity_type = ?");
|
|
1802
|
+
params.push(type);
|
|
1803
|
+
}
|
|
1804
|
+
rows = db.prepare(`
|
|
1805
|
+
SELECT path, title, entity_type, last_updated
|
|
1806
|
+
FROM wiki_pages
|
|
1807
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
1808
|
+
LIMIT 50
|
|
1809
|
+
`).all(...params);
|
|
1810
|
+
}
|
|
1811
|
+
const pages = rows.map((row) => ({
|
|
1812
|
+
slug: wikiPathToBrowserSlug(row.path),
|
|
1813
|
+
title: row.title,
|
|
1814
|
+
type: row.entity_type ?? "topics",
|
|
1815
|
+
last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
|
|
1816
|
+
}));
|
|
1817
|
+
res.json({ pages });
|
|
1818
|
+
});
|
|
1593
1819
|
// ---------------------------------------------------------------------------
|
|
1594
1820
|
// Skills
|
|
1595
1821
|
// ---------------------------------------------------------------------------
|