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.
- package/agents/korg.agent.md +65 -0
- package/dist/api/agent-edit-access.js +11 -0
- package/dist/api/agents.api.test.js +48 -0
- package/dist/api/korg.js +34 -0
- package/dist/api/korg.test.js +42 -0
- package/dist/api/server.js +420 -13
- package/dist/api/server.test.js +533 -3
- package/dist/config.js +28 -0
- package/dist/config.test.js +20 -0
- package/dist/copilot/agent-event-bus.js +1 -0
- package/dist/copilot/agents.js +117 -50
- package/dist/copilot/agents.mcp-servers.test.js +87 -0
- package/dist/copilot/agents.parse.test.js +69 -0
- package/dist/copilot/agents.test.js +137 -2
- package/dist/copilot/orchestrator.js +62 -13
- package/dist/copilot/orchestrator.test.js +130 -8
- package/dist/copilot/session-manager.js +34 -0
- package/dist/copilot/system-message.js +11 -10
- package/dist/copilot/system-message.test.js +6 -1
- package/dist/copilot/tools.js +184 -376
- package/dist/copilot/tools.memory.test.js +32 -0
- package/dist/copilot/tools.wiki.test.js +53 -59
- package/dist/daemon.js +9 -0
- package/dist/memory/decisions.js +6 -5
- package/dist/memory/entities.js +20 -9
- 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 +100 -0
- package/dist/memory/reflect.js +273 -0
- package/dist/memory/reflect.test.js +254 -0
- package/dist/store/db.js +119 -4
- package/dist/store/db.test.js +19 -1
- package/dist/test/setup-env.js +3 -1
- package/dist/test/setup-env.test.js +8 -1
- package/dist/wiki/consolidation.js +641 -0
- package/dist/wiki/consolidation.test.js +140 -0
- package/dist/wiki/frontmatter.js +48 -0
- package/dist/wiki/frontmatter.test.js +42 -0
- package/dist/wiki/index-manager.js +246 -330
- package/dist/wiki/index-manager.test.js +138 -145
- 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/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 +3 -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-5kz9aRU9.css +10 -0
- package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
- 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-B5oDsQ5y.js.map +0 -1
- package/web/dist/assets/index-DknKAtDS.css +0 -10
|
@@ -555,4 +555,36 @@ test("memory_promote and memory_demote are orchestrator-only manual tier control
|
|
|
555
555
|
assert.equal(demoted.ok, true);
|
|
556
556
|
assert.equal(db.prepare(`SELECT tier FROM mem_observations WHERE id = ?`).get(observationId).tier, "cold");
|
|
557
557
|
});
|
|
558
|
+
test("memory_remember dual-writes decisions to the wiki timeline when an entity is provided", async () => {
|
|
559
|
+
const { toolsModule, agentsModule, dbModule } = await loadModules();
|
|
560
|
+
const tools = toolsModule.createTools({
|
|
561
|
+
client: { async listModels() { return []; } },
|
|
562
|
+
onAgentTaskComplete: () => { },
|
|
563
|
+
});
|
|
564
|
+
const wikiFs = await import(new URL("../wiki/fs.js", import.meta.url).href);
|
|
565
|
+
wikiFs.ensureWikiStructure();
|
|
566
|
+
wikiFs.writePage("pages/projects/project-x/index.md", "---\ntitle: Project X\nsummary: Project X summary\nupdated: 2026-05-14\ntags: []\n---\n\n# Project X\n\n## Summary\n\nCurrent state.\n");
|
|
567
|
+
const bindToolsToAgent = agentsModule.bindToolsToAgent;
|
|
568
|
+
const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
|
|
569
|
+
const memoryRemember = findTool(chapterhouseTools, "memory_remember");
|
|
570
|
+
const result = await memoryRemember.handler({
|
|
571
|
+
scope: "chapterhouse",
|
|
572
|
+
kind: "decision",
|
|
573
|
+
entity_name: "project-x",
|
|
574
|
+
entity_kind: "project",
|
|
575
|
+
title: "Ship append-only decisions",
|
|
576
|
+
content: "Store the decision in SQLite and mirror it into the project timeline.",
|
|
577
|
+
}, {});
|
|
578
|
+
assert.equal(result.ok, true);
|
|
579
|
+
const db = dbModule.getDb();
|
|
580
|
+
const decision = db.prepare(`SELECT title, rationale FROM mem_decisions WHERE title = ?`).get("Ship append-only decisions");
|
|
581
|
+
assert.deepEqual(decision, {
|
|
582
|
+
title: "Ship append-only decisions",
|
|
583
|
+
rationale: "Store the decision in SQLite and mirror it into the project timeline.",
|
|
584
|
+
});
|
|
585
|
+
const page = wikiFs.readPage("pages/projects/project-x/index.md") ?? "";
|
|
586
|
+
assert.match(page, /## Timeline/);
|
|
587
|
+
assert.match(page, /Ship append-only decisions/);
|
|
588
|
+
assert.match(page, /Store the decision in SQLite and mirror it into the project timeline\./);
|
|
589
|
+
});
|
|
558
590
|
//# sourceMappingURL=tools.memory.test.js.map
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
4
|
import test from "node:test";
|
|
6
5
|
async function loadToolsModule() {
|
|
7
6
|
return await import(new URL(`./tools.js?cachebust=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
@@ -9,10 +8,14 @@ async function loadToolsModule() {
|
|
|
9
8
|
async function readWikiArtifacts() {
|
|
10
9
|
const nonce = `${Date.now()}-${Math.random()}`;
|
|
11
10
|
const wikiFs = await import(new URL(`../wiki/fs.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
-
|
|
11
|
+
const indexManager = await import(new URL(`../wiki/index-manager.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
+
return { wikiFs, indexManager };
|
|
13
13
|
}
|
|
14
|
+
test.before(() => {
|
|
15
|
+
mkdirSync(join(process.cwd(), ".test-work"), { recursive: true });
|
|
16
|
+
});
|
|
14
17
|
test.beforeEach(() => {
|
|
15
|
-
process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(
|
|
18
|
+
process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(process.cwd(), ".test-work", "chapterhouse-tools-wiki-"));
|
|
16
19
|
process.env.CHAPTERHOUSE_AGENT_NAME = "tools-test-agent";
|
|
17
20
|
});
|
|
18
21
|
test.afterEach(async () => {
|
|
@@ -38,9 +41,9 @@ test("wiki_update returns validation errors instead of throwing for invalid fron
|
|
|
38
41
|
content: "# Chapterhouse\n\nRuntime notes.\n",
|
|
39
42
|
});
|
|
40
43
|
assert.deepEqual(result, {
|
|
41
|
-
error: "Wiki page frontmatter violates the required shape: missing
|
|
44
|
+
error: "Wiki page frontmatter violates the required shape: missing 'summary'. Use:\n---\ntitle: <title>\nsummary: <plain-text one-line summary, max 200 chars>\nupdated: YYYY-MM-DD\ntags: []\nrelated: []\n---",
|
|
42
45
|
});
|
|
43
|
-
const wikiFs = await readWikiArtifacts();
|
|
46
|
+
const { wikiFs } = await readWikiArtifacts();
|
|
44
47
|
assert.equal(wikiFs.readPage("pages/shared/chapterhouse.md"), undefined);
|
|
45
48
|
});
|
|
46
49
|
test("wiki_update returns descriptive validation errors for malformed summaries and unknown tags", async () => {
|
|
@@ -99,22 +102,28 @@ Runtime notes.
|
|
|
99
102
|
`,
|
|
100
103
|
});
|
|
101
104
|
assert.equal(result, "Wiki page updated: Chapterhouse (pages/shared/chapterhouse.md)");
|
|
102
|
-
const wikiFs = await readWikiArtifacts();
|
|
105
|
+
const { wikiFs, indexManager } = await readWikiArtifacts();
|
|
106
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
103
107
|
assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /summary: Runtime notes/);
|
|
104
|
-
assert.
|
|
108
|
+
assert.deepEqual(indexManager.parseIndex(), [
|
|
109
|
+
{
|
|
110
|
+
path: "pages/shared/chapterhouse.md",
|
|
111
|
+
title: "Chapterhouse",
|
|
112
|
+
summary: "Runtime notes",
|
|
113
|
+
section: "shared",
|
|
114
|
+
tags: ["engineering"],
|
|
115
|
+
updated: today,
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
105
118
|
});
|
|
106
|
-
test("wiki tools append audit entries to pages/_meta/log.md", async () => {
|
|
119
|
+
test("retained wiki tools append audit entries to pages/_meta/log.md", async () => {
|
|
107
120
|
const toolsModule = await loadToolsModule();
|
|
108
121
|
const tools = toolsModule.createTools({
|
|
109
122
|
client: { async listModels() { return []; } },
|
|
110
123
|
onAgentTaskComplete: () => { },
|
|
111
124
|
});
|
|
112
125
|
const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
|
|
113
|
-
|
|
114
|
-
const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
|
|
115
|
-
const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
|
|
116
|
-
const forget = tools.find((entry) => entry.name === "forget");
|
|
117
|
-
assert.ok(wikiUpdate && wikiIngest && wikiLint && wikiRebuildIndex && forget);
|
|
126
|
+
assert.ok(wikiUpdate);
|
|
118
127
|
await wikiUpdate.handler({
|
|
119
128
|
path: "pages/shared/chapterhouse.md",
|
|
120
129
|
title: "Chapterhouse",
|
|
@@ -133,66 +142,51 @@ tags: [engineering]
|
|
|
133
142
|
Runtime notes with enough content to avoid incidental lint noise in the audit-log test.
|
|
134
143
|
`,
|
|
135
144
|
});
|
|
136
|
-
await
|
|
137
|
-
type: "text",
|
|
138
|
-
source: "Source content for the wiki ingest audit log test.",
|
|
139
|
-
name: "audit-log-source",
|
|
140
|
-
});
|
|
141
|
-
await wikiLint.handler({});
|
|
142
|
-
await wikiRebuildIndex.handler({});
|
|
143
|
-
await forget.handler({ page_path: "pages/shared/chapterhouse.md" });
|
|
144
|
-
const wikiFs = await readWikiArtifacts();
|
|
145
|
+
const { wikiFs } = await readWikiArtifacts();
|
|
145
146
|
const log = wikiFs.readLogFile();
|
|
146
147
|
assert.match(log, /update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| tools-test-agent/);
|
|
147
|
-
assert.match(log, /ingest \| Ingested text: audit-log-source \(\d+ chars\) \| tools-test-agent/);
|
|
148
|
-
assert.match(log, /lint \| .* \| tools-test-agent/);
|
|
149
|
-
assert.match(log, /rebuild-index \| wiki_rebuild_index: rebuilt \d+ entries from pages on disk \| tools-test-agent/);
|
|
150
|
-
assert.match(log, /delete \| forget: deleted page pages\/shared\/chapterhouse\.md \| tools-test-agent/);
|
|
151
148
|
});
|
|
152
|
-
test("
|
|
149
|
+
test("removed legacy wiki tools return helpful stub messages without mutating wiki pages", async () => {
|
|
153
150
|
const toolsModule = await loadToolsModule();
|
|
154
151
|
const tools = toolsModule.createTools({
|
|
155
152
|
client: { async listModels() { return []; } },
|
|
156
153
|
onAgentTaskComplete: () => { },
|
|
157
154
|
});
|
|
155
|
+
const wikiUpdate = tools.find((entry) => entry.name === "wiki_update");
|
|
156
|
+
const remember = tools.find((entry) => entry.name === "remember");
|
|
157
|
+
const recall = tools.find((entry) => entry.name === "recall");
|
|
158
|
+
const forget = tools.find((entry) => entry.name === "forget");
|
|
159
|
+
const wikiIngest = tools.find((entry) => entry.name === "wiki_ingest");
|
|
160
|
+
const wikiLint = tools.find((entry) => entry.name === "wiki_lint");
|
|
161
|
+
const wikiRebuildIndex = tools.find((entry) => entry.name === "wiki_rebuild_index");
|
|
158
162
|
const wikiFix = tools.find((entry) => entry.name === "wiki_fix");
|
|
159
|
-
assert.ok(wikiFix);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
assert.ok(wikiUpdate && remember && recall && forget && wikiIngest && wikiLint && wikiRebuildIndex && wikiFix);
|
|
164
|
+
await wikiUpdate.handler({
|
|
165
|
+
path: "pages/shared/chapterhouse.md",
|
|
166
|
+
title: "Chapterhouse",
|
|
167
|
+
summary: "Runtime notes",
|
|
168
|
+
content: `---
|
|
165
169
|
title: Chapterhouse
|
|
166
170
|
summary: Runtime notes
|
|
167
171
|
updated: 2026-05-12
|
|
168
|
-
tags: [
|
|
172
|
+
tags: [engineering]
|
|
169
173
|
---
|
|
170
174
|
|
|
171
175
|
# Chapterhouse
|
|
172
176
|
|
|
173
|
-
Runtime notes
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const preview = await wikiFix.handler({});
|
|
188
|
-
assert.equal(typeof preview, "object");
|
|
189
|
-
assert.equal(preview.dryRun, true);
|
|
190
|
-
assert.equal(preview.changedFiles, 1);
|
|
191
|
-
assert.match(preview.diff, /--- a\/pages\/projects\/chapterhouse\/index\.md/);
|
|
192
|
-
assert.equal(wikiFs.readPage("pages/projects/chapterhouse/index.md"), before);
|
|
193
|
-
const applied = await wikiFix.handler({ dry_run: false });
|
|
194
|
-
assert.equal(applied.dryRun, false);
|
|
195
|
-
assert.match(wikiFs.readPage("pages/projects/chapterhouse/index.md") ?? "", /tags: \[runbook\]/);
|
|
196
|
-
assert.match(wikiFs.readLogFile(), /fix-tags \| pages\/projects\/chapterhouse\/index\.md \| tools-test-agent/);
|
|
177
|
+
Runtime notes.
|
|
178
|
+
`,
|
|
179
|
+
});
|
|
180
|
+
assert.equal(await remember.handler({ category: "fact", content: "The sky is blue" }), "This tool has been removed. Use wiki_update to write to wiki pages, or memory_remember for agent memory.");
|
|
181
|
+
assert.equal(await recall.handler({ keyword: "sky" }), "This tool has been removed. Use wiki_search to search wiki pages, or memory_recall for agent memory.");
|
|
182
|
+
assert.equal(await forget.handler({ page_path: "pages/shared/chapterhouse.md" }), "This tool has been removed. Use wiki_update to modify wiki pages.");
|
|
183
|
+
assert.equal(await wikiRebuildIndex.handler({}), "This tool has been removed. The wiki index is now maintained automatically via SQLite FTS5.");
|
|
184
|
+
assert.equal(await wikiLint.handler({}), "This tool has been removed. Wiki health checks are no longer needed with SQLite-backed storage.");
|
|
185
|
+
assert.equal(await wikiFix.handler({}), "This tool has been removed. Use wiki_update to correct wiki pages.");
|
|
186
|
+
assert.equal(await wikiIngest.handler({ source: "hello" }), "This tool has been removed. Use wiki_ingest_source instead.");
|
|
187
|
+
const { wikiFs } = await readWikiArtifacts();
|
|
188
|
+
assert.equal(wikiFs.readPage("pages/facts.md"), undefined);
|
|
189
|
+
assert.match(wikiFs.readPage("pages/shared/chapterhouse.md") ?? "", /Runtime notes\./);
|
|
190
|
+
assert.doesNotMatch(wikiFs.readLogFile(), /forget: deleted page|wiki_rebuild_index|wiki_lint|wiki_fix|wiki_ingest:/);
|
|
197
191
|
});
|
|
198
192
|
//# sourceMappingURL=tools.wiki.test.js.map
|
package/dist/daemon.js
CHANGED
|
@@ -21,9 +21,12 @@ import { logger } from "./util/logger.js";
|
|
|
21
21
|
import { CHAPTERHOUSE_VERSION } from "./version.js";
|
|
22
22
|
import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
|
|
23
23
|
import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
|
|
24
|
+
import { runP6Migration } from "./memory/migration.js";
|
|
25
|
+
import { WikiConsolidationScheduler } from "./wiki/scheduler.js";
|
|
24
26
|
const log = logger.child({ module: "daemon" });
|
|
25
27
|
const modeContext = new ModeContext(config);
|
|
26
28
|
let memoryHousekeepingScheduler;
|
|
29
|
+
let wikiConsolidationScheduler;
|
|
27
30
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
28
31
|
/**
|
|
29
32
|
* How long the daemon waits for in-flight work to finish before forcing an exit.
|
|
@@ -126,6 +129,8 @@ async function main() {
|
|
|
126
129
|
const moved = enforceTopicStructure();
|
|
127
130
|
log.info({ moved }, "Topic-structure migration complete");
|
|
128
131
|
}
|
|
132
|
+
const p6Migration = await runP6Migration(getDb());
|
|
133
|
+
log.info({ p6Migration }, "P6 wiki seed migration complete");
|
|
129
134
|
// Prune orphaned session folders older than 7 days
|
|
130
135
|
pruneOldSessions();
|
|
131
136
|
// One-time deprecation note for legacy Telegram users (v1 → v2)
|
|
@@ -158,6 +163,8 @@ async function main() {
|
|
|
158
163
|
await startApiServer();
|
|
159
164
|
memoryHousekeepingScheduler = new MemoryHousekeepingScheduler();
|
|
160
165
|
memoryHousekeepingScheduler.start();
|
|
166
|
+
wikiConsolidationScheduler = new WikiConsolidationScheduler();
|
|
167
|
+
wikiConsolidationScheduler.start();
|
|
161
168
|
if (modeContext.canLogToAdo() && (config.adoPat || config.teamChapterhouseUrl)) {
|
|
162
169
|
new StandupScheduler().schedule();
|
|
163
170
|
}
|
|
@@ -212,6 +219,7 @@ async function shutdown() {
|
|
|
212
219
|
// Destroy all active agent sessions
|
|
213
220
|
await shutdownAgents();
|
|
214
221
|
await memoryHousekeepingScheduler?.stop();
|
|
222
|
+
await wikiConsolidationScheduler?.stop();
|
|
215
223
|
try {
|
|
216
224
|
stopEpisodeWriter();
|
|
217
225
|
}
|
|
@@ -234,6 +242,7 @@ export async function restartDaemon() {
|
|
|
234
242
|
// Destroy all active agent sessions
|
|
235
243
|
await shutdownAgents();
|
|
236
244
|
await memoryHousekeepingScheduler?.stop();
|
|
245
|
+
await wikiConsolidationScheduler?.stop();
|
|
237
246
|
try {
|
|
238
247
|
stopEpisodeWriter();
|
|
239
248
|
}
|
package/dist/memory/decisions.js
CHANGED
|
@@ -7,6 +7,7 @@ function toDecision(row) {
|
|
|
7
7
|
title: row.title,
|
|
8
8
|
rationale: row.rationale,
|
|
9
9
|
decidedAt: row.decided_at,
|
|
10
|
+
source: row.source ?? undefined,
|
|
10
11
|
tier: row.tier,
|
|
11
12
|
supersededBy: row.superseded_by ?? undefined,
|
|
12
13
|
archivedAt: row.archived_at ?? undefined,
|
|
@@ -15,14 +16,14 @@ function toDecision(row) {
|
|
|
15
16
|
}
|
|
16
17
|
export function recordDecision(input) {
|
|
17
18
|
const result = getDb().prepare(`
|
|
18
|
-
INSERT INTO mem_decisions (scope_id, entity_id, title, rationale, decided_at, tier, created_at)
|
|
19
|
-
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
20
|
-
`).run(input.scope_id, input.entity_id ?? null, input.title, input.rationale, input.decided_at ?? new Date().toISOString().slice(0, 10), input.tier ?? "warm");
|
|
19
|
+
INSERT INTO mem_decisions (scope_id, entity_id, title, rationale, decided_at, source, tier, created_at)
|
|
20
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
21
|
+
`).run(input.scope_id, input.entity_id ?? null, input.title, input.rationale, input.decided_at ?? new Date().toISOString().slice(0, 10), input.source ?? null, input.tier ?? "warm");
|
|
21
22
|
return getDecision(Number(result.lastInsertRowid));
|
|
22
23
|
}
|
|
23
24
|
export function getDecision(id) {
|
|
24
25
|
const row = getDb().prepare(`
|
|
25
|
-
SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
|
|
26
|
+
SELECT id, scope_id, entity_id, title, rationale, decided_at, source, tier, superseded_by, archived_at, created_at
|
|
26
27
|
FROM mem_decisions
|
|
27
28
|
WHERE id = ?
|
|
28
29
|
`).get(id);
|
|
@@ -30,7 +31,7 @@ export function getDecision(id) {
|
|
|
30
31
|
}
|
|
31
32
|
export function listDecisions(input = {}) {
|
|
32
33
|
const rows = getDb().prepare(`
|
|
33
|
-
SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
|
|
34
|
+
SELECT id, scope_id, entity_id, title, rationale, decided_at, source, tier, superseded_by, archived_at, created_at
|
|
34
35
|
FROM mem_decisions
|
|
35
36
|
WHERE (? IS NULL OR scope_id = ?)
|
|
36
37
|
AND (? IS NULL OR entity_id = ?)
|
package/dist/memory/entities.js
CHANGED
|
@@ -3,6 +3,7 @@ function toEntity(row) {
|
|
|
3
3
|
return {
|
|
4
4
|
id: row.id,
|
|
5
5
|
scopeId: row.scope_id,
|
|
6
|
+
slug: row.slug ?? undefined,
|
|
6
7
|
kind: row.kind,
|
|
7
8
|
name: row.name,
|
|
8
9
|
summary: row.summary ?? undefined,
|
|
@@ -14,7 +15,7 @@ function toEntity(row) {
|
|
|
14
15
|
}
|
|
15
16
|
export function getEntity(id) {
|
|
16
17
|
const row = getDb().prepare(`
|
|
17
|
-
SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
|
|
18
|
+
SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
|
|
18
19
|
FROM mem_entities
|
|
19
20
|
WHERE id = ?
|
|
20
21
|
`).get(id);
|
|
@@ -22,32 +23,42 @@ export function getEntity(id) {
|
|
|
22
23
|
}
|
|
23
24
|
export function findEntityByName(scopeId, kind, name) {
|
|
24
25
|
const row = getDb().prepare(`
|
|
25
|
-
SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
|
|
26
|
+
SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
|
|
26
27
|
FROM mem_entities
|
|
27
28
|
WHERE scope_id = ? AND kind = ? AND name = ?
|
|
28
29
|
`).get(scopeId, kind, name);
|
|
29
30
|
return row ? toEntity(row) : undefined;
|
|
30
31
|
}
|
|
32
|
+
export function findEntityBySlug(scopeId, slug) {
|
|
33
|
+
const row = getDb().prepare(`
|
|
34
|
+
SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
|
|
35
|
+
FROM mem_entities
|
|
36
|
+
WHERE scope_id = ? AND slug = ?
|
|
37
|
+
`).get(scopeId, slug);
|
|
38
|
+
return row ? toEntity(row) : undefined;
|
|
39
|
+
}
|
|
31
40
|
export function upsertEntity(input) {
|
|
32
41
|
const db = getDb();
|
|
33
|
-
const existing =
|
|
42
|
+
const existing = input.slug
|
|
43
|
+
? findEntityBySlug(input.scope_id, input.slug) ?? findEntityByName(input.scope_id, input.kind, input.name)
|
|
44
|
+
: findEntityByName(input.scope_id, input.kind, input.name);
|
|
34
45
|
if (existing) {
|
|
35
46
|
db.prepare(`
|
|
36
47
|
UPDATE mem_entities
|
|
37
|
-
SET summary = ?, tier = ?, confidence = ?, updated_at = CURRENT_TIMESTAMP
|
|
48
|
+
SET slug = COALESCE(?, slug), summary = ?, tier = ?, confidence = ?, updated_at = CURRENT_TIMESTAMP
|
|
38
49
|
WHERE id = ?
|
|
39
|
-
`).run(input.summary ?? existing.summary ?? null, input.tier ?? existing.tier, input.confidence ?? existing.confidence, existing.id);
|
|
50
|
+
`).run(input.slug ?? null, input.summary ?? existing.summary ?? null, input.tier ?? existing.tier, input.confidence ?? existing.confidence, existing.id);
|
|
40
51
|
return getEntity(existing.id);
|
|
41
52
|
}
|
|
42
53
|
const result = db.prepare(`
|
|
43
|
-
INSERT INTO mem_entities (scope_id, kind, name, summary, tier, confidence, created_at, updated_at)
|
|
44
|
-
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
45
|
-
`).run(input.scope_id, input.kind, input.name, input.summary ?? null, input.tier ?? "warm", input.confidence ?? 1.0);
|
|
54
|
+
INSERT INTO mem_entities (scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
56
|
+
`).run(input.scope_id, input.slug ?? null, input.kind, input.name, input.summary ?? null, input.tier ?? "warm", input.confidence ?? 1.0);
|
|
46
57
|
return getEntity(Number(result.lastInsertRowid));
|
|
47
58
|
}
|
|
48
59
|
export function listEntities(input = {}) {
|
|
49
60
|
const rows = getDb().prepare(`
|
|
50
|
-
SELECT id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at
|
|
61
|
+
SELECT id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at
|
|
51
62
|
FROM mem_entities
|
|
52
63
|
WHERE (? IS NULL OR scope_id = ?)
|
|
53
64
|
AND (? IS NULL OR kind = ?)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { childLogger } from "../util/logger.js";
|
|
2
|
+
import { getActiveScope } from "./active-scope.js";
|
|
3
|
+
import { recordObservation } from "./observations.js";
|
|
4
|
+
import { getScope } from "./scopes.js";
|
|
5
|
+
const log = childLogger("memory.hooks");
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Env knob
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
function isHooksEnabled() {
|
|
10
|
+
const raw = process.env.CHAPTERHOUSE_MEMORY_HOOKS_ENABLED?.trim();
|
|
11
|
+
if (raw === "false" || raw === "0")
|
|
12
|
+
return false;
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Dispatcher
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export class MemoryHookDispatcher {
|
|
19
|
+
handlers = new Map();
|
|
20
|
+
register(event, handler) {
|
|
21
|
+
const bucket = this.handlers.get(event) ?? [];
|
|
22
|
+
bucket.push(handler);
|
|
23
|
+
this.handlers.set(event, bucket);
|
|
24
|
+
}
|
|
25
|
+
async dispatch(event, payload) {
|
|
26
|
+
if (!isHooksEnabled()) {
|
|
27
|
+
log.debug({ event }, "memory.hooks.skip.disabled");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const bucket = this.handlers.get(event) ?? [];
|
|
31
|
+
if (bucket.length === 0) {
|
|
32
|
+
log.debug({ event }, "memory.hooks.no_handlers");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
log.info({ event, handlerCount: bucket.length }, "memory.hooks.dispatch");
|
|
36
|
+
for (const handler of bucket) {
|
|
37
|
+
try {
|
|
38
|
+
await handler(payload);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
log.error({ err, event }, "memory.hooks.handler_error");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Singleton dispatcher — shared across the process
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
export const hookDispatcher = new MemoryHookDispatcher();
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Built-in handler: git:commit → observation in active scope
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
function buildGitCommitContent(payload) {
|
|
54
|
+
const lines = [`Git commit: ${payload.message}`];
|
|
55
|
+
if (payload.stat?.trim()) {
|
|
56
|
+
lines.push("", "Changed files:", payload.stat.trim());
|
|
57
|
+
}
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
function resolveGlobalOrActiveScope() {
|
|
61
|
+
const active = getActiveScope();
|
|
62
|
+
if (active)
|
|
63
|
+
return active;
|
|
64
|
+
return getScope("global") ?? null;
|
|
65
|
+
}
|
|
66
|
+
export async function handleGitCommitHook(payload) {
|
|
67
|
+
if (!isHooksEnabled()) {
|
|
68
|
+
log.debug("memory.hooks.git_commit.disabled");
|
|
69
|
+
return { observation_id: "disabled" };
|
|
70
|
+
}
|
|
71
|
+
const scope = resolveGlobalOrActiveScope();
|
|
72
|
+
if (!scope) {
|
|
73
|
+
throw new Error("No active or global memory scope found. Create a scope before using memory hooks.");
|
|
74
|
+
}
|
|
75
|
+
const content = buildGitCommitContent(payload);
|
|
76
|
+
const observation = recordObservation({
|
|
77
|
+
scope_id: scope.id,
|
|
78
|
+
content,
|
|
79
|
+
source: "git:commit",
|
|
80
|
+
tier: "warm",
|
|
81
|
+
confidence: 1.0,
|
|
82
|
+
});
|
|
83
|
+
log.info({ scope: scope.slug, observation_id: observation.id }, "memory.hooks.git_commit.recorded");
|
|
84
|
+
return { observation_id: String(observation.id) };
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Built-in handler: pr:merge → observation
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
function buildPrMergeContent(payload) {
|
|
90
|
+
const lines = [`PR #${payload.number} merged: ${payload.title}`];
|
|
91
|
+
if (payload.body?.trim()) {
|
|
92
|
+
lines.push("", payload.body.trim());
|
|
93
|
+
}
|
|
94
|
+
if (payload.files_changed && payload.files_changed.length > 0) {
|
|
95
|
+
lines.push("", "Files changed:");
|
|
96
|
+
for (const file of payload.files_changed) {
|
|
97
|
+
lines.push(` ${file}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
export async function handlePrMergeHook(payload) {
|
|
103
|
+
if (!isHooksEnabled()) {
|
|
104
|
+
log.debug("memory.hooks.pr_merge.disabled");
|
|
105
|
+
return { observation_id: "disabled" };
|
|
106
|
+
}
|
|
107
|
+
const scope = resolveGlobalOrActiveScope();
|
|
108
|
+
if (!scope) {
|
|
109
|
+
throw new Error("No active or global memory scope found. Create a scope before using memory hooks.");
|
|
110
|
+
}
|
|
111
|
+
const content = buildPrMergeContent(payload);
|
|
112
|
+
const observation = recordObservation({
|
|
113
|
+
scope_id: scope.id,
|
|
114
|
+
content,
|
|
115
|
+
source: "pr:merge",
|
|
116
|
+
tier: "warm",
|
|
117
|
+
confidence: 1.0,
|
|
118
|
+
});
|
|
119
|
+
log.info({ scope: scope.slug, observation_id: observation.id, pr: payload.number }, "memory.hooks.pr_merge.recorded");
|
|
120
|
+
return { observation_id: String(observation.id) };
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Built-in handler: scope:created → observation in global scope
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
hookDispatcher.register("scope:created", async (payload) => {
|
|
126
|
+
const globalScope = getScope("global");
|
|
127
|
+
if (!globalScope) {
|
|
128
|
+
log.debug("memory.hooks.scope_created.no_global_scope");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const content = `Scope '${payload.slug}' created: ${payload.description || payload.title}`;
|
|
132
|
+
const observation = recordObservation({
|
|
133
|
+
scope_id: globalScope.id,
|
|
134
|
+
content,
|
|
135
|
+
source: "scope:created",
|
|
136
|
+
tier: "warm",
|
|
137
|
+
confidence: 1.0,
|
|
138
|
+
});
|
|
139
|
+
log.info({ scope: payload.slug, observation_id: observation.id }, "memory.hooks.scope_created.recorded");
|
|
140
|
+
});
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Built-in handler: memory:decision → structured log event
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
hookDispatcher.register("memory:decision", async (payload) => {
|
|
145
|
+
log.info({
|
|
146
|
+
decision_id: payload.id,
|
|
147
|
+
scope_id: payload.scope_id,
|
|
148
|
+
title: payload.title,
|
|
149
|
+
}, "memory.hooks.decision.recorded");
|
|
150
|
+
});
|
|
151
|
+
//# sourceMappingURL=hooks.js.map
|