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
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { statSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { OkResponseSchema, WikiBrowserPageListSchema, WikiDeleteResponseSchema, WikiLinksResponseSchema, WikiPageContentSchema, WikiPageDetailSchema, WikiPageListSchema, WikiPageUpdateResponseSchema, WikiPinResponseSchema, WikiResearchSessionsResponseSchema, WikiSourcesResponseSchema, WikiWriteResponseSchema, } from "../../shared/api-schemas.js";
|
|
6
|
+
import { getDb } from "../../store/db.js";
|
|
7
|
+
import { deletePage, ensureWikiStructure, assertPagePath, getWikiDir, listPages, pageExists, readPage, writePage } from "../../wiki/fs.js";
|
|
8
|
+
import { parseWikiFrontmatter } from "../../wiki/frontmatter.js";
|
|
9
|
+
import { ingestSource } from "../../wiki/ingest.js";
|
|
10
|
+
import { parseIndex } from "../../wiki/index-manager.js";
|
|
11
|
+
import { withWikiWrite } from "../../wiki/lock.js";
|
|
12
|
+
import { normalizeWikiPath } from "../../wiki/path-utils.js";
|
|
13
|
+
import { readWikiPage, teamWikiSync } from "../../wiki/team-sync.js";
|
|
14
|
+
import { asBadRequest, BadRequestError, NotFoundError, parseRequest } from "../errors.js";
|
|
15
|
+
import { listKorgResearchSessions } from "../korg.js";
|
|
16
|
+
import { sendJson } from "../send-json.js";
|
|
17
|
+
const requiredString = (message) => z.string({ error: message }).trim().min(1, message);
|
|
18
|
+
const wikiWriteSchema = z.object({
|
|
19
|
+
content: z.string({ error: "Missing 'content' string in request body" }),
|
|
20
|
+
}).strict();
|
|
21
|
+
function createWikiPagePayload(path, content) {
|
|
22
|
+
const { parsed: frontmatter, body: renderedContent } = parseWikiFrontmatter(content);
|
|
23
|
+
return {
|
|
24
|
+
path,
|
|
25
|
+
content,
|
|
26
|
+
renderedContent,
|
|
27
|
+
frontmatter,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function coerceWikiPageUpdated(path, updated) {
|
|
31
|
+
const normalized = updated?.trim();
|
|
32
|
+
if (normalized) {
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return statSync(join(getWikiDir(), path)).mtime.toISOString();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "unknown";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function wikiPathToBrowserSlug(path) {
|
|
43
|
+
return path.replace(/^pages\//, "").replace(/\.md$/, "");
|
|
44
|
+
}
|
|
45
|
+
function entityTypeFromPath(path) {
|
|
46
|
+
const rest = path.startsWith("pages/") ? path.slice("pages/".length) : path;
|
|
47
|
+
const segs = rest.split("/").filter(Boolean);
|
|
48
|
+
if (segs.length <= 1)
|
|
49
|
+
return (segs[0] || "pages").replace(/\.md$/i, "");
|
|
50
|
+
return segs[0];
|
|
51
|
+
}
|
|
52
|
+
function normalizeOptionalQueryParam(value) {
|
|
53
|
+
if (typeof value !== "string") {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
return trimmed ? trimmed : undefined;
|
|
58
|
+
}
|
|
59
|
+
function matchesWikiBrowserFilters(page, filters) {
|
|
60
|
+
if (filters.type && page.type !== filters.type) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (!filters.q) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const query = filters.q.toLowerCase();
|
|
67
|
+
return page.title.toLowerCase().includes(query) || page.summary.toLowerCase().includes(query);
|
|
68
|
+
}
|
|
69
|
+
function mapIndexEntryToBrowserPage(entry) {
|
|
70
|
+
return {
|
|
71
|
+
slug: wikiPathToBrowserSlug(entry.path),
|
|
72
|
+
title: entry.title,
|
|
73
|
+
summary: entry.summary,
|
|
74
|
+
type: entry.section,
|
|
75
|
+
last_updated: coerceWikiPageUpdated(entry.path, entry.updated),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function listFallbackWikiBrowserPages(filters) {
|
|
79
|
+
const entries = parseIndex();
|
|
80
|
+
const indexed = new Set(entries.map((entry) => entry.path));
|
|
81
|
+
const indexedResults = entries.map(mapIndexEntryToBrowserPage);
|
|
82
|
+
const orphanResults = listPages()
|
|
83
|
+
.filter((path) => !indexed.has(path))
|
|
84
|
+
.map((path) => ({
|
|
85
|
+
slug: wikiPathToBrowserSlug(path),
|
|
86
|
+
title: path,
|
|
87
|
+
summary: "",
|
|
88
|
+
type: "Unindexed",
|
|
89
|
+
last_updated: coerceWikiPageUpdated(path, undefined),
|
|
90
|
+
}));
|
|
91
|
+
return [...indexedResults, ...orphanResults].filter((page) => matchesWikiBrowserFilters(page, filters));
|
|
92
|
+
}
|
|
93
|
+
function listDbWikiBrowserPages(filters) {
|
|
94
|
+
const db = getDb();
|
|
95
|
+
const clauses = [];
|
|
96
|
+
const params = [];
|
|
97
|
+
if (filters.type) {
|
|
98
|
+
clauses.push("(entity_type = ? OR (entity_type IS NULL AND path LIKE ?))");
|
|
99
|
+
params.push(filters.type, `pages/${filters.type}/%`);
|
|
100
|
+
}
|
|
101
|
+
if (filters.q) {
|
|
102
|
+
clauses.push("(title LIKE ? COLLATE NOCASE OR COALESCE(summary, '') LIKE ? COLLATE NOCASE)");
|
|
103
|
+
params.push(`%${filters.q}%`, `%${filters.q}%`);
|
|
104
|
+
}
|
|
105
|
+
const rows = db.prepare(`
|
|
106
|
+
SELECT path, title, summary, entity_type, last_updated, pinned
|
|
107
|
+
FROM wiki_pages
|
|
108
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
109
|
+
ORDER BY COALESCE(last_updated, '') DESC, title ASC
|
|
110
|
+
`).all(...params);
|
|
111
|
+
return rows.map((row) => ({
|
|
112
|
+
slug: wikiPathToBrowserSlug(row.path),
|
|
113
|
+
title: row.title,
|
|
114
|
+
summary: row.summary ?? "",
|
|
115
|
+
type: row.entity_type ?? entityTypeFromPath(row.path),
|
|
116
|
+
last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
|
|
117
|
+
...(row.pinned ? { pinned: true } : {}),
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
function parseWikiSourcePages(value) {
|
|
121
|
+
if (!value) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(value);
|
|
126
|
+
return Array.isArray(parsed)
|
|
127
|
+
? parsed.filter((entry) => typeof entry === "string").map((entry) => normalizeWikiPath(entry))
|
|
128
|
+
: [];
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function listWikiSources(page) {
|
|
135
|
+
const rows = getDb().prepare(`
|
|
136
|
+
SELECT source_type, origin, raw_path, pages_updated, status, session_id, ingested_at
|
|
137
|
+
FROM wiki_sources
|
|
138
|
+
ORDER BY ingested_at DESC, id ASC
|
|
139
|
+
`).all();
|
|
140
|
+
return rows
|
|
141
|
+
.filter((row) => !page || parseWikiSourcePages(row.pages_updated).includes(page))
|
|
142
|
+
.map((row) => ({
|
|
143
|
+
source_url: row.origin,
|
|
144
|
+
path: row.raw_path ?? undefined,
|
|
145
|
+
kind: row.source_type,
|
|
146
|
+
status: row.status,
|
|
147
|
+
session_id: row.session_id,
|
|
148
|
+
ingested_at: row.ingested_at,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
function wikiPathToSlug(path) {
|
|
152
|
+
const segments = normalizeWikiPath(path).split("/").filter(Boolean);
|
|
153
|
+
const file = segments[segments.length - 1] ?? path;
|
|
154
|
+
const base = file.endsWith(".md") ? file.slice(0, -3) : file;
|
|
155
|
+
if (base === "index" && segments.length >= 2) {
|
|
156
|
+
return segments[segments.length - 2] ?? base;
|
|
157
|
+
}
|
|
158
|
+
return base;
|
|
159
|
+
}
|
|
160
|
+
function listWikiLinks(page) {
|
|
161
|
+
const rows = page
|
|
162
|
+
? getDb().prepare(`
|
|
163
|
+
SELECT from_page, to_page, link_type
|
|
164
|
+
FROM wiki_links
|
|
165
|
+
WHERE from_page = ? OR to_page = ?
|
|
166
|
+
ORDER BY from_page ASC, to_page ASC, link_type ASC
|
|
167
|
+
`).all(page, page)
|
|
168
|
+
: getDb().prepare(`
|
|
169
|
+
SELECT from_page, to_page, link_type
|
|
170
|
+
FROM wiki_links
|
|
171
|
+
ORDER BY from_page ASC, to_page ASC, link_type ASC
|
|
172
|
+
`).all();
|
|
173
|
+
return rows.map((row) => ({
|
|
174
|
+
source_slug: wikiPathToSlug(row.from_page),
|
|
175
|
+
target_slug: wikiPathToSlug(row.to_page),
|
|
176
|
+
link_type: row.link_type,
|
|
177
|
+
...(page ? { direction: row.from_page === page ? "outgoing" : "incoming" } : {}),
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
function readPathParam(req) {
|
|
181
|
+
const raw = req.query.path;
|
|
182
|
+
if (typeof raw !== "string" || !raw) {
|
|
183
|
+
throw new BadRequestError("Missing 'path' query param");
|
|
184
|
+
}
|
|
185
|
+
return raw;
|
|
186
|
+
}
|
|
187
|
+
function readOptionalPageFilter(req) {
|
|
188
|
+
const raw = req.query.page;
|
|
189
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
return normalizeWikiPath(raw.trim());
|
|
193
|
+
}
|
|
194
|
+
function assertValidPagePath(path) {
|
|
195
|
+
try {
|
|
196
|
+
assertPagePath(path);
|
|
197
|
+
return path;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
asBadRequest(error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function getEmptyWikiWelcomeContent(today = new Date()) {
|
|
204
|
+
return `---
|
|
205
|
+
title: Wiki
|
|
206
|
+
summary: Empty wiki — get started.
|
|
207
|
+
updated: ${today.toISOString().slice(0, 10)}
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
# Wiki
|
|
211
|
+
|
|
212
|
+
Your wiki is empty. Pages are organized by category — projects, people, tools, topics, areas, orgs, facts, preferences, routines.
|
|
213
|
+
|
|
214
|
+
Create your first page via the wiki UI or by editing files under \`pages/\`.
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
export function createWikiRouter(options) {
|
|
218
|
+
const { authMiddleware, modeContext } = options;
|
|
219
|
+
const router = Router();
|
|
220
|
+
function getWikiPageScope(path) {
|
|
221
|
+
return modeContext.canSyncTeamWiki() && teamWikiSync.isTeamPath(path) ? "team" : "personal";
|
|
222
|
+
}
|
|
223
|
+
router.get("/api/wiki/pages", async (req, res) => {
|
|
224
|
+
ensureWikiStructure();
|
|
225
|
+
// Sync team wiki pages if connected, using the caller's auth token
|
|
226
|
+
if (modeContext.canSyncTeamWiki() && teamWikiSync.isEnabled()) {
|
|
227
|
+
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
228
|
+
? req.headers.authorization
|
|
229
|
+
: undefined;
|
|
230
|
+
try {
|
|
231
|
+
await teamWikiSync.syncAll({ authorizationHeader });
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Non-fatal: list local pages even if team sync fails
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const entries = parseIndex();
|
|
238
|
+
// Index entries first (rich metadata), then any pages on disk that aren't yet indexed.
|
|
239
|
+
const indexed = new Set(entries.map((e) => e.path));
|
|
240
|
+
const indexedResults = entries.map((e) => ({
|
|
241
|
+
path: e.path,
|
|
242
|
+
title: e.title,
|
|
243
|
+
summary: e.summary,
|
|
244
|
+
section: e.section,
|
|
245
|
+
tags: e.tags || [],
|
|
246
|
+
updated: coerceWikiPageUpdated(e.path, e.updated),
|
|
247
|
+
scope: getWikiPageScope(e.path),
|
|
248
|
+
}));
|
|
249
|
+
const orphanResults = listPages()
|
|
250
|
+
.filter((p) => !indexed.has(p))
|
|
251
|
+
.map((p) => ({
|
|
252
|
+
path: p,
|
|
253
|
+
title: p,
|
|
254
|
+
summary: "",
|
|
255
|
+
section: "Unindexed",
|
|
256
|
+
tags: [],
|
|
257
|
+
updated: coerceWikiPageUpdated(p, undefined),
|
|
258
|
+
scope: getWikiPageScope(p),
|
|
259
|
+
}));
|
|
260
|
+
sendJson(res, WikiPageListSchema, [...indexedResults, ...orphanResults]);
|
|
261
|
+
});
|
|
262
|
+
router.get("/api/wiki/browser-pages", async (req, res) => {
|
|
263
|
+
ensureWikiStructure();
|
|
264
|
+
const filters = {
|
|
265
|
+
q: normalizeOptionalQueryParam(req.query.q),
|
|
266
|
+
type: normalizeOptionalQueryParam(req.query.type),
|
|
267
|
+
};
|
|
268
|
+
const db = getDb();
|
|
269
|
+
const { count } = db.prepare("SELECT COUNT(*) AS count FROM wiki_pages").get();
|
|
270
|
+
const pages = count > 0
|
|
271
|
+
? listDbWikiBrowserPages(filters)
|
|
272
|
+
: listFallbackWikiBrowserPages(filters);
|
|
273
|
+
sendJson(res, WikiBrowserPageListSchema, { pages });
|
|
274
|
+
});
|
|
275
|
+
router.get("/api/wiki/sources", async (req, res) => {
|
|
276
|
+
ensureWikiStructure();
|
|
277
|
+
sendJson(res, WikiSourcesResponseSchema, { sources: listWikiSources(readOptionalPageFilter(req)) });
|
|
278
|
+
});
|
|
279
|
+
router.get("/api/wiki/links", async (req, res) => {
|
|
280
|
+
ensureWikiStructure();
|
|
281
|
+
sendJson(res, WikiLinksResponseSchema, { links: listWikiLinks(readOptionalPageFilter(req)) });
|
|
282
|
+
});
|
|
283
|
+
router.get("/api/wiki/page", async (req, res) => {
|
|
284
|
+
const slugParam = normalizeOptionalQueryParam(req.query.slug);
|
|
285
|
+
if (slugParam) {
|
|
286
|
+
const path = `pages/${slugParam}.md`;
|
|
287
|
+
assertValidPagePath(path);
|
|
288
|
+
const db = getDb();
|
|
289
|
+
const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
|
|
290
|
+
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
291
|
+
? req.headers.authorization
|
|
292
|
+
: undefined;
|
|
293
|
+
const rawContent = await readWikiPage(path, { authorizationHeader });
|
|
294
|
+
if (rawContent === undefined) {
|
|
295
|
+
throw new NotFoundError("Page not found");
|
|
296
|
+
}
|
|
297
|
+
const { parsed: frontmatter } = parseWikiFrontmatter(rawContent);
|
|
298
|
+
sendJson(res, WikiPageDetailSchema, {
|
|
299
|
+
page: {
|
|
300
|
+
slug: slugParam,
|
|
301
|
+
title: row?.title ?? slugParam,
|
|
302
|
+
type: row?.entity_type ?? "topics",
|
|
303
|
+
last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
|
|
304
|
+
compiled_truth: rawContent,
|
|
305
|
+
pinned: Boolean(row?.pinned),
|
|
306
|
+
frontmatter,
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const path = assertValidPagePath(readPathParam(req));
|
|
312
|
+
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
313
|
+
? req.headers.authorization
|
|
314
|
+
: undefined;
|
|
315
|
+
const content = await readWikiPage(path, { authorizationHeader });
|
|
316
|
+
if (content === undefined) {
|
|
317
|
+
if (path === "pages/index.md") {
|
|
318
|
+
sendJson(res, WikiPageContentSchema, createWikiPagePayload(path, getEmptyWikiWelcomeContent()));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
throw new NotFoundError("Page not found");
|
|
322
|
+
}
|
|
323
|
+
sendJson(res, WikiPageContentSchema, createWikiPagePayload(path, content));
|
|
324
|
+
});
|
|
325
|
+
router.put("/api/wiki/page", async (req, res) => {
|
|
326
|
+
const path = assertValidPagePath(readPathParam(req));
|
|
327
|
+
const { content } = parseRequest(wikiWriteSchema, req.body);
|
|
328
|
+
const created = await withWikiWrite(() => {
|
|
329
|
+
const isCreated = !pageExists(path);
|
|
330
|
+
writePage(path, content);
|
|
331
|
+
return isCreated;
|
|
332
|
+
});
|
|
333
|
+
sendJson(res, WikiWriteResponseSchema, { ok: true, created, path });
|
|
334
|
+
});
|
|
335
|
+
const wikiUpdateSchema = z.object({
|
|
336
|
+
slug: requiredString("Missing 'slug' in request body"),
|
|
337
|
+
compiled_truth: z.string().optional(),
|
|
338
|
+
frontmatter: z.record(z.string(), z.unknown()).optional(),
|
|
339
|
+
});
|
|
340
|
+
router.post("/api/wiki/update", authMiddleware, async (req, res) => {
|
|
341
|
+
const { slug, compiled_truth, frontmatter: newFrontmatter } = parseRequest(wikiUpdateSchema, req.body);
|
|
342
|
+
const path = assertValidPagePath(`pages/${slug}.md`);
|
|
343
|
+
let finalContent = compiled_truth;
|
|
344
|
+
if (newFrontmatter !== undefined) {
|
|
345
|
+
const existing = readPage(path);
|
|
346
|
+
const base = finalContent ?? existing ?? "";
|
|
347
|
+
const { parsed: existingFrontmatter, body } = parseWikiFrontmatter(base);
|
|
348
|
+
const merged = { ...existingFrontmatter, ...newFrontmatter };
|
|
349
|
+
const fmLines = Object.entries(merged).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n");
|
|
350
|
+
finalContent = `---\n${fmLines}\n---\n${body}`;
|
|
351
|
+
}
|
|
352
|
+
if (finalContent !== undefined) {
|
|
353
|
+
await withWikiWrite(() => writePage(path, finalContent));
|
|
354
|
+
}
|
|
355
|
+
const db = getDb();
|
|
356
|
+
const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
|
|
357
|
+
const content = readPage(path) ?? finalContent ?? "";
|
|
358
|
+
const { parsed: frontmatter } = parseWikiFrontmatter(content);
|
|
359
|
+
sendJson(res, WikiPageUpdateResponseSchema, {
|
|
360
|
+
ok: true,
|
|
361
|
+
page: {
|
|
362
|
+
slug,
|
|
363
|
+
title: row?.title ?? slug,
|
|
364
|
+
type: row?.entity_type ?? "topics",
|
|
365
|
+
last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
|
|
366
|
+
compiled_truth: content,
|
|
367
|
+
pinned: Boolean(row?.pinned),
|
|
368
|
+
frontmatter,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
const wikiPinSchema = z.object({
|
|
373
|
+
slug: requiredString("Missing 'slug' in request body"),
|
|
374
|
+
pinned: z.boolean({ error: "Missing 'pinned' in request body" }),
|
|
375
|
+
});
|
|
376
|
+
router.post("/api/wiki/page/pin", authMiddleware, async (req, res) => {
|
|
377
|
+
const { slug, pinned } = parseRequest(wikiPinSchema, req.body);
|
|
378
|
+
const path = assertValidPagePath(`pages/${slug}.md`);
|
|
379
|
+
const db = getDb();
|
|
380
|
+
db.prepare("UPDATE wiki_pages SET pinned = ? WHERE path = ?").run(pinned ? 1 : 0, path);
|
|
381
|
+
sendJson(res, WikiPinResponseSchema, { ok: true, pinned: Boolean(pinned) });
|
|
382
|
+
});
|
|
383
|
+
router.delete("/api/wiki/page", async (req, res) => {
|
|
384
|
+
const path = assertValidPagePath(readPathParam(req));
|
|
385
|
+
const removed = await withWikiWrite(() => deletePage(path));
|
|
386
|
+
sendJson(res, WikiDeleteResponseSchema, { ok: removed, path });
|
|
387
|
+
});
|
|
388
|
+
router.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
|
|
389
|
+
sendJson(res, WikiResearchSessionsResponseSchema, { sessions: listKorgResearchSessions(getDb()) });
|
|
390
|
+
});
|
|
391
|
+
const wikiIngestSchema = z.object({
|
|
392
|
+
source_url: z.string().optional(),
|
|
393
|
+
path: z.string().optional(),
|
|
394
|
+
topic: z.string().optional(),
|
|
395
|
+
});
|
|
396
|
+
router.post("/api/wiki/ingest", authMiddleware, async (req, res) => {
|
|
397
|
+
const { source_url, path: ingestPath, topic } = parseRequest(wikiIngestSchema, req.body ?? {});
|
|
398
|
+
const source = source_url ?? ingestPath;
|
|
399
|
+
if (!source) {
|
|
400
|
+
throw new BadRequestError("Missing 'source_url' or 'path' in request body");
|
|
401
|
+
}
|
|
402
|
+
const type = source_url ? "url" : "text";
|
|
403
|
+
await withWikiWrite(() => ingestSource(source, type, topic));
|
|
404
|
+
sendJson(res, OkResponseSchema, { ok: true });
|
|
405
|
+
});
|
|
406
|
+
router.get("/api/wiki/search", authMiddleware, async (req, res) => {
|
|
407
|
+
ensureWikiStructure();
|
|
408
|
+
const q = normalizeOptionalQueryParam(req.query.q);
|
|
409
|
+
const type = normalizeOptionalQueryParam(req.query.type);
|
|
410
|
+
const db = getDb();
|
|
411
|
+
let rows;
|
|
412
|
+
try {
|
|
413
|
+
const clauses = ["wiki_pages_fts MATCH ?"];
|
|
414
|
+
const params = [q ? `${q}*` : "*"];
|
|
415
|
+
if (type) {
|
|
416
|
+
clauses.push("entity_type = ?");
|
|
417
|
+
params.push(type);
|
|
418
|
+
}
|
|
419
|
+
rows = db.prepare(`
|
|
420
|
+
SELECT path, title, entity_type, last_updated
|
|
421
|
+
FROM wiki_pages_fts
|
|
422
|
+
WHERE ${clauses.join(" AND ")}
|
|
423
|
+
ORDER BY rank
|
|
424
|
+
LIMIT 50
|
|
425
|
+
`).all(...params);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
const clauses = [];
|
|
429
|
+
const params = [];
|
|
430
|
+
if (q) {
|
|
431
|
+
clauses.push("title LIKE ? COLLATE NOCASE");
|
|
432
|
+
params.push(`%${q}%`);
|
|
433
|
+
}
|
|
434
|
+
if (type) {
|
|
435
|
+
clauses.push("entity_type = ?");
|
|
436
|
+
params.push(type);
|
|
437
|
+
}
|
|
438
|
+
rows = db.prepare(`
|
|
439
|
+
SELECT path, title, entity_type, last_updated
|
|
440
|
+
FROM wiki_pages
|
|
441
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
442
|
+
LIMIT 50
|
|
443
|
+
`).all(...params);
|
|
444
|
+
}
|
|
445
|
+
const pages = rows.map((row) => ({
|
|
446
|
+
slug: wikiPathToBrowserSlug(row.path),
|
|
447
|
+
title: row.title,
|
|
448
|
+
type: row.entity_type ?? "topics",
|
|
449
|
+
last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
|
|
450
|
+
}));
|
|
451
|
+
sendJson(res, WikiBrowserPageListSchema, { pages });
|
|
452
|
+
});
|
|
453
|
+
return router;
|
|
454
|
+
}
|
|
455
|
+
//# sourceMappingURL=wiki.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { createWikiRouter } from "./wiki.js";
|
|
5
|
+
import { config } from "../../config.js";
|
|
6
|
+
import { ModeContext } from "../../mode-context.js";
|
|
7
|
+
test("wiki explicit auth routes are guarded when mounted without global auth", async () => {
|
|
8
|
+
const authMiddleware = (_req, res) => {
|
|
9
|
+
res.status(401).json({ error: "missing token" });
|
|
10
|
+
};
|
|
11
|
+
const app = express();
|
|
12
|
+
app.use(express.json());
|
|
13
|
+
app.use(createWikiRouter({
|
|
14
|
+
authMiddleware,
|
|
15
|
+
modeContext: new ModeContext(config),
|
|
16
|
+
}));
|
|
17
|
+
const server = app.listen(0, "127.0.0.1");
|
|
18
|
+
await new Promise((resolve) => server.once("listening", resolve));
|
|
19
|
+
const address = server.address();
|
|
20
|
+
assert.ok(address && typeof address === "object");
|
|
21
|
+
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
22
|
+
try {
|
|
23
|
+
const responses = await Promise.all([
|
|
24
|
+
fetch(`${baseUrl}/api/wiki/update`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ slug: "topics/auth-required", compiled_truth: "# Auth required" }),
|
|
28
|
+
}),
|
|
29
|
+
fetch(`${baseUrl}/api/wiki/ingest`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ path: "raw source" }),
|
|
33
|
+
}),
|
|
34
|
+
fetch(`${baseUrl}/api/wiki/search?q=chapterhouse`),
|
|
35
|
+
fetch(`${baseUrl}/api/wiki/page/pin`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ slug: "topics/auth-required", pinned: true }),
|
|
39
|
+
}),
|
|
40
|
+
]);
|
|
41
|
+
assert.deepEqual(responses.map((response) => response.status), [401, 401, 401, 401]);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=wiki.test.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { childLogger } from "../util/logger.js";
|
|
3
|
+
const log = childLogger("api.send-json");
|
|
4
|
+
export function sendJson(res, schema, payload) {
|
|
5
|
+
const parsed = schema.safeParse(payload);
|
|
6
|
+
if (!parsed.success) {
|
|
7
|
+
const message = `Invalid API response payload: ${parsed.error.message}`;
|
|
8
|
+
if (!config.isProduction) {
|
|
9
|
+
throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
log.warn({ err: parsed.error.message }, "Invalid API response payload");
|
|
12
|
+
return res.json(payload);
|
|
13
|
+
}
|
|
14
|
+
return res.json(parsed.data);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=send-json.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
test("sendJson validates payloads against shared schemas before responding", async () => {
|
|
5
|
+
const { sendJson } = await import("./send-json.js");
|
|
6
|
+
const writes = [];
|
|
7
|
+
const response = {
|
|
8
|
+
json(payload) {
|
|
9
|
+
writes.push(payload);
|
|
10
|
+
return this;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const schema = z.object({ ok: z.literal(true) });
|
|
14
|
+
sendJson(response, schema, { ok: true });
|
|
15
|
+
assert.deepEqual(writes, [{ ok: true }]);
|
|
16
|
+
assert.throws(() => sendJson(response, schema, { ok: false }), /Invalid API response payload/);
|
|
17
|
+
});
|
|
18
|
+
//# sourceMappingURL=send-json.test.js.map
|
|
@@ -10,12 +10,24 @@ export function resolveApiToken({ envToken, tokenPath, exists = existsSync, read
|
|
|
10
10
|
}
|
|
11
11
|
return null;
|
|
12
12
|
}
|
|
13
|
+
function isLoopbackBindHost(host) {
|
|
14
|
+
const normalizedHost = host.trim().toLowerCase();
|
|
15
|
+
return normalizedHost === "127.0.0.1" || normalizedHost === "::1" || normalizedHost === "localhost";
|
|
16
|
+
}
|
|
13
17
|
export function assertAuthenticationConfigured(options) {
|
|
14
|
-
|
|
18
|
+
if (options.entraAuthEnabled || options.apiToken) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (isLoopbackBindHost(options.apiHost)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Refusing to start without authentication on non-loopback bind ${options.apiHost}. `
|
|
25
|
+
+ "Set API_TOKEN or configure Entra auth (ENTRA_AUTH_ENABLED=true with ENTRA_TENANT_ID and ENTRA_CLIENT_ID), "
|
|
26
|
+
+ "or bind API_HOST to 127.0.0.1, ::1, or localhost for standalone local use.");
|
|
15
27
|
}
|
|
16
|
-
export function createHealthPayload(now = new Date()) {
|
|
28
|
+
export function createHealthPayload(now = new Date(), options = {}) {
|
|
17
29
|
return {
|
|
18
|
-
status: "ok",
|
|
30
|
+
status: options.fts5Ready === false ? "initializing" : "ok",
|
|
19
31
|
timestamp: now.toISOString(),
|
|
20
32
|
};
|
|
21
33
|
}
|
|
@@ -46,4 +58,34 @@ export function shouldServeSpaPath(pathname) {
|
|
|
46
58
|
export function getDisplayHost(host) {
|
|
47
59
|
return host === "0.0.0.0" || host === "::" || host === "127.0.0.1" || host === "::1" ? "localhost" : host;
|
|
48
60
|
}
|
|
61
|
+
export function setupSseCleanup(setup) {
|
|
62
|
+
const cleanups = [];
|
|
63
|
+
let cleaned = false;
|
|
64
|
+
const cleanup = () => {
|
|
65
|
+
if (cleaned)
|
|
66
|
+
return;
|
|
67
|
+
cleaned = true;
|
|
68
|
+
for (let i = cleanups.length - 1; i >= 0; i--) {
|
|
69
|
+
try {
|
|
70
|
+
cleanups[i]?.();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Best-effort cleanup should never mask the original SSE failure path.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
let setupComplete = false;
|
|
78
|
+
try {
|
|
79
|
+
setup((fn) => {
|
|
80
|
+
if (fn)
|
|
81
|
+
cleanups.push(fn);
|
|
82
|
+
}, cleanup);
|
|
83
|
+
setupComplete = true;
|
|
84
|
+
return cleanup;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
if (!setupComplete)
|
|
88
|
+
cleanup();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
49
91
|
//# sourceMappingURL=server-runtime.js.map
|