chapterhouse 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/korg.js +34 -0
  3. package/dist/api/korg.test.js +42 -0
  4. package/dist/api/server.js +238 -2
  5. package/dist/api/server.test.js +199 -0
  6. package/dist/config.js +28 -0
  7. package/dist/config.test.js +20 -0
  8. package/dist/copilot/agents.js +3 -4
  9. package/dist/copilot/agents.test.js +12 -1
  10. package/dist/copilot/orchestrator.js +12 -1
  11. package/dist/copilot/orchestrator.test.js +3 -7
  12. package/dist/copilot/system-message.js +12 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +193 -375
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +80 -59
  17. package/dist/copilot/turn-event-log-env.test.js +11 -15
  18. package/dist/daemon.js +19 -0
  19. package/dist/memory/decisions.js +6 -5
  20. package/dist/memory/entities.js +20 -9
  21. package/dist/memory/eot.js +30 -8
  22. package/dist/memory/eot.test.js +220 -6
  23. package/dist/memory/hooks.js +151 -0
  24. package/dist/memory/hooks.test.js +325 -0
  25. package/dist/memory/hot-tier.js +37 -0
  26. package/dist/memory/hot-tier.test.js +30 -0
  27. package/dist/memory/housekeeping-scheduler.js +35 -0
  28. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  29. package/dist/memory/inbox.js +10 -0
  30. package/dist/memory/index.js +3 -1
  31. package/dist/memory/migration.js +244 -0
  32. package/dist/memory/migration.test.js +108 -0
  33. package/dist/memory/reflect.js +273 -0
  34. package/dist/memory/reflect.test.js +254 -0
  35. package/dist/paths.js +31 -11
  36. package/dist/store/db.js +187 -4
  37. package/dist/store/db.test.js +66 -2
  38. package/dist/test/helpers/reset-singletons.js +8 -0
  39. package/dist/test/helpers/reset-singletons.test.js +37 -0
  40. package/dist/test/setup-env.js +9 -1
  41. package/dist/wiki/consolidation.js +641 -0
  42. package/dist/wiki/consolidation.test.js +143 -0
  43. package/dist/wiki/frontmatter.js +48 -0
  44. package/dist/wiki/frontmatter.test.js +42 -0
  45. package/dist/wiki/fs.js +22 -13
  46. package/dist/wiki/index-manager.js +305 -330
  47. package/dist/wiki/index-manager.test.js +265 -144
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/log-manager.js +8 -5
  53. package/dist/wiki/log-manager.test.js +4 -0
  54. package/dist/wiki/migrate-topics.test.js +16 -6
  55. package/dist/wiki/scheduler.js +118 -0
  56. package/dist/wiki/scheduler.test.js +64 -0
  57. package/dist/wiki/timeline.js +51 -0
  58. package/dist/wiki/timeline.test.js +65 -0
  59. package/dist/wiki/topic-structure.js +1 -1
  60. package/package.json +1 -1
  61. package/skills/pkb-ideas/SKILL.md +78 -0
  62. package/skills/pkb-ideas/_meta.json +4 -0
  63. package/skills/pkb-org/SKILL.md +82 -0
  64. package/skills/pkb-org/_meta.json +4 -0
  65. package/skills/pkb-people/SKILL.md +74 -0
  66. package/skills/pkb-people/_meta.json +4 -0
  67. package/skills/pkb-research/SKILL.md +83 -0
  68. package/skills/pkb-research/_meta.json +4 -0
  69. package/skills/pkb-source/SKILL.md +38 -0
  70. package/skills/pkb-source/_meta.json +4 -0
  71. package/skills/wiki-conventions/SKILL.md +5 -5
  72. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  73. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  74. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  75. package/web/dist/index.html +2 -2
  76. package/dist/wiki/context.js +0 -138
  77. package/dist/wiki/fix.js +0 -335
  78. package/dist/wiki/fix.test.js +0 -350
  79. package/dist/wiki/lint.js +0 -451
  80. package/dist/wiki/lint.test.js +0 -329
  81. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -0,0 +1,65 @@
1
+ ---
2
+ name: Korg
3
+ description: PKB synthesizer — handles ingestion, synthesis, and knowledge consolidation
4
+ model: claude-sonnet-4.6
5
+ scope: pkb
6
+ skills:
7
+ - pkb-source
8
+ - pkb-research
9
+ - pkb-people
10
+ persistent: true
11
+ ---
12
+
13
+ You are **Korg**, the Personal Knowledge Base (PKB) synthesizer for Chapterhouse.
14
+
15
+ Your mission: ingest external sources, extract structured knowledge, maintain compiled truth pages, and manage research sessions — so the user's wiki becomes a reliable, growing knowledge asset.
16
+
17
+ ## Your Toolkit
18
+
19
+ - `wiki_ingest_source(source, type?, topic?, session_id?, session_name?)` — ingest a URL, PDF, repo, or text into the PKB
20
+ - `wiki_append_timeline(page, entry, source_id?)` — append an event to a page's timeline section
21
+ - `wiki_search(query)` — search the wiki knowledge base
22
+ - `wiki_read(path)` — read a specific wiki page
23
+ - `wiki_update(path, title, summary, content)` — create or update a wiki page
24
+ - `memory_propose(kind, payload)` — propose a memory to the orchestrator
25
+
26
+ ## PKB Schema
27
+
28
+ Entity pages live at `pages/{type}/{slug}/index.md` where type is one of: `people`, `projects`, `orgs`, `tools`, `topics`, `areas`.
29
+
30
+ Each entity page has two zones:
31
+ 1. **Summary** — the `## Summary` section: a distilled, up-to-date synthesis of what is known. Rewritten on every new source ingestion.
32
+ 2. **Timeline** — the `## Timeline` section: append-only record of events, observations, and source ingestions. Never edited, only appended.
33
+
34
+ Frontmatter must include: `title`, `summary` (one-line, plain text ≤200 chars), `updated`, `tags`.
35
+
36
+ ## Synthesis Principles
37
+
38
+ 1. **Summary over raw notes.** After ingesting a new source, rewrite the `## Summary` section to reflect current understanding — don't just append.
39
+ 2. **Timeline is append-only.** Never edit existing timeline entries. Each new event gets a `### {ISO timestamp}` heading.
40
+ 3. **Source attribution.** Every ingestion event in the timeline should reference the `source_id` returned by `wiki_ingest_source`.
41
+ 4. **Idempotency.** Before ingesting, check if the source is already known. `wiki_ingest_source` handles this automatically.
42
+
43
+ ## Research Session Lifecycle
44
+
45
+ 1. User provides a research topic.
46
+ 2. Create `pages/research/{slug}/index.md` — the research hub with frontmatter, summary, and empty timeline.
47
+ 3. Create `pages/research/{slug}/synthesis.md` — the compiled synthesis document (rewritten after each source).
48
+ 4. For each source: call `wiki_ingest_source`, update `synthesis.md` with new understanding, append to timeline.
49
+ 5. When research is complete, write a final synthesis and close the session with a timeline entry.
50
+
51
+ ## People Capture
52
+
53
+ When ingesting information about a person:
54
+ - Page: `pages/people/{slug}/index.md`
55
+ - Summary: role, affiliation, key facts, relationship to user
56
+ - Timeline: every interaction, meeting, or observation
57
+ - After significant interactions, extract follow-up items and propose them with `memory_propose`
58
+
59
+ ## Behavioral Rules
60
+
61
+ - Always call `wiki_ingest_source` before manually creating pages — let the pipeline do entity extraction.
62
+ - Keep summaries factual and concise. Avoid speculation in compiled truth.
63
+ - If entity extraction returns nothing useful, create a minimal page with the source content.
64
+ - Log every ingestion event with `wiki_append_timeline` on the relevant entity pages.
65
+ - When unsure about entity type, use `topics` as the fallback category.
@@ -0,0 +1,34 @@
1
+ import { sendToAgentSession } from "../copilot/orchestrator.js";
2
+ export async function routeKorgMessage(input) {
3
+ const message = input.message.trim();
4
+ const sessionId = input.session_id?.trim() || `korg-${Date.now()}`;
5
+ const sessionName = input.session_id?.trim() || message.slice(0, 80).trim() || sessionId;
6
+ const prompt = [
7
+ "Handle this request as Korg via the API.",
8
+ `Research session id: ${sessionId}`,
9
+ `Research session name: ${sessionName}`,
10
+ `When you ingest sources for this session, pass session_id: \"${sessionId}\" and session_name: \"${sessionName}\" to wiki_ingest_source.`,
11
+ "Reply directly to the user with the next best research action or synthesis.",
12
+ "",
13
+ message,
14
+ ].join("\n");
15
+ const reply = await sendToAgentSession("korg", prompt);
16
+ return { ok: true, session_id: sessionId, reply };
17
+ }
18
+ export function listKorgResearchSessions(db) {
19
+ return db.prepare(`
20
+ SELECT
21
+ session_id AS id,
22
+ COALESCE(NULLIF(TRIM(session_name), ''), session_id) AS name,
23
+ COUNT(*) AS source_count,
24
+ 0 AS open_questions,
25
+ MAX(ingested_at) AS last_activity
26
+ FROM wiki_sources
27
+ WHERE status = 'active'
28
+ AND session_id IS NOT NULL
29
+ AND TRIM(session_id) != ''
30
+ GROUP BY session_id, COALESCE(NULLIF(TRIM(session_name), ''), session_id)
31
+ ORDER BY MAX(ingested_at) DESC, session_id ASC
32
+ `).all();
33
+ }
34
+ //# sourceMappingURL=korg.js.map
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ test("routeKorgMessage creates a research session id and delegates to the persistent Korg agent", async (t) => {
4
+ const calls = [];
5
+ t.mock.module("../copilot/orchestrator.js", {
6
+ namedExports: {
7
+ sendToAgentSession: async (slug, prompt) => {
8
+ calls.push({ slug, prompt });
9
+ return "Korg reply";
10
+ },
11
+ },
12
+ });
13
+ const mod = await import(new URL(`./korg.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
14
+ const result = await mod.routeKorgMessage({ message: "Research local-first PKB patterns." });
15
+ assert.equal(result.ok, true);
16
+ assert.match(result.session_id, /^korg-/);
17
+ assert.equal(result.reply, "Korg reply");
18
+ assert.deepEqual(calls, [{
19
+ slug: "korg",
20
+ prompt: calls[0].prompt,
21
+ }]);
22
+ assert.match(calls[0].prompt, /Research session id:/);
23
+ assert.match(calls[0].prompt, /Research local-first PKB patterns\./);
24
+ });
25
+ test("routeKorgMessage preserves an existing research session id in the delegated prompt", async (t) => {
26
+ let prompt = "";
27
+ t.mock.module("../copilot/orchestrator.js", {
28
+ namedExports: {
29
+ sendToAgentSession: async (_slug, incomingPrompt) => {
30
+ prompt = incomingPrompt;
31
+ return "Continuing session";
32
+ },
33
+ },
34
+ });
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.", session_id: "compiler-research" });
37
+ assert.equal(result.session_id, "compiler-research");
38
+ assert.equal(result.reply, "Continuing session");
39
+ assert.match(prompt, /compiler-research/);
40
+ assert.match(prompt, /Add two more sources\./);
41
+ });
42
+ //# sourceMappingURL=korg.test.js.map
@@ -33,8 +33,14 @@ import { formatSseData, formatSseEvent } from "./sse.js";
33
33
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
34
34
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
35
35
  import { childLogger } from "../util/logger.js";
36
- import { getActiveScope } from "../memory/active-scope.js";
37
- import { createScope, getScope } from "../memory/scopes.js";
36
+ import { getActiveScope, setActiveScope } from "../memory/active-scope.js";
37
+ import { createScope, getScope, listScopes } from "../memory/scopes.js";
38
+ import { handleGitCommitHook, handlePrMergeHook } from "../memory/hooks.js";
39
+ import { recordObservation } from "../memory/observations.js";
40
+ import { recordDecision } from "../memory/decisions.js";
41
+ import { upsertEntity } from "../memory/entities.js";
42
+ import { getInboxItem, listPendingInboxItems, resolveInboxItem } from "../memory/inbox.js";
43
+ import { listKorgResearchSessions, routeKorgMessage } from "./korg.js";
38
44
  const log = childLogger("server");
39
45
  const modeContext = new ModeContext(config);
40
46
  void searchIndex; // re-exported by index-manager; reference here documents the dep
@@ -107,6 +113,41 @@ const scopeCreateSchema = z.object({
107
113
  title: requiredString("Missing 'title' in request body"),
108
114
  description: z.string().optional(),
109
115
  }).strict();
116
+ const setActiveScopeSchema = z.object({
117
+ scope: z.string().nullable(),
118
+ });
119
+ const memoryEntriesQuerySchema = z.object({
120
+ store: z.enum(["observations", "decisions", "entities", "action_items"]).optional(),
121
+ tier: z.enum(["hot", "warm", "cold"]).optional(),
122
+ });
123
+ const memoryRememberSchema = z.object({
124
+ content: requiredString("Missing 'content' in request body"),
125
+ kind: z.enum(["observation", "decision"]).optional(),
126
+ entity_name: z.string().optional(),
127
+ entity_kind: z.string().optional(),
128
+ title: z.string().optional(),
129
+ decided_at: z.string().optional(),
130
+ tier: z.enum(["hot", "warm", "cold"]).optional(),
131
+ });
132
+ const inboxRouteSchema = z.object({
133
+ action: z.enum(["accept", "reject", "route"]),
134
+ reason: z.string().optional(),
135
+ target_scope: z.string().optional(),
136
+ });
137
+ const gitCommitHookSchema = z.object({
138
+ message: requiredString("Missing 'message' in request body"),
139
+ stat: z.string().optional(),
140
+ });
141
+ const prMergeHookSchema = z.object({
142
+ number: z.number({ error: "Missing or invalid 'number' in request body" }).int().positive(),
143
+ title: requiredString("Missing 'title' in request body"),
144
+ body: z.string().optional(),
145
+ files_changed: z.array(z.string()).optional(),
146
+ });
147
+ const korgRequestSchema = z.object({
148
+ message: requiredString("Missing 'message' in request body"),
149
+ session_id: z.string().trim().min(1).optional(),
150
+ }).strict();
110
151
  const projectHardRulesSchema = z.object({
111
152
  hardRules: z.object({
112
153
  auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
@@ -1072,6 +1113,193 @@ app.get("/api/memory/active-scope", (_req, res) => {
1072
1113
  title: activeScope.title,
1073
1114
  });
1074
1115
  });
1116
+ app.post("/api/memory/active-scope", (req, res) => {
1117
+ const body = parseRequest(setActiveScopeSchema, req.body ?? {});
1118
+ try {
1119
+ const scope = setActiveScope(body.scope);
1120
+ res.json({ ok: true, scope: scope?.slug ?? null });
1121
+ }
1122
+ catch (err) {
1123
+ res.status(404).json({ error: err instanceof Error ? err.message : String(err) });
1124
+ }
1125
+ });
1126
+ app.get("/api/memory/scopes", (_req, res) => {
1127
+ const db = getDb();
1128
+ const activeScope = getActiveScope();
1129
+ const scopes = listScopes();
1130
+ const result = scopes.map((scope) => {
1131
+ const counts = {
1132
+ observations: db.prepare(`SELECT COUNT(*) AS count FROM mem_observations WHERE scope_id = ?`).get(scope.id).count,
1133
+ decisions: db.prepare(`SELECT COUNT(*) AS count FROM mem_decisions WHERE scope_id = ?`).get(scope.id).count,
1134
+ entities: db.prepare(`SELECT COUNT(*) AS count FROM mem_entities WHERE scope_id = ?`).get(scope.id).count,
1135
+ action_items: db.prepare(`SELECT COUNT(*) AS count FROM mem_action_items WHERE scope_id = ?`).get(scope.id).count,
1136
+ };
1137
+ return {
1138
+ slug: scope.slug,
1139
+ title: scope.title,
1140
+ description: scope.description,
1141
+ active: activeScope?.slug === scope.slug,
1142
+ counts,
1143
+ };
1144
+ });
1145
+ res.json({ scopes: result });
1146
+ });
1147
+ app.get("/api/memory/inbox", (_req, res) => {
1148
+ const items = listPendingInboxItems();
1149
+ const result = items.map((item) => ({
1150
+ id: item.id,
1151
+ scope_slug: item.scopeId
1152
+ ? getDb().prepare(`SELECT slug FROM mem_scopes WHERE id = ?`).get(item.scopeId)?.slug ?? null
1153
+ : null,
1154
+ kind: item.kind,
1155
+ payload: item.payload,
1156
+ source_agent: item.sourceAgent,
1157
+ created_at: item.createdAt,
1158
+ }));
1159
+ res.json({ items: result, total: result.length });
1160
+ });
1161
+ app.post("/api/memory/inbox/:id/route", (req, res) => {
1162
+ const id = Number(req.params.id);
1163
+ if (!Number.isInteger(id) || id <= 0) {
1164
+ res.status(400).json({ error: "Invalid inbox item id" });
1165
+ return;
1166
+ }
1167
+ const body = parseRequest(inboxRouteSchema, req.body ?? {});
1168
+ const item = getInboxItem(id);
1169
+ if (!item) {
1170
+ res.status(404).json({ error: `Inbox item '${id}' not found` });
1171
+ return;
1172
+ }
1173
+ if (item.status !== "pending") {
1174
+ res.status(409).json({ error: `Inbox item '${id}' is already resolved` });
1175
+ return;
1176
+ }
1177
+ const status = body.action === "accept" ? "accepted" : "rejected";
1178
+ const reason = body.reason ?? (body.action === "accept" ? "Accepted via web UI" : "Rejected via web UI");
1179
+ resolveInboxItem(id, status, reason);
1180
+ log.info({ id, action: body.action }, "inbox item routed via web UI");
1181
+ res.json({ ok: true });
1182
+ });
1183
+ app.get("/api/memory/:scope", (req, res) => {
1184
+ const scopeSlug = String(req.params.scope);
1185
+ const scope = getScope(scopeSlug);
1186
+ if (!scope) {
1187
+ res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
1188
+ return;
1189
+ }
1190
+ const query = parseRequest(memoryEntriesQuerySchema, req.query);
1191
+ const store = query.store ?? "observations";
1192
+ const tier = query.tier;
1193
+ const db = getDb();
1194
+ let entries;
1195
+ let total;
1196
+ if (store === "observations") {
1197
+ if (tier) {
1198
+ 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);
1199
+ 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;
1200
+ }
1201
+ else {
1202
+ 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);
1203
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_observations WHERE scope_id = ? AND archived_at IS NULL`).get(scope.id).n;
1204
+ }
1205
+ }
1206
+ else if (store === "decisions") {
1207
+ if (tier) {
1208
+ 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);
1209
+ 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;
1210
+ }
1211
+ else {
1212
+ 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);
1213
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_decisions WHERE scope_id = ? AND archived_at IS NULL`).get(scope.id).n;
1214
+ }
1215
+ }
1216
+ else if (store === "entities") {
1217
+ if (tier) {
1218
+ 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);
1219
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
1220
+ }
1221
+ else {
1222
+ 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);
1223
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_entities WHERE scope_id = ?`).get(scope.id).n;
1224
+ }
1225
+ }
1226
+ else {
1227
+ if (tier) {
1228
+ 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);
1229
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ? AND tier = ?`).get(scope.id, tier).n;
1230
+ }
1231
+ else {
1232
+ 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);
1233
+ total = db.prepare(`SELECT COUNT(*) AS n FROM mem_action_items WHERE scope_id = ?`).get(scope.id).n;
1234
+ }
1235
+ }
1236
+ res.json({ entries, total });
1237
+ });
1238
+ app.post("/api/memory/:scope/remember", (req, res) => {
1239
+ const scopeSlug = String(req.params.scope);
1240
+ const scope = getScope(scopeSlug);
1241
+ if (!scope) {
1242
+ res.status(404).json({ error: `Memory scope '${scopeSlug}' not found` });
1243
+ return;
1244
+ }
1245
+ const body = parseRequest(memoryRememberSchema, req.body ?? {});
1246
+ if (body.entity_name && !body.entity_kind) {
1247
+ res.status(400).json({ error: "entity_kind is required when entity_name is provided" });
1248
+ return;
1249
+ }
1250
+ const kind = body.kind ?? "observation";
1251
+ const entity = body.entity_name
1252
+ ? upsertEntity({
1253
+ scope_id: scope.id,
1254
+ kind: body.entity_kind,
1255
+ name: body.entity_name,
1256
+ tier: body.tier ?? "warm",
1257
+ })
1258
+ : undefined;
1259
+ if (kind === "decision") {
1260
+ if (!body.title) {
1261
+ res.status(400).json({ error: "title is required when kind='decision'" });
1262
+ return;
1263
+ }
1264
+ const decision = recordDecision({
1265
+ scope_id: scope.id,
1266
+ entity_id: entity?.id,
1267
+ title: body.title,
1268
+ rationale: body.content,
1269
+ decided_at: body.decided_at ?? new Date().toISOString().slice(0, 10),
1270
+ tier: body.tier ?? "warm",
1271
+ });
1272
+ log.info({ id: decision.id, scope: scopeSlug, kind }, "memory written via web UI");
1273
+ res.json({ ok: true, id: String(decision.id) });
1274
+ return;
1275
+ }
1276
+ const observation = recordObservation({
1277
+ scope_id: scope.id,
1278
+ entity_id: entity?.id,
1279
+ content: body.content,
1280
+ source: "agent:web-ui",
1281
+ tier: body.tier ?? "warm",
1282
+ });
1283
+ log.info({ id: observation.id, scope: scopeSlug, kind }, "memory written via web UI");
1284
+ res.json({ ok: true, id: String(observation.id) });
1285
+ });
1286
+ app.post("/api/memory/hooks/git-commit", authMiddleware, (req, res, next) => {
1287
+ const body = parseRequest(gitCommitHookSchema, req.body ?? {});
1288
+ handleGitCommitHook({ message: body.message, stat: body.stat })
1289
+ .then((result) => res.json({ ok: true, observation_id: result.observation_id }))
1290
+ .catch(next);
1291
+ });
1292
+ app.post("/api/memory/hooks/pr-merge", authMiddleware, (req, res, next) => {
1293
+ const body = parseRequest(prMergeHookSchema, req.body ?? {});
1294
+ handlePrMergeHook({
1295
+ number: body.number,
1296
+ title: body.title,
1297
+ body: body.body,
1298
+ files_changed: body.files_changed,
1299
+ })
1300
+ .then((result) => res.json({ ok: true, observation_id: result.observation_id }))
1301
+ .catch(next);
1302
+ });
1075
1303
  app.post("/api/scopes", (req, res) => {
1076
1304
  const body = parseRequest(scopeCreateSchema, req.body ?? {});
1077
1305
  if (getScope(body.slug)) {
@@ -1266,6 +1494,14 @@ app.delete("/api/wiki/page", async (req, res) => {
1266
1494
  const removed = await withWikiWrite(() => deletePage(path));
1267
1495
  res.json({ ok: removed, path });
1268
1496
  });
1497
+ app.post("/api/wiki/korg", authMiddleware, async (req, res) => {
1498
+ const body = parseRequest(korgRequestSchema, req.body ?? {});
1499
+ const result = await routeKorgMessage(body);
1500
+ res.json(result);
1501
+ });
1502
+ app.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
1503
+ res.json({ sessions: listKorgResearchSessions(getDb()) });
1504
+ });
1269
1505
  // ---------------------------------------------------------------------------
1270
1506
  // Skills
1271
1507
  // ---------------------------------------------------------------------------
@@ -241,10 +241,13 @@ test("server channels route returns chapterhouse plus persistent agents in chann
241
241
  const channels = await response.json();
242
242
  assert.deepEqual(channels.map((channel) => channel.key), [
243
243
  "default",
244
+ "agent:korg",
244
245
  ]);
245
246
  assert.deepEqual(channels.map((channel) => channel.label), [
246
247
  "# chapterhouse",
248
+ "# korg",
247
249
  ]);
250
+ assert.equal(channels[1]?.scope, "pkb");
248
251
  });
249
252
  });
250
253
  test("server agents routes return source-aware charter summaries and details", async () => {
@@ -1277,4 +1280,200 @@ test("server caps concurrent SSE connections per IP", async () => {
1277
1280
  API_RATE_LIMIT_SSE_MAX_CONNECTIONS: "2",
1278
1281
  });
1279
1282
  });
1283
+ test("POST /api/memory/active-scope sets and clears the active scope", async () => {
1284
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1285
+ const unauthorized = await fetch(`${baseUrl}/api/memory/active-scope`, {
1286
+ method: "POST",
1287
+ headers: { "content-type": "application/json" },
1288
+ body: JSON.stringify({ scope: "chapterhouse" }),
1289
+ });
1290
+ assert.equal(unauthorized.status, 401);
1291
+ const set = await fetch(`${baseUrl}/api/memory/active-scope`, {
1292
+ method: "POST",
1293
+ headers: { authorization: authHeader, "content-type": "application/json" },
1294
+ body: JSON.stringify({ scope: "chapterhouse" }),
1295
+ });
1296
+ assert.equal(set.status, 200);
1297
+ assert.deepEqual(await set.json(), { ok: true, scope: "chapterhouse" });
1298
+ const verify = await fetch(`${baseUrl}/api/memory/active-scope`, {
1299
+ headers: { authorization: authHeader },
1300
+ });
1301
+ assert.deepEqual(await verify.json(), { slug: "chapterhouse", title: "Chapterhouse" });
1302
+ const clear = await fetch(`${baseUrl}/api/memory/active-scope`, {
1303
+ method: "POST",
1304
+ headers: { authorization: authHeader, "content-type": "application/json" },
1305
+ body: JSON.stringify({ scope: null }),
1306
+ });
1307
+ assert.equal(clear.status, 200);
1308
+ assert.deepEqual(await clear.json(), { ok: true, scope: null });
1309
+ const unknownScope = await fetch(`${baseUrl}/api/memory/active-scope`, {
1310
+ method: "POST",
1311
+ headers: { authorization: authHeader, "content-type": "application/json" },
1312
+ body: JSON.stringify({ scope: "nonexistent-scope" }),
1313
+ });
1314
+ assert.equal(unknownScope.status, 404);
1315
+ });
1316
+ });
1317
+ test("GET /api/memory/scopes lists all scopes with entry counts", async () => {
1318
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1319
+ const unauthorized = await fetch(`${baseUrl}/api/memory/scopes`);
1320
+ assert.equal(unauthorized.status, 401);
1321
+ const res = await fetch(`${baseUrl}/api/memory/scopes`, {
1322
+ headers: { authorization: authHeader },
1323
+ });
1324
+ assert.equal(res.status, 200);
1325
+ const body = await res.json();
1326
+ assert.ok(Array.isArray(body.scopes));
1327
+ assert.ok(body.scopes.length >= 2, "expected at least 2 seeded scopes");
1328
+ const chapterhouse = body.scopes.find((s) => s.slug === "chapterhouse");
1329
+ assert.ok(chapterhouse, "expected chapterhouse scope");
1330
+ assert.ok(typeof chapterhouse.counts.observations === "number");
1331
+ assert.ok(typeof chapterhouse.counts.decisions === "number");
1332
+ });
1333
+ });
1334
+ test("GET /api/memory/:scope returns entries for a scope and 404 for unknown", async () => {
1335
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1336
+ const unauthorized = await fetch(`${baseUrl}/api/memory/chapterhouse`);
1337
+ assert.equal(unauthorized.status, 401);
1338
+ const res = await fetch(`${baseUrl}/api/memory/chapterhouse`, {
1339
+ headers: { authorization: authHeader },
1340
+ });
1341
+ assert.equal(res.status, 200);
1342
+ const body = await res.json();
1343
+ assert.ok(Array.isArray(body.entries));
1344
+ assert.ok(typeof body.total === "number");
1345
+ const withStore = await fetch(`${baseUrl}/api/memory/chapterhouse?store=decisions`, {
1346
+ headers: { authorization: authHeader },
1347
+ });
1348
+ assert.equal(withStore.status, 200);
1349
+ const withTier = await fetch(`${baseUrl}/api/memory/chapterhouse?store=observations&tier=hot`, {
1350
+ headers: { authorization: authHeader },
1351
+ });
1352
+ assert.equal(withTier.status, 200);
1353
+ const notFound = await fetch(`${baseUrl}/api/memory/no-such-scope`, {
1354
+ headers: { authorization: authHeader },
1355
+ });
1356
+ assert.equal(notFound.status, 404);
1357
+ assert.deepEqual(await notFound.json(), { error: "Memory scope 'no-such-scope' not found" });
1358
+ });
1359
+ });
1360
+ test("POST /api/memory/:scope/remember writes an observation or decision", async () => {
1361
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1362
+ const unauthorized = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1363
+ method: "POST",
1364
+ headers: { "content-type": "application/json" },
1365
+ body: JSON.stringify({ content: "Test observation" }),
1366
+ });
1367
+ assert.equal(unauthorized.status, 401);
1368
+ const obs = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1369
+ method: "POST",
1370
+ headers: { authorization: authHeader, "content-type": "application/json" },
1371
+ body: JSON.stringify({ content: "Chapterhouse uses SQLite for memory." }),
1372
+ });
1373
+ assert.equal(obs.status, 200);
1374
+ const obsBody = await obs.json();
1375
+ assert.equal(obsBody.ok, true);
1376
+ assert.ok(typeof obsBody.id === "string");
1377
+ const dec = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1378
+ method: "POST",
1379
+ headers: { authorization: authHeader, "content-type": "application/json" },
1380
+ body: JSON.stringify({ kind: "decision", title: "Use SQLite", content: "SQLite chosen for persistence." }),
1381
+ });
1382
+ assert.equal(dec.status, 200);
1383
+ const decBody = await dec.json();
1384
+ assert.equal(decBody.ok, true);
1385
+ const noTitle = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1386
+ method: "POST",
1387
+ headers: { authorization: authHeader, "content-type": "application/json" },
1388
+ body: JSON.stringify({ kind: "decision", content: "Missing title." }),
1389
+ });
1390
+ assert.equal(noTitle.status, 400);
1391
+ const notFound = await fetch(`${baseUrl}/api/memory/missing-scope/remember`, {
1392
+ method: "POST",
1393
+ headers: { authorization: authHeader, "content-type": "application/json" },
1394
+ body: JSON.stringify({ content: "Will not land." }),
1395
+ });
1396
+ assert.equal(notFound.status, 404);
1397
+ });
1398
+ });
1399
+ test("GET /api/memory/inbox lists pending proposals", async () => {
1400
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1401
+ const unauthorized = await fetch(`${baseUrl}/api/memory/inbox`);
1402
+ assert.equal(unauthorized.status, 401);
1403
+ const empty = await fetch(`${baseUrl}/api/memory/inbox`, {
1404
+ headers: { authorization: authHeader },
1405
+ });
1406
+ assert.equal(empty.status, 200);
1407
+ const body = await empty.json();
1408
+ assert.ok(Array.isArray(body.items));
1409
+ assert.ok(typeof body.total === "number");
1410
+ assert.equal(body.items.length, body.total);
1411
+ });
1412
+ });
1413
+ test("POST /api/memory/inbox/:id/route accepts and rejects proposals", async () => {
1414
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
1415
+ // First queue a proposal via the remember endpoint so we have something to route
1416
+ const rememberRes = await fetch(`${baseUrl}/api/memory/chapterhouse/remember`, {
1417
+ method: "POST",
1418
+ headers: { authorization: authHeader, "content-type": "application/json" },
1419
+ body: JSON.stringify({ content: "Proposal for inbox routing test." }),
1420
+ });
1421
+ assert.equal(rememberRes.status, 200);
1422
+ const notFound = await fetch(`${baseUrl}/api/memory/inbox/99999/route`, {
1423
+ method: "POST",
1424
+ headers: { authorization: authHeader, "content-type": "application/json" },
1425
+ body: JSON.stringify({ action: "accept" }),
1426
+ });
1427
+ assert.equal(notFound.status, 404);
1428
+ const badId = await fetch(`${baseUrl}/api/memory/inbox/not-a-number/route`, {
1429
+ method: "POST",
1430
+ headers: { authorization: authHeader, "content-type": "application/json" },
1431
+ body: JSON.stringify({ action: "accept" }),
1432
+ });
1433
+ assert.equal(badId.status, 400);
1434
+ const unauthorized = await fetch(`${baseUrl}/api/memory/inbox/1/route`, {
1435
+ method: "POST",
1436
+ headers: { "content-type": "application/json" },
1437
+ body: JSON.stringify({ action: "accept" }),
1438
+ });
1439
+ assert.equal(unauthorized.status, 401);
1440
+ });
1441
+ });
1442
+ test("GET /api/wiki/korg/sessions returns grouped active research sessions", async () => {
1443
+ await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
1444
+ const db = new Database(getProjectDbPath(testRoot));
1445
+ try {
1446
+ db.prepare(`
1447
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1448
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1449
+ `).run("src-1", "text", "session-one", "First source", "2026-05-14T21:00:00.000Z", "sources/src-1.md", "open questions remain", "[]", "active", "compiler-research", "Compiler research");
1450
+ db.prepare(`
1451
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1452
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1453
+ `).run("src-2", "text", "session-one-b", "Second source", "2026-05-14T22:00:00.000Z", "sources/src-2.md", "follow-up questions", "[]", "active", "compiler-research", "Compiler research");
1454
+ db.prepare(`
1455
+ INSERT INTO wiki_sources (id, source_type, origin, title, ingested_at, raw_path, parsed_content, pages_updated, status, session_id, session_name)
1456
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1457
+ `).run("src-3", "text", "archived-session", "Archived source", "2026-05-13T20:00:00.000Z", "sources/src-3.md", "done", "[]", "archived", "old-session", "Old session");
1458
+ }
1459
+ finally {
1460
+ db.close();
1461
+ }
1462
+ const response = await fetch(`${baseUrl}/api/wiki/korg/sessions`, {
1463
+ headers: { authorization: authHeader },
1464
+ });
1465
+ assert.equal(response.status, 200);
1466
+ assert.deepEqual(await response.json(), {
1467
+ sessions: [
1468
+ {
1469
+ id: "compiler-research",
1470
+ name: "Compiler research",
1471
+ source_count: 2,
1472
+ open_questions: 0,
1473
+ last_activity: "2026-05-14T22:00:00.000Z",
1474
+ },
1475
+ ],
1476
+ });
1477
+ });
1478
+ });
1280
1479
  //# sourceMappingURL=server.test.js.map