chapterhouse 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/agents/korg.agent.md +20 -0
  3. package/dist/api/auth.js +11 -1
  4. package/dist/api/auth.test.js +29 -0
  5. package/dist/api/errors.js +23 -0
  6. package/dist/api/route-coverage.test.js +61 -21
  7. package/dist/api/routes/agents.js +472 -0
  8. package/dist/api/routes/memory.js +299 -0
  9. package/dist/api/routes/projects.js +170 -0
  10. package/dist/api/routes/sessions.js +347 -0
  11. package/dist/api/routes/system.js +82 -0
  12. package/dist/api/routes/wiki.js +455 -0
  13. package/dist/api/routes/wiki.test.js +49 -0
  14. package/dist/api/send-json.js +16 -0
  15. package/dist/api/send-json.test.js +18 -0
  16. package/dist/api/server-runtime.js +45 -3
  17. package/dist/api/server.js +34 -1764
  18. package/dist/api/server.test.js +239 -8
  19. package/dist/api/sse-hub.js +37 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/config.js +151 -58
  22. package/dist/config.test.js +29 -0
  23. package/dist/copilot/okr-mapper.js +2 -11
  24. package/dist/copilot/orchestrator.js +358 -352
  25. package/dist/copilot/orchestrator.test.js +139 -4
  26. package/dist/copilot/prompt-date.js +2 -1
  27. package/dist/copilot/session-manager.js +25 -23
  28. package/dist/copilot/session-manager.test.js +35 -1
  29. package/dist/copilot/standup.js +2 -2
  30. package/dist/copilot/task-event-log.js +7 -1
  31. package/dist/copilot/task-event-log.test.js +13 -0
  32. package/dist/copilot/tools/agent.js +608 -0
  33. package/dist/copilot/tools/index.js +19 -0
  34. package/dist/copilot/tools/memory.js +678 -0
  35. package/dist/copilot/tools/models.js +2 -0
  36. package/dist/copilot/tools/okr.js +171 -0
  37. package/dist/copilot/tools/wiki.js +333 -0
  38. package/dist/copilot/tools-deps.js +4 -0
  39. package/dist/copilot/tools.agent.test.js +10 -8
  40. package/dist/copilot/tools.inventory.test.js +76 -0
  41. package/dist/copilot/tools.js +1 -1725
  42. package/dist/copilot/tools.okr.test.js +31 -0
  43. package/dist/copilot/tools.wiki.test.js +358 -6
  44. package/dist/copilot/turn-event-log.js +31 -4
  45. package/dist/copilot/turn-event-log.test.js +24 -2
  46. package/dist/copilot/workiq-installer.test.js +2 -2
  47. package/dist/daemon-install.js +3 -2
  48. package/dist/daemon.js +9 -17
  49. package/dist/integrations/ado-client.js +90 -9
  50. package/dist/integrations/ado-client.test.js +56 -0
  51. package/dist/integrations/team-push.js +1 -0
  52. package/dist/integrations/team-push.test.js +6 -0
  53. package/dist/integrations/teams-notify.js +1 -0
  54. package/dist/integrations/teams-notify.test.js +5 -0
  55. package/dist/memory/active-scope.test.js +0 -1
  56. package/dist/memory/checkpoint.js +89 -72
  57. package/dist/memory/checkpoint.test.js +23 -3
  58. package/dist/memory/eot.js +194 -89
  59. package/dist/memory/eot.test.js +186 -3
  60. package/dist/memory/hooks.js +2 -4
  61. package/dist/memory/housekeeping-scheduler.js +1 -1
  62. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  63. package/dist/memory/housekeeping.js +100 -3
  64. package/dist/memory/housekeeping.test.js +33 -2
  65. package/dist/memory/reflect.test.js +2 -0
  66. package/dist/memory/scope-lock.js +26 -0
  67. package/dist/memory/scope-lock.test.js +118 -0
  68. package/dist/memory/scopes.test.js +0 -1
  69. package/dist/mode-context.js +58 -5
  70. package/dist/mode-context.test.js +68 -0
  71. package/dist/paths.js +1 -0
  72. package/dist/setup.js +3 -2
  73. package/dist/shared/api-schemas.js +48 -5
  74. package/dist/store/connection.js +96 -0
  75. package/dist/store/db.js +5 -1498
  76. package/dist/store/db.test.js +182 -1
  77. package/dist/store/migrations.js +460 -0
  78. package/dist/store/repositories/memory.js +281 -0
  79. package/dist/store/repositories/okr.js +3 -0
  80. package/dist/store/repositories/projects.js +5 -0
  81. package/dist/store/repositories/sessions.js +284 -0
  82. package/dist/store/repositories/wiki.js +60 -0
  83. package/dist/store/schema.js +501 -0
  84. package/dist/util/logger.js +3 -2
  85. package/dist/wiki/consolidation.js +50 -9
  86. package/dist/wiki/consolidation.test.js +45 -0
  87. package/dist/wiki/frontmatter.js +45 -14
  88. package/dist/wiki/frontmatter.test.js +26 -1
  89. package/dist/wiki/fs.js +16 -4
  90. package/dist/wiki/fs.test.js +84 -0
  91. package/dist/wiki/index-manager.js +30 -2
  92. package/dist/wiki/index-manager.test.js +43 -12
  93. package/dist/wiki/ingest.js +17 -1
  94. package/dist/wiki/lock.js +11 -1
  95. package/dist/wiki/log-manager.js +2 -7
  96. package/dist/wiki/migrate.js +44 -17
  97. package/dist/wiki/project-registry.js +10 -5
  98. package/dist/wiki/project-registry.test.js +14 -0
  99. package/dist/wiki/scheduler.js +1 -1
  100. package/dist/wiki/seed-team-wiki.js +2 -1
  101. package/dist/wiki/team-sync.js +31 -6
  102. package/dist/wiki/team-sync.test.js +81 -0
  103. package/package.json +1 -1
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  105. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  107. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  109. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  110. package/web/dist/index.html +1 -1
  111. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  112. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -0,0 +1,281 @@
1
+ export const MEMORY_SCOPE_SEEDS = [
2
+ {
3
+ slug: "global",
4
+ title: "Global",
5
+ description: "Cross-cutting facts that apply everywhere",
6
+ keywords: ["everywhere", "general"],
7
+ },
8
+ {
9
+ slug: "chapterhouse",
10
+ title: "Chapterhouse",
11
+ description: "Chapterhouse codebase, conventions, decisions, gotchas",
12
+ keywords: ["chapterhouse", "this repo", "this project", "the daemon"],
13
+ },
14
+ ];
15
+ const CHAPTERHOUSE_WIKI_INDEX_SOURCE = "wiki:pages/projects/chapterhouse/index.md";
16
+ const CHAPTERHOUSE_WIKI_HOT_TIER_REASON = "P6 PR1 wiki migration hot-tier candidate";
17
+ const CHAPTERHOUSE_WIKI_DECISION_HOT_TIER_REASON = "P6 PR2 wiki migration hot-tier candidate";
18
+ const CHAPTERHOUSE_PROJECT_ENTITY_SEED = {
19
+ name: "Chapterhouse",
20
+ kind: "project",
21
+ summary: "Always-on team-level AI assistant daemon that orchestrates specialist subagents and maintains a persistent knowledge wiki.",
22
+ };
23
+ const CHAPTERHOUSE_WIKI_OBSERVATION_SEEDS = [
24
+ {
25
+ content: "Chapterhouse architecture: Node.js daemon, web UI, per-conversation sessions, specialist agents, modular skills, MCP servers, local markdown wiki, project-rules prompt injection, and background workers with SSE event streaming.",
26
+ tier: "warm",
27
+ },
28
+ {
29
+ content: "Chapterhouse source lives at bketelsen/chapterhouse with local checkout ~/projects/chapterhouse; engineering plans are in docs/plans/ and product specs are in docs/prd/.",
30
+ tier: "warm",
31
+ },
32
+ {
33
+ content: "Background agent completions arrive as automatic system notifications; after spawning a background agent, end the turn and wait instead of polling read_agent, sleeping, or re-checking list_agents.",
34
+ tier: "hot",
35
+ },
36
+ {
37
+ content: "Use the default Chapterhouse chat for research and general questions; move code changes against a specific project into that project's chat session.",
38
+ tier: "warm",
39
+ },
40
+ {
41
+ content: "Squad manages Chapterhouse's own development workflow.",
42
+ tier: "warm",
43
+ },
44
+ {
45
+ content: "Chapterhouse uses frequent semver patch releases published to npm via the Publish to npm GitHub Actions workflow on tag push; never publish manually.",
46
+ tier: "hot",
47
+ },
48
+ {
49
+ content: "Agent-memory P1 shipped on 2026-05-13 as Chapterhouse v0.4.0 across 7 PRs: #215 through #225.",
50
+ tier: "warm",
51
+ },
52
+ {
53
+ content: "The v0.3.2 per-session orchestrator was confirmed under real concurrent load on 2026-05-08, enabling concurrent sessions without cross-blocking.",
54
+ tier: "hot",
55
+ },
56
+ ];
57
+ const CHAPTERHOUSE_WIKI_DECISION_SEEDS = [
58
+ {
59
+ title: "Standardize API validation and JSON errors around `src/api/errors.ts`",
60
+ rationale: "Keep one 400/403/404/500 contract across routes instead of ad hoc parsing and responses; `src/api/errors.ts`, `src/api/server.ts`.",
61
+ decidedAt: "2026-05-06",
62
+ tier: "warm",
63
+ },
64
+ {
65
+ title: "Run in standalone mode when neither Entra nor an API token is configured",
66
+ rationale: "Disable auth checks and team sync together so local single-user startup still works; `src/config.ts`, `src/api/auth.ts`.",
67
+ decidedAt: "2026-05-06",
68
+ tier: "warm",
69
+ },
70
+ {
71
+ title: "Use bearer auth for SSE and serialize wiki writes",
72
+ rationale: "`/stream` no longer accepts query tokens, and wiki mutations go through `withWikiWrite()`; `web/src/stream.ts`, `src/wiki/lock.ts`.",
73
+ decidedAt: "2026-05-06",
74
+ tier: "warm",
75
+ },
76
+ {
77
+ title: "Default production to least privilege",
78
+ rationale: "Run as a non-root container, validate env early, and keep CORS and security headers tight in production; `Dockerfile`, `deploy/deploy.sh`, `src/config.ts`.",
79
+ decidedAt: "2026-05-06",
80
+ tier: "warm",
81
+ },
82
+ {
83
+ title: "Keep chat state session-scoped by `sessionKey`",
84
+ rationale: "Conversation history, SSE routing, and frontend buffers stay isolated per session instead of bleeding across chats; `src/store/db.ts`, `web/src/store.ts`.",
85
+ decidedAt: "2026-05-07",
86
+ tier: "warm",
87
+ },
88
+ {
89
+ title: "Preserve the 3-layer daemon timing contract",
90
+ rationale: "Orchestrator timeout must stay below daemon shutdown grace, which must stay below systemd `TimeoutStopSec`; `README.md`, `src/daemon.ts`, `src/daemon-install.ts`.",
91
+ decidedAt: "2026-05-08",
92
+ tier: "hot",
93
+ },
94
+ {
95
+ title: "Start WorkIQ MCP auto-install at daemon startup",
96
+ rationale: "Write the MCP entry before the SDK client starts so new sessions see it immediately; issue #33, PR #78.",
97
+ decidedAt: "2026-05-08",
98
+ tier: "warm",
99
+ },
100
+ {
101
+ title: "Use per-session orchestrators instead of a global queue",
102
+ rationale: "`SessionManager` and `SessionRegistry` keep independent queues so one chat cannot block another; issue #74, `src/copilot/session-manager.ts`.",
103
+ decidedAt: "2026-05-08",
104
+ tier: "hot",
105
+ },
106
+ {
107
+ title: "Make wiki path hierarchy the primary navigation model",
108
+ rationale: "`path` drives the tree and breadcrumbs, while `section` remains secondary metadata; `web/src/wiki/index.ts`, `web/src/routes/Wiki.tsx`.",
109
+ decidedAt: "2026-05-06",
110
+ tier: "warm",
111
+ },
112
+ {
113
+ title: "Route wiki breadcrumbs through `selected` state",
114
+ rationale: "Breadcrumb clicks use the same route-state flow as the sidebar so navigation stays predictable; `web/src/components/wiki/WikiBreadcrumbs.tsx`.",
115
+ decidedAt: "2026-05-06",
116
+ tier: "warm",
117
+ },
118
+ {
119
+ title: "Derive wiki page scope from configured team paths",
120
+ rationale: "The API returns `scope` and the UI shows personal/team indicators without adding new persisted metadata; `src/api/server.ts`, `web/src/components/wiki/WikiScopeIndicator.tsx`.",
121
+ decidedAt: "2026-05-06",
122
+ tier: "warm",
123
+ },
124
+ {
125
+ title: "Sort sidebar recents by actual project activity",
126
+ rationale: "`last_used_at` replaces registration time so recent projects reflect chat usage rather than signup order; issue #26, PR #30.",
127
+ decidedAt: "2026-05-08",
128
+ tier: "warm",
129
+ },
130
+ {
131
+ title: "Preload `CHAPTERHOUSE_DISABLE_DOTENV=1` for Node tests",
132
+ rationale: "Tests must not inherit developer-local env files before config singletons initialize; `src/test/setup-env.ts`, `src/config.ts`.",
133
+ decidedAt: "2026-05-06",
134
+ tier: "warm",
135
+ },
136
+ {
137
+ title: "Test API routes through a spawned server process",
138
+ rationale: "Real HTTP coverage catches auth, config preload, error middleware, and wiki I/O behavior that an unexported in-process app would miss; `src/api/server.test.ts`.",
139
+ decidedAt: "2026-05-06",
140
+ tier: "warm",
141
+ },
142
+ {
143
+ title: "Centralize frontend API Zod schemas in `web/src/api-schemas.ts`",
144
+ rationale: "Keep one audit surface for the browser-to-daemon contract and require validated JSON reads; issue #40, PR #63.",
145
+ decidedAt: "2026-05-08",
146
+ tier: "warm",
147
+ },
148
+ {
149
+ title: "Make frontend test isolation an infrastructure concern",
150
+ rationale: "Vitest globals, `test-setup.ts`, and MSW own cleanup so tests do not hand-roll it; issue #43, PR #62.",
151
+ decidedAt: "2026-05-08",
152
+ tier: "warm",
153
+ },
154
+ {
155
+ title: "Clean `dist/` before `npm test`",
156
+ rationale: "Remove stale compiled artifacts so deleted tests cannot linger in the Node test run; `package.json`.",
157
+ decidedAt: "2026-05-08",
158
+ tier: "warm",
159
+ },
160
+ {
161
+ title: "Adopt `pino` as the backend logger",
162
+ rationale: "Use `childLogger()` everywhere and keep chat content at `debug`, not `info`; issue #13, PR #28.",
163
+ decidedAt: "2026-05-08",
164
+ tier: "warm",
165
+ },
166
+ {
167
+ title: "Keep daemon control as a thin CLI over `src/daemon-install.ts`",
168
+ rationale: "Generate service artifacts in one module and support launchd and systemd user services without a separate control layer; issue #14, PR #24.",
169
+ decidedAt: "2026-05-08",
170
+ tier: "warm",
171
+ },
172
+ {
173
+ title: "Default publish workflows to Node 24+",
174
+ rationale: "npm Trusted Publishing depends on npm 11.5.1+, which Node 22 does not provide; `.github/workflows/npm-publish.yml`.",
175
+ decidedAt: "2026-05-08",
176
+ tier: "warm",
177
+ },
178
+ {
179
+ title: "Make `chapterhouse update` registry-aware",
180
+ rationale: "Registry installs update via npm, while legacy git installs keep the older pull-and-rebuild path; issue #31, PR #32.",
181
+ decidedAt: "2026-05-08",
182
+ tier: "warm",
183
+ },
184
+ {
185
+ title: "Keep markdownlint pragmatic rather than maximalist",
186
+ rationale: "Disable MD060, ignore `.github/agents/**`, and suppress MD041 only where template UX requires it; `.markdownlint-cli2.jsonc`.",
187
+ decidedAt: "2026-05-08",
188
+ tier: "warm",
189
+ },
190
+ {
191
+ title: "Enforce issue-closing references in feature and fix PRs",
192
+ rationale: "PR bodies must include `Closes #N`, `Fixes #N`, or `Resolves #N`, with a documented `no-issue` bypass; PR #60, `.github/workflows/lint-pr-closes.yml`.",
193
+ decidedAt: "2026-05-08",
194
+ tier: "warm",
195
+ },
196
+ {
197
+ title: "Use Conventional Commits for commits and PR titles",
198
+ rationale: "Keep repo history machine-readable and enforce it with commitlint, husky, and PR-title linting; `commitlint.config.js`, `.github/workflows/lint-pr-title.yml`.",
199
+ decidedAt: "2026-05-08",
200
+ tier: "warm",
201
+ },
202
+ ];
203
+ export function seedChapterhouseWikiIndexMemory(database) {
204
+ database.transaction(() => {
205
+ const chapterhouseScope = database.prepare(`
206
+ SELECT id
207
+ FROM mem_scopes
208
+ WHERE slug = 'chapterhouse'
209
+ `).get();
210
+ if (!chapterhouseScope) {
211
+ throw new Error("Cannot seed Chapterhouse wiki memory because scope 'chapterhouse' does not exist.");
212
+ }
213
+ database.prepare(`
214
+ INSERT INTO mem_entities (scope_id, kind, name, summary, tier, confidence, created_at, updated_at)
215
+ VALUES (?, ?, ?, ?, 'warm', 1.0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
216
+ ON CONFLICT(scope_id, kind, name) DO UPDATE SET
217
+ summary = excluded.summary,
218
+ updated_at = CURRENT_TIMESTAMP
219
+ `).run(chapterhouseScope.id, CHAPTERHOUSE_PROJECT_ENTITY_SEED.kind, CHAPTERHOUSE_PROJECT_ENTITY_SEED.name, CHAPTERHOUSE_PROJECT_ENTITY_SEED.summary);
220
+ const chapterhouseEntity = database.prepare(`
221
+ SELECT id
222
+ FROM mem_entities
223
+ WHERE scope_id = ? AND kind = ? AND name = ?
224
+ `).get(chapterhouseScope.id, CHAPTERHOUSE_PROJECT_ENTITY_SEED.kind, CHAPTERHOUSE_PROJECT_ENTITY_SEED.name);
225
+ if (!chapterhouseEntity) {
226
+ throw new Error("Cannot seed Chapterhouse wiki observations because the Chapterhouse entity was not created.");
227
+ }
228
+ const insertObservation = database.prepare(`
229
+ INSERT INTO mem_observations (
230
+ scope_id, entity_id, content, source, tier, confidence, tier_pinned_at, tier_reason, created_at
231
+ )
232
+ SELECT ?, ?, ?, ?, ?, 1.0,
233
+ CASE WHEN ? = 'hot' THEN CURRENT_TIMESTAMP ELSE NULL END,
234
+ CASE WHEN ? = 'hot' THEN ? ELSE NULL END,
235
+ CURRENT_TIMESTAMP
236
+ WHERE NOT EXISTS (
237
+ SELECT 1 FROM mem_observations WHERE scope_id = ? AND content = ?
238
+ )
239
+ `);
240
+ const updateObservation = database.prepare(`
241
+ UPDATE mem_observations
242
+ SET entity_id = ?,
243
+ source = ?,
244
+ tier = CASE WHEN ? = 'hot' THEN 'hot' ELSE tier END,
245
+ tier_pinned_at = CASE WHEN ? = 'hot' THEN COALESCE(tier_pinned_at, CURRENT_TIMESTAMP) ELSE tier_pinned_at END,
246
+ tier_reason = CASE WHEN ? = 'hot' THEN ? ELSE tier_reason END
247
+ WHERE scope_id = ? AND content = ?
248
+ `);
249
+ for (const observation of CHAPTERHOUSE_WIKI_OBSERVATION_SEEDS) {
250
+ insertObservation.run(chapterhouseScope.id, chapterhouseEntity.id, observation.content, CHAPTERHOUSE_WIKI_INDEX_SOURCE, observation.tier, observation.tier, observation.tier, CHAPTERHOUSE_WIKI_HOT_TIER_REASON, chapterhouseScope.id, observation.content);
251
+ updateObservation.run(chapterhouseEntity.id, CHAPTERHOUSE_WIKI_INDEX_SOURCE, observation.tier, observation.tier, observation.tier, CHAPTERHOUSE_WIKI_HOT_TIER_REASON, chapterhouseScope.id, observation.content);
252
+ }
253
+ const insertDecision = database.prepare(`
254
+ INSERT INTO mem_decisions (
255
+ scope_id, entity_id, title, rationale, decided_at, tier, tier_pinned_at, tier_reason, created_at
256
+ )
257
+ SELECT ?, ?, ?, ?, ?, ?,
258
+ CASE WHEN ? = 'hot' THEN CURRENT_TIMESTAMP ELSE NULL END,
259
+ CASE WHEN ? = 'hot' THEN ? ELSE NULL END,
260
+ CURRENT_TIMESTAMP
261
+ WHERE NOT EXISTS (
262
+ SELECT 1 FROM mem_decisions WHERE scope_id = ? AND title = ?
263
+ )
264
+ `);
265
+ const updateDecision = database.prepare(`
266
+ UPDATE mem_decisions
267
+ SET entity_id = ?,
268
+ rationale = ?,
269
+ decided_at = ?,
270
+ tier = ?,
271
+ tier_pinned_at = CASE WHEN ? = 'hot' THEN COALESCE(tier_pinned_at, CURRENT_TIMESTAMP) ELSE NULL END,
272
+ tier_reason = CASE WHEN ? = 'hot' THEN ? ELSE NULL END
273
+ WHERE scope_id = ? AND title = ?
274
+ `);
275
+ for (const decision of CHAPTERHOUSE_WIKI_DECISION_SEEDS) {
276
+ insertDecision.run(chapterhouseScope.id, chapterhouseEntity.id, decision.title, decision.rationale, decision.decidedAt, decision.tier, decision.tier, decision.tier, CHAPTERHOUSE_WIKI_DECISION_HOT_TIER_REASON, chapterhouseScope.id, decision.title);
277
+ updateDecision.run(chapterhouseEntity.id, decision.rationale, decision.decidedAt, decision.tier, decision.tier, decision.tier, CHAPTERHOUSE_WIKI_DECISION_HOT_TIER_REASON, chapterhouseScope.id, decision.title);
278
+ }
279
+ })();
280
+ }
281
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1,3 @@
1
+ export {};
2
+ // OKR/ADO persistence currently uses wiki-backed modules; no store-level OKR repository functions are defined yet.
3
+ //# sourceMappingURL=okr.js.map
@@ -0,0 +1,5 @@
1
+ import { getDb } from "../connection.js";
2
+ export function touchProjectsRepository() {
3
+ getDb().prepare("SELECT 1").get();
4
+ }
5
+ //# sourceMappingURL=projects.js.map
@@ -0,0 +1,284 @@
1
+ import { getCurrentRunId, getDb } from "../connection.js";
2
+ let logInsertCount = 0;
3
+ const MAX_CONVERSATION_CONTENT_LENGTH = 10_000;
4
+ export function resetSessionRepositoryForTests() {
5
+ logInsertCount = 0;
6
+ }
7
+ export function getState(key) {
8
+ const db = getDb();
9
+ const row = db.prepare(`SELECT value FROM max_state WHERE key = ?`).get(key);
10
+ return row?.value;
11
+ }
12
+ export function setState(key, value) {
13
+ const db = getDb();
14
+ db.prepare(`INSERT OR REPLACE INTO max_state (key, value) VALUES (?, ?)`).run(key, value);
15
+ }
16
+ /** Remove a key from persistent state. */
17
+ export function deleteState(key) {
18
+ const db = getDb();
19
+ db.prepare(`DELETE FROM max_state WHERE key = ?`).run(key);
20
+ }
21
+ /** Log a conversation turn (user, assistant, or system). */
22
+ export function logConversation(role, content, source, sessionKey = "default", metadata) {
23
+ const db = getDb();
24
+ db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, turn_id, agent_slug, agent_display_name, run_id)
25
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(role, content.slice(0, MAX_CONVERSATION_CONTENT_LENGTH), source, sessionKey, metadata?.turnId ?? null, metadata?.agentSlug ?? null, metadata?.agentDisplayName ?? null, getCurrentRunId());
26
+ // Keep last 1000 entries to support context recovery after session loss
27
+ logInsertCount++;
28
+ if (logInsertCount % 50 === 0) {
29
+ db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
30
+ }
31
+ }
32
+ /** Retrieve a stored Copilot session by key. */
33
+ export function getCopilotSession(sessionKey) {
34
+ const db = getDb();
35
+ const row = db
36
+ .prepare(`SELECT copilot_session_id, model FROM copilot_sessions WHERE session_key = ?`)
37
+ .get(sessionKey);
38
+ if (!row)
39
+ return undefined;
40
+ return { copilotSessionId: row.copilot_session_id, model: row.model ?? undefined };
41
+ }
42
+ /** Insert or update a Copilot session record. */
43
+ export function upsertCopilotSession(sessionKey, mode, copilotSessionId, projectRoot, model) {
44
+ const db = getDb();
45
+ db.prepare(`INSERT INTO copilot_sessions (session_key, mode, project_root, copilot_session_id, model, updated_at)
46
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
47
+ ON CONFLICT(session_key) DO UPDATE SET
48
+ copilot_session_id = excluded.copilot_session_id,
49
+ model = excluded.model,
50
+ project_root = excluded.project_root,
51
+ updated_at = CURRENT_TIMESTAMP`).run(sessionKey, mode, projectRoot ?? null, copilotSessionId, model ?? null);
52
+ }
53
+ /** Delete a stored Copilot session by key. */
54
+ export function deleteCopilotSession(sessionKey) {
55
+ getDb().prepare(`DELETE FROM copilot_sessions WHERE session_key = ?`).run(sessionKey);
56
+ }
57
+ /**
58
+ * Return the session_key stored against a task, or 'default' if the column
59
+ * doesn't exist yet (Kaylee will add it in a follow-on phase).
60
+ */
61
+ export function getTaskSessionKey(taskId) {
62
+ const db = getDb();
63
+ try {
64
+ const row = db
65
+ .prepare(`SELECT session_key FROM agent_tasks WHERE task_id = ?`)
66
+ .get(taskId);
67
+ return row?.session_key ?? "default";
68
+ }
69
+ catch {
70
+ return "default";
71
+ }
72
+ }
73
+ /**
74
+ * Return whether a batch of agent tasks has fully reached terminal completion.
75
+ *
76
+ * `completed` and `error` are terminal. Missing rows are treated as not-ready
77
+ * so callers do not open downstream barriers (for example, a Scribe merge pass)
78
+ * until every expected task has recorded a terminal state.
79
+ */
80
+ export function getTaskBarrierStatus(taskIds) {
81
+ const orderedTaskIds = Array.from(new Set(taskIds.filter((taskId) => taskId.trim().length > 0)));
82
+ if (orderedTaskIds.length === 0) {
83
+ return {
84
+ ready: true,
85
+ pendingTaskIds: [],
86
+ missingTaskIds: [],
87
+ statuses: [],
88
+ };
89
+ }
90
+ const db = getDb();
91
+ const placeholders = orderedTaskIds.map(() => "?").join(", ");
92
+ const rows = db.prepare(`SELECT task_id, status FROM agent_tasks WHERE task_id IN (${placeholders})`).all(...orderedTaskIds);
93
+ const statusByTaskId = new Map(rows.map((row) => [row.task_id, row.status ?? "missing"]));
94
+ const statuses = orderedTaskIds.map((taskId) => ({
95
+ taskId,
96
+ status: statusByTaskId.get(taskId) ?? "missing",
97
+ }));
98
+ const missingTaskIds = statuses.filter((entry) => entry.status === "missing").map((entry) => entry.taskId);
99
+ const pendingTaskIds = statuses
100
+ .filter((entry) => entry.status !== "completed" && entry.status !== "error")
101
+ .map((entry) => entry.taskId);
102
+ return {
103
+ ready: pendingTaskIds.length === 0,
104
+ pendingTaskIds,
105
+ missingTaskIds,
106
+ statuses,
107
+ };
108
+ }
109
+ /**
110
+ * Poll `getTaskBarrierStatus` until all tasks are terminal or the timeout
111
+ * is reached.
112
+ *
113
+ * On VS Code, all subagents in a single coordinator turn run concurrently,
114
+ * so Scribe cannot rely on siblings being complete at the instant it starts.
115
+ * This function lets Scribe (or any downstream pass) wait until every
116
+ * sibling has written its `.squad/decisions/inbox/` file and reached a
117
+ * terminal state before proceeding with the merge.
118
+ *
119
+ * Returns the last observed `TaskBarrierStatus`. Callers must check
120
+ * `.ready` — a `false` result means the timeout was reached with at least
121
+ * one task still pending.
122
+ */
123
+ export async function pollTaskBarrierStatus(taskIds, options) {
124
+ const intervalMs = options?.intervalMs ?? 500;
125
+ const timeoutMs = options?.timeoutMs ?? 30_000;
126
+ const deadline = Date.now() + timeoutMs;
127
+ while (true) {
128
+ const status = getTaskBarrierStatus(taskIds);
129
+ if (status.ready)
130
+ return status;
131
+ if (Date.now() >= deadline)
132
+ return status;
133
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
134
+ }
135
+ }
136
+ /**
137
+ * Get recent conversation history formatted for injection into system message.
138
+ *
139
+ * When `sessionKey` is provided, only rows belonging to that session are
140
+ * returned — useful for per-session isolation that must not bleed context
141
+ * from other sessions. When omitted, rows from all sessions are returned
142
+ * (legacy behavior for callers that don't care about session isolation).
143
+ */
144
+ export function getRecentConversation(limit, sessionKey) {
145
+ const db = getDb();
146
+ const effectiveLimit = limit ?? 20;
147
+ const rows = sessionKey
148
+ ? db.prepare(`SELECT role, content, source, ts FROM conversation_log WHERE session_key = ? ORDER BY id DESC LIMIT ?`).all(sessionKey, effectiveLimit)
149
+ : db.prepare(`SELECT role, content, source, ts FROM conversation_log ORDER BY id DESC LIMIT ?`).all(effectiveLimit);
150
+ if (rows.length === 0)
151
+ return "";
152
+ // Reverse so oldest is first (chronological order)
153
+ rows.reverse();
154
+ return rows.map((r) => {
155
+ const tag = r.role === "user" ? `[${r.source}] User`
156
+ : r.role === "system" ? `[${r.source}] System`
157
+ : r.role === "agent_completion" ? `[${r.source}] Agent completion`
158
+ : "Chapterhouse";
159
+ // Truncate long messages to keep context manageable
160
+ const content = r.content.length > 1500 ? r.content.slice(0, 1500) + "…" : r.content;
161
+ return `${tag}: ${content}`;
162
+ }).join("\n\n");
163
+ }
164
+ const MAX_SESSION_MESSAGES_LIMIT = 500;
165
+ const DEFAULT_SESSION_MESSAGES_LIMIT = 100;
166
+ /**
167
+ * Normalize a SQLite CURRENT_TIMESTAMP string to a proper ISO-8601 UTC string.
168
+ *
169
+ * SQLite stores CURRENT_TIMESTAMP as "YYYY-MM-DD HH:MM:SS" — no timezone
170
+ * marker, space separator. Browsers that receive this bare string may parse it
171
+ * as *local* time instead of UTC, shifting every displayed timestamp by the
172
+ * user's UTC offset. This helper appends the `Z` suffix (and replaces the space
173
+ * separator with `T`) so that `new Date(ts)` always parses as UTC.
174
+ *
175
+ * - Already-ISO strings (containing `T`) are returned unchanged (idempotent).
176
+ * - Strings with a `T` but no `Z` are left as-is; they are already ISO format
177
+ * and the caller is responsible for any timezone semantics (we do not blindly
178
+ * append `Z` and risk double-shifting a value that might already be local).
179
+ * - Falsy / empty input is returned as an empty string rather than throwing.
180
+ */
181
+ export function normalizeSqliteTsToIso(ts) {
182
+ if (!ts)
183
+ return "";
184
+ if (ts.includes("T"))
185
+ return ts;
186
+ return ts.replace(" ", "T") + "Z";
187
+ }
188
+ /**
189
+ * Return conversation_log rows for a specific session as structured JSON,
190
+ * suitable for seeding the frontend Zustand store on mount.
191
+ *
192
+ * Unlike `getRecentConversation()`, this returns structured objects (not a
193
+ * formatted string) and omits internal system messages. Synthetic background
194
+ * completion notices are included and mapped to assistant-style turns so reload
195
+ * matches the live chat rendering path.
196
+ */
197
+ export function getSessionMessages(sessionKey, limit, options = {}) {
198
+ const db = getDb();
199
+ const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
200
+ const includeHistorical = options.includeHistorical ?? false;
201
+ const runId = options.runId ?? getCurrentRunId();
202
+ const rows = includeHistorical
203
+ ? db
204
+ .prepare(`SELECT id, role, content, ts, turn_id, agent_slug, agent_display_name FROM conversation_log
205
+ WHERE session_key = ? AND role IN ('user', 'assistant', 'agent_completion')
206
+ ORDER BY id DESC LIMIT ?`)
207
+ .all(sessionKey, effectiveLimit)
208
+ : db
209
+ .prepare(`SELECT id, role, content, ts, turn_id, agent_slug, agent_display_name FROM conversation_log
210
+ WHERE session_key = ? AND run_id = ? AND role IN ('user', 'assistant', 'agent_completion')
211
+ ORDER BY id DESC LIMIT ?`)
212
+ .all(sessionKey, runId, effectiveLimit);
213
+ // Reverse so oldest is first (chronological order for the UI)
214
+ rows.reverse();
215
+ return rows.map((r) => {
216
+ const message = {
217
+ id: r.id,
218
+ role: r.role === "agent_completion" ? "assistant" : r.role,
219
+ content: r.content,
220
+ ts: normalizeSqliteTsToIso(r.ts),
221
+ turn_id: r.turn_id,
222
+ };
223
+ if (r.turn_id)
224
+ message.turnId = r.turn_id;
225
+ if (r.agent_slug)
226
+ message.agentSlug = r.agent_slug;
227
+ if (r.agent_display_name)
228
+ message.agentDisplayName = r.agent_display_name;
229
+ return message;
230
+ });
231
+ }
232
+ /**
233
+ * Append one event to agent_task_events and return the new event.
234
+ * Uses a transaction so seq is monotonically incremented.
235
+ * Non-fatal: silently ignores DB errors (task may not exist yet due to race).
236
+ */
237
+ export function appendTaskEvent(taskId, kind, toolName, summary, text = null, status = null) {
238
+ const db = getDb();
239
+ try {
240
+ return db.transaction(() => {
241
+ db.prepare(`UPDATE agent_tasks SET event_seq = event_seq + 1 WHERE task_id = ?`).run(taskId);
242
+ const row = db.prepare(`SELECT event_seq FROM agent_tasks WHERE task_id = ?`).get(taskId);
243
+ if (!row)
244
+ return undefined;
245
+ const seq = row.event_seq;
246
+ const ts = Date.now();
247
+ const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary, text, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary, text, status);
248
+ return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary, text, status };
249
+ })();
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ }
255
+ export function appendTaskOutputDeltaEvent(taskId, text) {
256
+ return appendTaskEvent(taskId, "output_delta", null, null, text, null);
257
+ }
258
+ export function appendTaskStatusEvent(taskId, status, summary = null) {
259
+ return appendTaskEvent(taskId, "task_status", null, summary, null, status);
260
+ }
261
+ export function updateTaskResult(taskId, status, result) {
262
+ const db = getDb();
263
+ db.prepare(`UPDATE agent_tasks SET status = ?, result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(status, result ? result.slice(0, 10000) : null, taskId);
264
+ }
265
+ /**
266
+ * Return all events for a task ordered by seq ascending.
267
+ */
268
+ export function getTaskEvents(taskId, afterSeq = 0) {
269
+ const db = getDb();
270
+ const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary, text, status
271
+ FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
272
+ return rows.map((r) => ({
273
+ id: r.id,
274
+ taskId: r.task_id,
275
+ seq: r.seq,
276
+ ts: r.ts,
277
+ kind: r.kind,
278
+ toolName: r.tool_name,
279
+ summary: r.summary,
280
+ text: r.text,
281
+ status: r.status,
282
+ }));
283
+ }
284
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1,60 @@
1
+ import { ensureWikiStructure, listPages, readPage } from "../../wiki/fs.js";
2
+ import { parseWikiFrontmatter } from "../../wiki/frontmatter.js";
3
+ const ACTION_LOG_PAGE_RE = /^pages\/_meta\/log(?:-\d{4})?\.md$/;
4
+ const LEGACY_INDEX_PAGE = "pages/index.md";
5
+ function isIgnoredWikiIndexPage(path) {
6
+ return path === LEGACY_INDEX_PAGE || ACTION_LOG_PAGE_RE.test(path);
7
+ }
8
+ function wikiBasenameTitle(path) {
9
+ const segs = path.split("/").filter(Boolean);
10
+ const file = segs[segs.length - 1] || path;
11
+ const base = file.replace(/\.md$/, "");
12
+ const titleBase = base === "index" && segs.length >= 2 ? segs[segs.length - 2] : base;
13
+ return titleBase.split(/[-_]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
14
+ }
15
+ function summarizeWikiBody(body) {
16
+ for (const raw of body.split("\n")) {
17
+ const line = raw.trim();
18
+ if (!line || line.startsWith("#") || line.startsWith("<!--")) {
19
+ continue;
20
+ }
21
+ const summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
22
+ if (summary) {
23
+ return summary.length > 160 ? `${summary.slice(0, 157)}…` : summary;
24
+ }
25
+ }
26
+ return "";
27
+ }
28
+ export function seedWikiPagesFromDisk(database) {
29
+ ensureWikiStructure();
30
+ const pages = listPages().filter((page) => !isIgnoredWikiIndexPage(page));
31
+ if (pages.length === 0) {
32
+ return;
33
+ }
34
+ const wikiPageCount = database.prepare(`SELECT COUNT(*) AS count FROM wiki_pages`).get().count;
35
+ if (wikiPageCount > 0) {
36
+ return;
37
+ }
38
+ const upsert = database.prepare(`
39
+ INSERT INTO wiki_pages (path, title, entity_type, tags, summary, last_updated)
40
+ VALUES (?, ?, ?, ?, ?, ?)
41
+ ON CONFLICT(path) DO UPDATE SET
42
+ title = excluded.title,
43
+ entity_type = excluded.entity_type,
44
+ tags = excluded.tags,
45
+ summary = excluded.summary,
46
+ last_updated = excluded.last_updated,
47
+ version = wiki_pages.version + 1
48
+ `);
49
+ for (const page of pages) {
50
+ const content = readPage(page);
51
+ if (!content) {
52
+ continue;
53
+ }
54
+ const { parsed: fm, body } = parseWikiFrontmatter(content);
55
+ const summary = fm.summary?.trim() || summarizeWikiBody(body) || fm.title || wikiBasenameTitle(page);
56
+ const entityType = fm.metadata?.["entity_type"] ?? null;
57
+ upsert.run(page, fm.title ?? wikiBasenameTitle(page), entityType, JSON.stringify(fm.tags ?? []), summary, fm.updated ?? new Date().toISOString());
58
+ }
59
+ }
60
+ //# sourceMappingURL=wiki.js.map