chapterhouse 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/agent-edit-access.js +11 -0
  3. package/dist/api/agents.api.test.js +48 -0
  4. package/dist/api/korg.js +34 -0
  5. package/dist/api/korg.test.js +42 -0
  6. package/dist/api/server.js +420 -13
  7. package/dist/api/server.test.js +533 -3
  8. package/dist/config.js +28 -0
  9. package/dist/config.test.js +20 -0
  10. package/dist/copilot/agent-event-bus.js +1 -0
  11. package/dist/copilot/agents.js +117 -50
  12. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  13. package/dist/copilot/agents.parse.test.js +69 -0
  14. package/dist/copilot/agents.test.js +137 -2
  15. package/dist/copilot/orchestrator.js +62 -13
  16. package/dist/copilot/orchestrator.test.js +130 -8
  17. package/dist/copilot/session-manager.js +34 -0
  18. package/dist/copilot/system-message.js +11 -10
  19. package/dist/copilot/system-message.test.js +6 -1
  20. package/dist/copilot/tools.js +184 -376
  21. package/dist/copilot/tools.memory.test.js +32 -0
  22. package/dist/copilot/tools.wiki.test.js +53 -59
  23. package/dist/daemon.js +9 -0
  24. package/dist/memory/decisions.js +6 -5
  25. package/dist/memory/entities.js +20 -9
  26. package/dist/memory/hooks.js +151 -0
  27. package/dist/memory/hooks.test.js +325 -0
  28. package/dist/memory/hot-tier.js +37 -0
  29. package/dist/memory/hot-tier.test.js +30 -0
  30. package/dist/memory/housekeeping-scheduler.js +35 -0
  31. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  32. package/dist/memory/inbox.js +10 -0
  33. package/dist/memory/index.js +3 -1
  34. package/dist/memory/migration.js +244 -0
  35. package/dist/memory/migration.test.js +100 -0
  36. package/dist/memory/reflect.js +273 -0
  37. package/dist/memory/reflect.test.js +254 -0
  38. package/dist/store/db.js +119 -4
  39. package/dist/store/db.test.js +19 -1
  40. package/dist/test/setup-env.js +3 -1
  41. package/dist/test/setup-env.test.js +8 -1
  42. package/dist/wiki/consolidation.js +641 -0
  43. package/dist/wiki/consolidation.test.js +140 -0
  44. package/dist/wiki/frontmatter.js +48 -0
  45. package/dist/wiki/frontmatter.test.js +42 -0
  46. package/dist/wiki/index-manager.js +246 -330
  47. package/dist/wiki/index-manager.test.js +138 -145
  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/migrate-topics.test.js +16 -6
  53. package/dist/wiki/scheduler.js +118 -0
  54. package/dist/wiki/scheduler.test.js +64 -0
  55. package/dist/wiki/timeline.js +51 -0
  56. package/dist/wiki/timeline.test.js +65 -0
  57. package/dist/wiki/topic-structure.js +1 -1
  58. package/package.json +3 -1
  59. package/skills/pkb-ideas/SKILL.md +78 -0
  60. package/skills/pkb-ideas/_meta.json +4 -0
  61. package/skills/pkb-org/SKILL.md +82 -0
  62. package/skills/pkb-org/_meta.json +4 -0
  63. package/skills/pkb-people/SKILL.md +74 -0
  64. package/skills/pkb-people/_meta.json +4 -0
  65. package/skills/pkb-research/SKILL.md +83 -0
  66. package/skills/pkb-research/_meta.json +4 -0
  67. package/skills/pkb-source/SKILL.md +38 -0
  68. package/skills/pkb-source/_meta.json +4 -0
  69. package/skills/wiki-conventions/SKILL.md +5 -5
  70. package/web/dist/assets/index-5kz9aRU9.css +10 -0
  71. package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
  72. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  73. package/web/dist/index.html +2 -2
  74. package/dist/wiki/context.js +0 -138
  75. package/dist/wiki/fix.js +0 -335
  76. package/dist/wiki/fix.test.js +0 -350
  77. package/dist/wiki/lint.js +0 -451
  78. package/dist/wiki/lint.test.js +0 -329
  79. package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
  80. package/web/dist/assets/index-DknKAtDS.css +0 -10
@@ -1,18 +1,19 @@
1
1
  import cors from "cors";
2
2
  import express from "express";
3
3
  import helmet from "helmet";
4
- import { existsSync } from "fs";
5
- import { join, dirname } from "path";
4
+ import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
5
+ import { join, dirname, resolve, sep } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { z } from "zod";
8
- import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
8
+ import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey, getPersistentAgentSessionState, reloadPersistentAgent } from "../copilot/orchestrator.js";
9
9
  import { agentEventBus } from "../copilot/agent-event-bus.js";
10
- import { ensureDefaultAgents, getAgentRegistry, loadAgents } from "../copilot/agents.js";
10
+ import { ensureDefaultAgents, getAgent, getAgentRegistry, isBuiltinAgent, loadAgents, notifyAgentSaved, parseAgentMd, parseAgentMdOrThrow, serializeAgentMd, setAgentSaveRuntimeHooks, SLUG_REGEX, } from "../copilot/agents.js";
11
11
  import { config, persistModel } from "../config.js";
12
12
  import { ModeContext } from "../mode-context.js";
13
13
  import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
14
14
  import { searchIndex, parseIndex } from "../wiki/index-manager.js";
15
15
  import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
16
+ import { assertAgentEditAccess } from "./agent-edit-access.js";
16
17
  import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
17
18
  import { createTeamRouter } from "./team.js";
18
19
  import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, } from "../wiki/fs.js";
@@ -23,7 +24,7 @@ import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
23
24
  import { withWikiWrite } from "../wiki/lock.js";
24
25
  import { listSkills, removeSkill } from "../copilot/skills.js";
25
26
  import { restartDaemon } from "../daemon.js";
26
- import { API_TOKEN_PATH } from "../paths.js";
27
+ import { AGENTS_DIR, API_TOKEN_PATH } from "../paths.js";
27
28
  import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
28
29
  import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
29
30
  import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
@@ -32,8 +33,14 @@ import { formatSseData, formatSseEvent } from "./sse.js";
32
33
  import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
33
34
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
34
35
  import { childLogger } from "../util/logger.js";
35
- import { getActiveScope } from "../memory/active-scope.js";
36
- 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";
37
44
  const log = childLogger("server");
38
45
  const modeContext = new ModeContext(config);
39
46
  void searchIndex; // re-exported by index-manager; reference here documents the dep
@@ -78,6 +85,22 @@ const autoRequestSchema = z.object({
78
85
  const wikiWriteSchema = z.object({
79
86
  content: z.string({ error: "Missing 'content' string in request body" }),
80
87
  }).strict();
88
+ const agentPatchSchema = z.object({
89
+ name: requiredString("name must be a non-empty string").optional(),
90
+ description: requiredString("description must be a non-empty string").optional(),
91
+ model: requiredString("model must be a non-empty string").optional(),
92
+ systemPrompt: z.string().optional(),
93
+ }).passthrough().superRefine((value, ctx) => {
94
+ for (const key of ["slug", "scope", "tools", "skills"]) {
95
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
96
+ ctx.addIssue({
97
+ code: "custom",
98
+ path: [key],
99
+ message: `'${key}' is read-only`,
100
+ });
101
+ }
102
+ }
103
+ });
81
104
  const projectCreateSchema = z.object({
82
105
  slug: requiredString("Missing 'slug' in request body")
83
106
  .regex(/^[a-z0-9][a-z0-9-]*$/, "Project slug must be a lowercase slug"),
@@ -90,6 +113,41 @@ const scopeCreateSchema = z.object({
90
113
  title: requiredString("Missing 'title' in request body"),
91
114
  description: z.string().optional(),
92
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();
93
151
  const projectHardRulesSchema = z.object({
94
152
  hardRules: z.object({
95
153
  auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
@@ -284,6 +342,18 @@ Create your first page via the wiki UI or by editing files under \`pages/\`.
284
342
  const sseClients = new Map();
285
343
  const pendingSseMessages = [];
286
344
  let connectionCounter = 0;
345
+ function broadcastSsePayload(payload) {
346
+ for (const [, res] of sseClients) {
347
+ res.write(formatSseData(payload));
348
+ }
349
+ }
350
+ setAgentSaveRuntimeHooks({
351
+ getPersistentSessionState: getPersistentAgentSessionState,
352
+ reloadPersistentSession: reloadPersistentAgent,
353
+ emitAgentReloadEvent: (event) => {
354
+ broadcastSsePayload(event);
355
+ },
356
+ });
287
357
  // ---------------------------------------------------------------------------
288
358
  // Bootstrap — hands the API token to the same-origin SPA on first load.
289
359
  // Loopback-only by IP / Origin check.
@@ -316,18 +386,145 @@ const handleHealth = (_req, res) => {
316
386
  };
317
387
  app.get("/status", handleHealth);
318
388
  app.get("/health", handleHealth);
389
+ function getLoadedAgents() {
390
+ let agents = getAgentRegistry();
391
+ if (agents.length === 0) {
392
+ ensureDefaultAgents();
393
+ agents = loadAgents();
394
+ }
395
+ return agents;
396
+ }
397
+ function getAgentLastEdited(slug) {
398
+ try {
399
+ return statSync(join(AGENTS_DIR, `${slug}.agent.md`)).mtime.toISOString();
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
319
405
  // ---------------------------------------------------------------------------
320
406
  // Workers / agents
321
407
  // ---------------------------------------------------------------------------
322
408
  app.get("/api/agents", (_req, res) => {
323
- res.json(getAgentInfo());
409
+ const agents = getLoadedAgents()
410
+ .map((agent) => ({
411
+ name: agent.name,
412
+ slug: agent.slug,
413
+ description: agent.description,
414
+ model: agent.model,
415
+ scope: agent.scope ?? null,
416
+ type: isBuiltinAgent(agent.slug) ? "builtin" : "custom",
417
+ lastEdited: getAgentLastEdited(agent.slug),
418
+ }))
419
+ .sort((left, right) => {
420
+ if (left.type !== right.type) {
421
+ return left.type === "builtin" ? -1 : 1;
422
+ }
423
+ return left.name.localeCompare(right.name);
424
+ });
425
+ res.json(agents);
324
426
  });
325
- app.get("/api/channels", (_req, res) => {
326
- let agents = getAgentRegistry();
327
- if (agents.length === 0) {
328
- ensureDefaultAgents();
329
- agents = loadAgents();
427
+ app.get("/api/agents/:slug", (req, res, next) => {
428
+ const slugParam = req.params.slug;
429
+ const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
430
+ if (slug === "stream") {
431
+ next();
432
+ return;
433
+ }
434
+ if (!SLUG_REGEX.test(slug)) {
435
+ res.status(400).json({ error: "Invalid slug" });
436
+ return;
437
+ }
438
+ getLoadedAgents();
439
+ const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
440
+ const resolvedAgentsDir = resolve(AGENTS_DIR);
441
+ const resolvedFilePath = resolve(filePath);
442
+ if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
443
+ res.status(403).json({ error: "Access denied" });
444
+ return;
445
+ }
446
+ let content;
447
+ try {
448
+ content = readFileSync(filePath, "utf-8");
449
+ }
450
+ catch {
451
+ res.status(404).json({ error: `Agent '${slug}' not found` });
452
+ return;
453
+ }
454
+ const agent = parseAgentMd(content, slug);
455
+ if (!agent) {
456
+ res.status(500).json({ error: `Agent '${slug}' could not be parsed` });
457
+ return;
458
+ }
459
+ const builtin = isBuiltinAgent(slug);
460
+ res.json({
461
+ name: agent.name,
462
+ slug: agent.slug,
463
+ description: agent.description,
464
+ model: agent.model,
465
+ scope: agent.scope ?? null,
466
+ persistent: agent.persistent ?? false,
467
+ skills: agent.skills ?? [],
468
+ type: builtin ? "builtin" : "custom",
469
+ editable: !builtin,
470
+ systemPrompt: agent.systemMessage,
471
+ });
472
+ });
473
+ app.patch("/api/agents/:slug", async (req, res) => {
474
+ const slugParam = req.params.slug;
475
+ const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
476
+ if (!SLUG_REGEX.test(slug)) {
477
+ throw new BadRequestError("Invalid slug");
478
+ }
479
+ if (modeContext.isTeam() && req.user?.role !== "team-lead") {
480
+ throw new ForbiddenError("Forbidden");
481
+ }
482
+ const patch = parseRequest(agentPatchSchema, req.body ?? {});
483
+ const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
484
+ const resolvedAgentsDir = resolve(AGENTS_DIR);
485
+ const resolvedFilePath = resolve(filePath);
486
+ if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
487
+ throw new ForbiddenError("Access denied");
488
+ }
489
+ assertAgentEditAccess({ entraAuthEnabled: config.entraAuthEnabled }, req.user);
490
+ if (isBuiltinAgent(slug)) {
491
+ throw new ForbiddenError("Built-in agents are read-only");
492
+ }
493
+ if (!existsSync(filePath)) {
494
+ throw new NotFoundError("Agent not found");
495
+ }
496
+ try {
497
+ const current = parseAgentMdOrThrow(readFileSync(filePath, "utf-8"), slug);
498
+ const updated = {
499
+ ...current,
500
+ name: patch.name ?? current.name,
501
+ description: patch.description ?? current.description,
502
+ model: patch.model ?? current.model,
503
+ systemMessage: patch.systemPrompt ?? current.systemMessage,
504
+ };
505
+ const nextContent = serializeAgentMd(updated);
506
+ writeFileSync(filePath, nextContent, "utf-8");
507
+ await notifyAgentSaved(slug, updated);
508
+ res.json({
509
+ name: updated.name,
510
+ slug: updated.slug,
511
+ description: updated.description,
512
+ model: updated.model,
513
+ scope: updated.scope ?? null,
514
+ persistent: updated.persistent ?? false,
515
+ skills: updated.skills ?? [],
516
+ type: "custom",
517
+ editable: true,
518
+ systemPrompt: updated.systemMessage,
519
+ });
520
+ }
521
+ catch (error) {
522
+ const message = error instanceof Error ? error.message : String(error);
523
+ throw new BadRequestError(`Invalid content: ${message}`);
330
524
  }
525
+ });
526
+ app.get("/api/channels", (_req, res) => {
527
+ const agents = getLoadedAgents();
331
528
  const persistentAgentChannels = agents
332
529
  .filter((agent) => agent.persistent)
333
530
  .map((agent) => ({
@@ -650,6 +847,21 @@ app.post("/api/cancel", async (_req, res) => {
650
847
  }
651
848
  res.json({ status: "ok", cancelled });
652
849
  });
850
+ app.post("/api/agents/:slug/reload-confirm", async (req, res) => {
851
+ const slugParam = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
852
+ const slug = slugParam?.trim() || "";
853
+ const agent = getAgent(slug);
854
+ if (!agent?.persistent) {
855
+ throw new NotFoundError("Agent not found");
856
+ }
857
+ const sessionKey = `agent:${agent.slug}`;
858
+ await interruptSessionTurn(sessionKey);
859
+ const reloadResult = await reloadPersistentAgent(agent.slug);
860
+ if (reloadResult === "reloaded") {
861
+ broadcastSsePayload({ type: "agent_reloaded", slug: agent.slug, reason: "confirmed_restart" });
862
+ }
863
+ res.json({ status: "ok" });
864
+ });
653
865
  // Cancel the active turn for one session key without touching other channels.
654
866
  app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
655
867
  const sessionKey = Array.isArray(req.params.sessionKey)
@@ -901,6 +1113,193 @@ app.get("/api/memory/active-scope", (_req, res) => {
901
1113
  title: activeScope.title,
902
1114
  });
903
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
+ });
904
1303
  app.post("/api/scopes", (req, res) => {
905
1304
  const body = parseRequest(scopeCreateSchema, req.body ?? {});
906
1305
  if (getScope(body.slug)) {
@@ -1095,6 +1494,14 @@ app.delete("/api/wiki/page", async (req, res) => {
1095
1494
  const removed = await withWikiWrite(() => deletePage(path));
1096
1495
  res.json({ ok: removed, path });
1097
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
+ });
1098
1505
  // ---------------------------------------------------------------------------
1099
1506
  // Skills
1100
1507
  // ---------------------------------------------------------------------------