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.
- package/agents/korg.agent.md +65 -0
- package/dist/api/korg.js +34 -0
- package/dist/api/korg.test.js +42 -0
- package/dist/api/server.js +238 -2
- package/dist/api/server.test.js +199 -0
- package/dist/config.js +28 -0
- package/dist/config.test.js +20 -0
- package/dist/copilot/agents.js +3 -4
- package/dist/copilot/agents.test.js +12 -1
- package/dist/copilot/orchestrator.js +12 -1
- package/dist/copilot/orchestrator.test.js +3 -7
- package/dist/copilot/system-message.js +12 -10
- package/dist/copilot/system-message.test.js +6 -1
- package/dist/copilot/tools.js +193 -375
- package/dist/copilot/tools.memory.test.js +32 -0
- package/dist/copilot/tools.wiki.test.js +80 -59
- package/dist/copilot/turn-event-log-env.test.js +11 -15
- package/dist/daemon.js +19 -0
- package/dist/memory/decisions.js +6 -5
- package/dist/memory/entities.js +20 -9
- package/dist/memory/eot.js +30 -8
- package/dist/memory/eot.test.js +220 -6
- package/dist/memory/hooks.js +151 -0
- package/dist/memory/hooks.test.js +325 -0
- package/dist/memory/hot-tier.js +37 -0
- package/dist/memory/hot-tier.test.js +30 -0
- package/dist/memory/housekeeping-scheduler.js +35 -0
- package/dist/memory/housekeeping-scheduler.test.js +50 -0
- package/dist/memory/inbox.js +10 -0
- package/dist/memory/index.js +3 -1
- package/dist/memory/migration.js +244 -0
- package/dist/memory/migration.test.js +108 -0
- package/dist/memory/reflect.js +273 -0
- package/dist/memory/reflect.test.js +254 -0
- package/dist/paths.js +31 -11
- package/dist/store/db.js +187 -4
- package/dist/store/db.test.js +66 -2
- package/dist/test/helpers/reset-singletons.js +8 -0
- package/dist/test/helpers/reset-singletons.test.js +37 -0
- package/dist/test/setup-env.js +9 -1
- package/dist/wiki/consolidation.js +641 -0
- package/dist/wiki/consolidation.test.js +143 -0
- package/dist/wiki/frontmatter.js +48 -0
- package/dist/wiki/frontmatter.test.js +42 -0
- package/dist/wiki/fs.js +22 -13
- package/dist/wiki/index-manager.js +305 -330
- package/dist/wiki/index-manager.test.js +265 -144
- package/dist/wiki/ingest.js +347 -0
- package/dist/wiki/ingest.test.js +111 -0
- package/dist/wiki/links.js +151 -0
- package/dist/wiki/links.test.js +176 -0
- package/dist/wiki/log-manager.js +8 -5
- package/dist/wiki/log-manager.test.js +4 -0
- package/dist/wiki/migrate-topics.test.js +16 -6
- package/dist/wiki/scheduler.js +118 -0
- package/dist/wiki/scheduler.test.js +64 -0
- package/dist/wiki/timeline.js +51 -0
- package/dist/wiki/timeline.test.js +65 -0
- package/dist/wiki/topic-structure.js +1 -1
- package/package.json +1 -1
- package/skills/pkb-ideas/SKILL.md +78 -0
- package/skills/pkb-ideas/_meta.json +4 -0
- package/skills/pkb-org/SKILL.md +82 -0
- package/skills/pkb-org/_meta.json +4 -0
- package/skills/pkb-people/SKILL.md +74 -0
- package/skills/pkb-people/_meta.json +4 -0
- package/skills/pkb-research/SKILL.md +83 -0
- package/skills/pkb-research/_meta.json +4 -0
- package/skills/pkb-source/SKILL.md +38 -0
- package/skills/pkb-source/_meta.json +4 -0
- package/skills/wiki-conventions/SKILL.md +5 -5
- package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
- package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
- package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/dist/wiki/context.js +0 -138
- package/dist/wiki/fix.js +0 -335
- package/dist/wiki/fix.test.js +0 -350
- package/dist/wiki/lint.js +0 -451
- package/dist/wiki/lint.test.js +0 -329
- 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.
|
package/dist/api/korg.js
ADDED
|
@@ -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
|
package/dist/api/server.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/dist/api/server.test.js
CHANGED
|
@@ -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
|