chapterhouse 0.1.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/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- package/web/dist/index.html +15 -0
package/dist/status.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
let currentStatus = "idle";
|
|
2
|
+
let statusMessage = "";
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
export function setBusy(reason, message) {
|
|
5
|
+
currentStatus = reason;
|
|
6
|
+
statusMessage = message;
|
|
7
|
+
for (const listener of listeners) {
|
|
8
|
+
listener(currentStatus, statusMessage);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function setIdle() {
|
|
12
|
+
currentStatus = "idle";
|
|
13
|
+
statusMessage = "";
|
|
14
|
+
for (const listener of listeners) {
|
|
15
|
+
listener(currentStatus, statusMessage);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function getStatus() {
|
|
19
|
+
return { status: currentStatus, message: statusMessage };
|
|
20
|
+
}
|
|
21
|
+
export function onStatusChange(fn) {
|
|
22
|
+
listeners.add(fn);
|
|
23
|
+
return () => listeners.delete(fn);
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { getStatus, onStatusChange, setBusy, setIdle } from "./status.js";
|
|
4
|
+
test("tracks dreaming status and notifies listeners", () => {
|
|
5
|
+
const seen = [];
|
|
6
|
+
const unsubscribe = onStatusChange((status, message) => {
|
|
7
|
+
seen.push({ status, message });
|
|
8
|
+
});
|
|
9
|
+
setBusy("dreaming", "Consolidating memories...");
|
|
10
|
+
assert.deepEqual(getStatus(), {
|
|
11
|
+
status: "dreaming",
|
|
12
|
+
message: "Consolidating memories...",
|
|
13
|
+
});
|
|
14
|
+
setIdle();
|
|
15
|
+
assert.deepEqual(getStatus(), { status: "idle", message: "" });
|
|
16
|
+
unsubscribe();
|
|
17
|
+
assert.deepEqual(seen, [
|
|
18
|
+
{ status: "dreaming", message: "Consolidating memories..." },
|
|
19
|
+
{ status: "idle", message: "" },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
//# sourceMappingURL=status.test.js.map
|
package/dist/store/db.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { DB_PATH, ensureChapterhouseHome } from "../paths.js";
|
|
3
|
+
let db;
|
|
4
|
+
let logInsertCount = 0;
|
|
5
|
+
let fts5Available = false;
|
|
6
|
+
export function getDb() {
|
|
7
|
+
if (!db) {
|
|
8
|
+
ensureChapterhouseHome();
|
|
9
|
+
db = new Database(DB_PATH);
|
|
10
|
+
db.pragma("journal_mode = WAL");
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS worker_sessions (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
name TEXT UNIQUE NOT NULL,
|
|
15
|
+
copilot_session_id TEXT,
|
|
16
|
+
working_dir TEXT NOT NULL,
|
|
17
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
18
|
+
last_output TEXT,
|
|
19
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
20
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
slug TEXT UNIQUE NOT NULL,
|
|
27
|
+
name TEXT NOT NULL,
|
|
28
|
+
model TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
30
|
+
current_task TEXT,
|
|
31
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
32
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
33
|
+
)
|
|
34
|
+
`);
|
|
35
|
+
db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS agent_tasks (
|
|
37
|
+
task_id TEXT PRIMARY KEY,
|
|
38
|
+
agent_slug TEXT NOT NULL,
|
|
39
|
+
description TEXT NOT NULL,
|
|
40
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
41
|
+
result TEXT,
|
|
42
|
+
origin_channel TEXT,
|
|
43
|
+
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
44
|
+
completed_at DATETIME
|
|
45
|
+
)
|
|
46
|
+
`);
|
|
47
|
+
db.exec(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS max_state (
|
|
49
|
+
key TEXT PRIMARY KEY,
|
|
50
|
+
value TEXT NOT NULL
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
db.exec(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS conversation_log (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
57
|
+
content TEXT NOT NULL,
|
|
58
|
+
source TEXT NOT NULL DEFAULT 'unknown',
|
|
59
|
+
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
60
|
+
)
|
|
61
|
+
`);
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
category TEXT NOT NULL CHECK(category IN ('preference', 'fact', 'project', 'person', 'routine')),
|
|
66
|
+
content TEXT NOT NULL,
|
|
67
|
+
source TEXT NOT NULL DEFAULT 'user',
|
|
68
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
69
|
+
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
70
|
+
)
|
|
71
|
+
`);
|
|
72
|
+
db.exec(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS project_squads (
|
|
74
|
+
project_root TEXT PRIMARY KEY,
|
|
75
|
+
squad_dir TEXT NOT NULL,
|
|
76
|
+
team_dir TEXT NOT NULL,
|
|
77
|
+
personal_dir TEXT,
|
|
78
|
+
mode TEXT NOT NULL CHECK(mode IN ('local', 'remote')),
|
|
79
|
+
project_key TEXT,
|
|
80
|
+
config_source TEXT,
|
|
81
|
+
registered INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
83
|
+
)
|
|
84
|
+
`);
|
|
85
|
+
db.exec(`
|
|
86
|
+
CREATE TABLE IF NOT EXISTS squad_agents (
|
|
87
|
+
project_root TEXT NOT NULL,
|
|
88
|
+
slug TEXT NOT NULL,
|
|
89
|
+
role TEXT NOT NULL,
|
|
90
|
+
description TEXT,
|
|
91
|
+
charter_path TEXT NOT NULL,
|
|
92
|
+
model_preference TEXT,
|
|
93
|
+
tools_json TEXT,
|
|
94
|
+
capabilities_json TEXT,
|
|
95
|
+
stale INTEGER NOT NULL DEFAULT 0,
|
|
96
|
+
loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
97
|
+
PRIMARY KEY (project_root, slug)
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
db.exec(`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS squad_task_links (
|
|
102
|
+
task_id TEXT PRIMARY KEY,
|
|
103
|
+
project_root TEXT NOT NULL,
|
|
104
|
+
squad_agent_slug TEXT NOT NULL,
|
|
105
|
+
wiki_decision_path TEXT NOT NULL
|
|
106
|
+
)
|
|
107
|
+
`);
|
|
108
|
+
// Migrate: if the table already existed with a stricter CHECK, recreate it
|
|
109
|
+
try {
|
|
110
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('system', '__migration_test__', 'test')`).run();
|
|
111
|
+
db.prepare(`DELETE FROM conversation_log WHERE content = '__migration_test__'`).run();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// CHECK constraint doesn't allow 'system' — recreate table preserving data
|
|
115
|
+
db.exec(`ALTER TABLE conversation_log RENAME TO conversation_log_old`);
|
|
116
|
+
db.exec(`
|
|
117
|
+
CREATE TABLE conversation_log (
|
|
118
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
119
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
120
|
+
content TEXT NOT NULL,
|
|
121
|
+
source TEXT NOT NULL DEFAULT 'unknown',
|
|
122
|
+
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
123
|
+
)
|
|
124
|
+
`);
|
|
125
|
+
db.exec(`INSERT INTO conversation_log (role, content, source, ts) SELECT role, content, source, ts FROM conversation_log_old`);
|
|
126
|
+
db.exec(`DROP TABLE conversation_log_old`);
|
|
127
|
+
}
|
|
128
|
+
// New persistent session table — one row per chat session (default or project)
|
|
129
|
+
db.exec(`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS copilot_sessions (
|
|
131
|
+
session_key TEXT PRIMARY KEY,
|
|
132
|
+
mode TEXT NOT NULL CHECK(mode IN ('default', 'project')),
|
|
133
|
+
project_root TEXT,
|
|
134
|
+
copilot_session_id TEXT NOT NULL,
|
|
135
|
+
model TEXT,
|
|
136
|
+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
137
|
+
)
|
|
138
|
+
`);
|
|
139
|
+
// Migrate: add session_key column to conversation_log if not present
|
|
140
|
+
const convCols = db.prepare(`PRAGMA table_info(conversation_log)`).all();
|
|
141
|
+
if (!convCols.some((c) => c.name === 'session_key')) {
|
|
142
|
+
db.exec(`ALTER TABLE conversation_log ADD COLUMN session_key TEXT NOT NULL DEFAULT 'default'`);
|
|
143
|
+
}
|
|
144
|
+
// Migrate: add session_key column to agent_tasks if not present
|
|
145
|
+
const taskCols = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
|
|
146
|
+
if (!taskCols.some((c) => c.name === 'session_key')) {
|
|
147
|
+
db.exec(`ALTER TABLE agent_tasks ADD COLUMN session_key TEXT NOT NULL DEFAULT 'default'`);
|
|
148
|
+
}
|
|
149
|
+
// Prune conversation log at startup — keep more history for better recovery
|
|
150
|
+
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
|
|
151
|
+
// Set up FTS5 for memory search (graceful fallback if not available)
|
|
152
|
+
try {
|
|
153
|
+
db.exec(`
|
|
154
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
155
|
+
content,
|
|
156
|
+
content_rowid='id'
|
|
157
|
+
)
|
|
158
|
+
`);
|
|
159
|
+
// Sync triggers
|
|
160
|
+
db.exec(`
|
|
161
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
162
|
+
INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
|
|
163
|
+
END
|
|
164
|
+
`);
|
|
165
|
+
db.exec(`
|
|
166
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
167
|
+
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
168
|
+
END
|
|
169
|
+
`);
|
|
170
|
+
db.exec(`
|
|
171
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
172
|
+
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
|
|
173
|
+
INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
|
|
174
|
+
END
|
|
175
|
+
`);
|
|
176
|
+
// Backfill: check if FTS is in sync by comparing row counts
|
|
177
|
+
const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get().c;
|
|
178
|
+
const ftsCount = db.prepare(`SELECT COUNT(*) as c FROM memories_fts`).get().c;
|
|
179
|
+
if (memCount > 0 && ftsCount < memCount) {
|
|
180
|
+
db.exec(`INSERT INTO memories_fts(memories_fts) VALUES ('rebuild')`);
|
|
181
|
+
}
|
|
182
|
+
fts5Available = true;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// FTS5 not available in this SQLite build — fall back to LIKE queries
|
|
186
|
+
fts5Available = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return db;
|
|
190
|
+
}
|
|
191
|
+
export function getState(key) {
|
|
192
|
+
const db = getDb();
|
|
193
|
+
const row = db.prepare(`SELECT value FROM max_state WHERE key = ?`).get(key);
|
|
194
|
+
return row?.value;
|
|
195
|
+
}
|
|
196
|
+
export function setState(key, value) {
|
|
197
|
+
const db = getDb();
|
|
198
|
+
db.prepare(`INSERT OR REPLACE INTO max_state (key, value) VALUES (?, ?)`).run(key, value);
|
|
199
|
+
}
|
|
200
|
+
/** Remove a key from persistent state. */
|
|
201
|
+
export function deleteState(key) {
|
|
202
|
+
const db = getDb();
|
|
203
|
+
db.prepare(`DELETE FROM max_state WHERE key = ?`).run(key);
|
|
204
|
+
}
|
|
205
|
+
/** Log a conversation turn (user, assistant, or system). */
|
|
206
|
+
export function logConversation(role, content, source, sessionKey = "default") {
|
|
207
|
+
const db = getDb();
|
|
208
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key) VALUES (?, ?, ?, ?)`).run(role, content, source, sessionKey);
|
|
209
|
+
// Keep last 1000 entries to support context recovery after session loss
|
|
210
|
+
logInsertCount++;
|
|
211
|
+
if (logInsertCount % 50 === 0) {
|
|
212
|
+
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Retrieve a stored Copilot session by key. */
|
|
216
|
+
export function getCopilotSession(sessionKey) {
|
|
217
|
+
const db = getDb();
|
|
218
|
+
const row = db
|
|
219
|
+
.prepare(`SELECT copilot_session_id, model FROM copilot_sessions WHERE session_key = ?`)
|
|
220
|
+
.get(sessionKey);
|
|
221
|
+
if (!row)
|
|
222
|
+
return undefined;
|
|
223
|
+
return { copilotSessionId: row.copilot_session_id, model: row.model ?? undefined };
|
|
224
|
+
}
|
|
225
|
+
/** Insert or update a Copilot session record. */
|
|
226
|
+
export function upsertCopilotSession(sessionKey, mode, copilotSessionId, projectRoot, model) {
|
|
227
|
+
const db = getDb();
|
|
228
|
+
db.prepare(`INSERT INTO copilot_sessions (session_key, mode, project_root, copilot_session_id, model, updated_at)
|
|
229
|
+
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
230
|
+
ON CONFLICT(session_key) DO UPDATE SET
|
|
231
|
+
copilot_session_id = excluded.copilot_session_id,
|
|
232
|
+
model = excluded.model,
|
|
233
|
+
project_root = excluded.project_root,
|
|
234
|
+
updated_at = CURRENT_TIMESTAMP`).run(sessionKey, mode, projectRoot ?? null, copilotSessionId, model ?? null);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Return the session_key stored against a task, or 'default' if the column
|
|
238
|
+
* doesn't exist yet (Kaylee will add it in a follow-on phase).
|
|
239
|
+
*/
|
|
240
|
+
export function getTaskSessionKey(taskId) {
|
|
241
|
+
const db = getDb();
|
|
242
|
+
try {
|
|
243
|
+
const row = db
|
|
244
|
+
.prepare(`SELECT session_key FROM agent_tasks WHERE task_id = ?`)
|
|
245
|
+
.get(taskId);
|
|
246
|
+
return row?.session_key ?? "default";
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return "default";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Get recent conversation history formatted for injection into system message.
|
|
254
|
+
*
|
|
255
|
+
* When `sessionKey` is provided, only rows belonging to that session are
|
|
256
|
+
* returned — useful for project/squad sessions that must not bleed context
|
|
257
|
+
* from other sessions. When omitted, rows from all sessions are returned
|
|
258
|
+
* (legacy behavior for callers that don't care about session isolation).
|
|
259
|
+
*/
|
|
260
|
+
export function getRecentConversation(limit, sessionKey) {
|
|
261
|
+
const db = getDb();
|
|
262
|
+
const effectiveLimit = limit ?? 20;
|
|
263
|
+
const rows = sessionKey
|
|
264
|
+
? db.prepare(`SELECT role, content, source, ts FROM conversation_log WHERE session_key = ? ORDER BY id DESC LIMIT ?`).all(sessionKey, effectiveLimit)
|
|
265
|
+
: db.prepare(`SELECT role, content, source, ts FROM conversation_log ORDER BY id DESC LIMIT ?`).all(effectiveLimit);
|
|
266
|
+
if (rows.length === 0)
|
|
267
|
+
return "";
|
|
268
|
+
// Reverse so oldest is first (chronological order)
|
|
269
|
+
rows.reverse();
|
|
270
|
+
return rows.map((r) => {
|
|
271
|
+
const tag = r.role === "user" ? `[${r.source}] User`
|
|
272
|
+
: r.role === "system" ? `[${r.source}] System`
|
|
273
|
+
: "Chapterhouse";
|
|
274
|
+
// Truncate long messages to keep context manageable
|
|
275
|
+
const content = r.content.length > 1500 ? r.content.slice(0, 1500) + "…" : r.content;
|
|
276
|
+
return `${tag}: ${content}`;
|
|
277
|
+
}).join("\n\n");
|
|
278
|
+
}
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// SQLite memory functions removed — wiki is the single source of truth.
|
|
281
|
+
// The memories table and FTS5 index are preserved in the schema for safety
|
|
282
|
+
// (existing data is not deleted), but no code reads or writes to them.
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
export function closeDb() {
|
|
285
|
+
if (db) {
|
|
286
|
+
db.close();
|
|
287
|
+
db = undefined;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import Database from "better-sqlite3";
|
|
6
|
+
const repoRoot = process.cwd();
|
|
7
|
+
const sandboxRoot = join(repoRoot, ".test-work", `store-db-${process.pid}`);
|
|
8
|
+
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
9
|
+
const dbPath = join(chapterhouseHome, "chapterhouse.db");
|
|
10
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
11
|
+
async function loadDbModule() {
|
|
12
|
+
return await import(new URL(`./db.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
13
|
+
}
|
|
14
|
+
function resetSandbox() {
|
|
15
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
16
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
17
|
+
mkdirSync(chapterhouseHome, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
test.beforeEach(() => {
|
|
20
|
+
resetSandbox();
|
|
21
|
+
});
|
|
22
|
+
test.after(() => {
|
|
23
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
test("getDb initializes schema, state helpers, and conversation formatting", async () => {
|
|
26
|
+
const dbModule = await loadDbModule();
|
|
27
|
+
try {
|
|
28
|
+
const db = dbModule.getDb();
|
|
29
|
+
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all();
|
|
30
|
+
const tableNames = new Set(tables.map((row) => row.name));
|
|
31
|
+
for (const name of [
|
|
32
|
+
"worker_sessions",
|
|
33
|
+
"agent_sessions",
|
|
34
|
+
"agent_tasks",
|
|
35
|
+
"max_state",
|
|
36
|
+
"conversation_log",
|
|
37
|
+
"memories",
|
|
38
|
+
]) {
|
|
39
|
+
assert.equal(tableNames.has(name), true, `expected schema table ${name}`);
|
|
40
|
+
}
|
|
41
|
+
dbModule.setState("router_config", "enabled");
|
|
42
|
+
assert.equal(dbModule.getState("router_config"), "enabled");
|
|
43
|
+
dbModule.deleteState("router_config");
|
|
44
|
+
assert.equal(dbModule.getState("router_config"), undefined);
|
|
45
|
+
const longReply = "A".repeat(1_550);
|
|
46
|
+
dbModule.logConversation("system", "Started background sync", "worker");
|
|
47
|
+
dbModule.logConversation("user", "Please summarize the last deploy", "web");
|
|
48
|
+
dbModule.logConversation("assistant", longReply, "web");
|
|
49
|
+
assert.equal(dbModule.getRecentConversation(3), [
|
|
50
|
+
"[worker] System: Started background sync",
|
|
51
|
+
"[web] User: Please summarize the last deploy",
|
|
52
|
+
`Chapterhouse: ${"A".repeat(1_500)}…`,
|
|
53
|
+
].join("\n\n"));
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
dbModule.closeDb();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
test("getDb migrates legacy conversation_log tables to allow system messages", async () => {
|
|
60
|
+
const seedDb = new Database(dbPath);
|
|
61
|
+
seedDb.exec(`
|
|
62
|
+
CREATE TABLE conversation_log (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
|
|
65
|
+
content TEXT NOT NULL,
|
|
66
|
+
source TEXT NOT NULL DEFAULT 'unknown',
|
|
67
|
+
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
68
|
+
)
|
|
69
|
+
`);
|
|
70
|
+
seedDb.prepare(`INSERT INTO conversation_log (role, content, source) VALUES (?, ?, ?)`)
|
|
71
|
+
.run("user", "Legacy turn", "cli");
|
|
72
|
+
seedDb.close();
|
|
73
|
+
const dbModule = await loadDbModule();
|
|
74
|
+
try {
|
|
75
|
+
dbModule.getDb();
|
|
76
|
+
dbModule.logConversation("system", "Migrated successfully", "migration");
|
|
77
|
+
const reopened = new Database(dbPath, { readonly: true });
|
|
78
|
+
const rows = reopened.prepare(`SELECT role, content, source FROM conversation_log ORDER BY id`).all();
|
|
79
|
+
reopened.close();
|
|
80
|
+
assert.deepEqual(rows, [
|
|
81
|
+
{ role: "user", content: "Legacy turn", source: "cli" },
|
|
82
|
+
{ role: "system", content: "Migrated successfully", source: "migration" },
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
dbModule.closeDb();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
test("getDb prunes oversized conversation logs on startup and during inserts", async () => {
|
|
90
|
+
const seedDb = new Database(dbPath);
|
|
91
|
+
seedDb.exec(`
|
|
92
|
+
CREATE TABLE conversation_log (
|
|
93
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
94
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
95
|
+
content TEXT NOT NULL,
|
|
96
|
+
source TEXT NOT NULL DEFAULT 'unknown',
|
|
97
|
+
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
const insert = seedDb.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('user', ?, 'seed')`);
|
|
101
|
+
seedDb.transaction(() => {
|
|
102
|
+
for (let i = 0; i < 1_025; i++) {
|
|
103
|
+
insert.run(`seed-${i}`);
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
seedDb.close();
|
|
107
|
+
const dbModule = await loadDbModule();
|
|
108
|
+
try {
|
|
109
|
+
const db = dbModule.getDb();
|
|
110
|
+
let count = db.prepare(`SELECT COUNT(*) AS count FROM conversation_log`).get().count;
|
|
111
|
+
assert.equal(count, 1_000);
|
|
112
|
+
for (let i = 0; i < 50; i++) {
|
|
113
|
+
dbModule.logConversation("assistant", `fresh-${i}`, "web");
|
|
114
|
+
}
|
|
115
|
+
count = db.prepare(`SELECT COUNT(*) AS count FROM conversation_log`).get().count;
|
|
116
|
+
const oldest = db.prepare(`SELECT content FROM conversation_log ORDER BY id ASC LIMIT 1`).get();
|
|
117
|
+
const newest = db.prepare(`SELECT content FROM conversation_log ORDER BY id DESC LIMIT 1`).get();
|
|
118
|
+
assert.equal(count, 1_000);
|
|
119
|
+
assert.equal(oldest.content, "seed-75");
|
|
120
|
+
assert.equal(newest.content, "fresh-49");
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
dbModule.closeDb();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
//# sourceMappingURL=db.test.js.map
|