wyrm-mcp 7.3.2 → 7.3.3

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 (184) hide show
  1. package/README.md +8 -14
  2. package/dist/activation.js +59 -1
  3. package/dist/agent-daemon.js +281 -4
  4. package/dist/agent-loop.js +332 -7
  5. package/dist/analytics.js +236 -13
  6. package/dist/attribution.js +49 -1
  7. package/dist/audit.js +457 -2
  8. package/dist/auto-capture.js +138 -3
  9. package/dist/auto-orchestrator.js +325 -1
  10. package/dist/autoconfig.d.ts +50 -0
  11. package/dist/autoconfig.d.ts.map +1 -1
  12. package/dist/autoconfig.js +1115 -39
  13. package/dist/autoconfig.js.map +1 -1
  14. package/dist/buddy-runner.js +109 -1
  15. package/dist/buddy.js +564 -14
  16. package/dist/build-flags.js +15 -1
  17. package/dist/capabilities.js +183 -3
  18. package/dist/capture.js +56 -1
  19. package/dist/causality.js +148 -8
  20. package/dist/cli.js +281 -20
  21. package/dist/cloud/cli.js +541 -5
  22. package/dist/cloud/client.js +221 -1
  23. package/dist/cloud/crypto.js +85 -1
  24. package/dist/cloud/machine-id.js +113 -2
  25. package/dist/cloud/recovery.js +60 -1
  26. package/dist/cloud/sync-engine.js +543 -7
  27. package/dist/cloud-backup.js +579 -5
  28. package/dist/cloud-profile.js +138 -1
  29. package/dist/cloud-sync-entrypoint.js +47 -1
  30. package/dist/cloud-sync.js +309 -2
  31. package/dist/connectors/bridge-source.d.ts +46 -0
  32. package/dist/connectors/bridge-source.d.ts.map +1 -0
  33. package/dist/connectors/bridge-source.js +77 -0
  34. package/dist/connectors/bridge-source.js.map +1 -0
  35. package/dist/connectors/index.d.ts +24 -0
  36. package/dist/connectors/index.d.ts.map +1 -0
  37. package/dist/connectors/index.js +69 -0
  38. package/dist/connectors/index.js.map +1 -0
  39. package/dist/connectors/ingest.d.ts +16 -0
  40. package/dist/connectors/ingest.d.ts.map +1 -0
  41. package/dist/connectors/ingest.js +116 -0
  42. package/dist/connectors/ingest.js.map +1 -0
  43. package/dist/connectors/types.d.ts +99 -0
  44. package/dist/connectors/types.d.ts.map +1 -0
  45. package/dist/connectors/types.js +17 -0
  46. package/dist/connectors/types.js.map +1 -0
  47. package/dist/constellation.js +168 -12
  48. package/dist/content-signature.js +45 -1
  49. package/dist/context-build-budgeted.js +144 -4
  50. package/dist/context-ranking.js +69 -1
  51. package/dist/crypto.js +179 -1
  52. package/dist/daemon-write-endpoint.js +290 -1
  53. package/dist/daemon-writer.js +406 -2
  54. package/dist/database.js +1278 -53
  55. package/dist/deprecations.js +162 -2
  56. package/dist/design.js +141 -13
  57. package/dist/event-replication.js +112 -1
  58. package/dist/events-sse.js +43 -7
  59. package/dist/events.js +238 -6
  60. package/dist/failure-patterns.d.ts +107 -0
  61. package/dist/failure-patterns.d.ts.map +1 -1
  62. package/dist/failure-patterns.js +924 -43
  63. package/dist/failure-patterns.js.map +1 -1
  64. package/dist/federation.js +236 -12
  65. package/dist/goals.js +101 -13
  66. package/dist/golden.js +355 -3
  67. package/dist/handlers/agent.js +165 -4
  68. package/dist/handlers/alias-adapters.js +129 -1
  69. package/dist/handlers/aliases.js +171 -1
  70. package/dist/handlers/audit.js +87 -1
  71. package/dist/handlers/boundary.js +221 -1
  72. package/dist/handlers/capture.js +1114 -73
  73. package/dist/handlers/causality.js +119 -9
  74. package/dist/handlers/cloud.js +382 -85
  75. package/dist/handlers/companion.js +459 -28
  76. package/dist/handlers/datalake.js +187 -7
  77. package/dist/handlers/dispatch-context.js +22 -0
  78. package/dist/handlers/entity.js +256 -25
  79. package/dist/handlers/events.js +335 -16
  80. package/dist/handlers/failure.d.ts.map +1 -1
  81. package/dist/handlers/failure.js +408 -13
  82. package/dist/handlers/failure.js.map +1 -1
  83. package/dist/handlers/goals.js +296 -4
  84. package/dist/handlers/intelligence.js +681 -126
  85. package/dist/handlers/invoicing.js +70 -1
  86. package/dist/handlers/mcpclient.js +137 -6
  87. package/dist/handlers/orchestration.js +125 -40
  88. package/dist/handlers/output-schemas.js +24 -1
  89. package/dist/handlers/presence.js +99 -3
  90. package/dist/handlers/project.js +182 -28
  91. package/dist/handlers/prompts.js +157 -6
  92. package/dist/handlers/quest.js +224 -4
  93. package/dist/handlers/recall.js +237 -13
  94. package/dist/handlers/registry.js +167 -1
  95. package/dist/handlers/resources.js +288 -1
  96. package/dist/handlers/review.js +74 -11
  97. package/dist/handlers/run.js +498 -16
  98. package/dist/handlers/search.js +338 -15
  99. package/dist/handlers/session.js +643 -31
  100. package/dist/handlers/share.js +184 -8
  101. package/dist/handlers/shims.js +464 -1
  102. package/dist/handlers/skill.js +449 -67
  103. package/dist/handlers/survivors.js +120 -1
  104. package/dist/handlers/symbols.js +109 -8
  105. package/dist/handlers/syncops.js +302 -4
  106. package/dist/handlers/types.js +27 -1
  107. package/dist/harvest.js +191 -5
  108. package/dist/hours.js +156 -7
  109. package/dist/http-auth.js +321 -3
  110. package/dist/http-fast.js +1302 -22
  111. package/dist/icons.js +47 -1
  112. package/dist/importers.js +268 -1
  113. package/dist/index.js +840 -2
  114. package/dist/indexer.js +145 -4
  115. package/dist/intelligence.js +261 -31
  116. package/dist/internal-dispatch.js +212 -3
  117. package/dist/keyset.js +110 -1
  118. package/dist/knowledge-graph.js +176 -12
  119. package/dist/license.js +441 -2
  120. package/dist/logger.js +199 -2
  121. package/dist/maintenance.js +148 -2
  122. package/dist/mcp-client.js +262 -6
  123. package/dist/memory-artifacts.js +596 -32
  124. package/dist/migrate-prompt.js +124 -2
  125. package/dist/migrations.d.ts.map +1 -1
  126. package/dist/migrations.js +799 -42
  127. package/dist/migrations.js.map +1 -1
  128. package/dist/performance.js +228 -1
  129. package/dist/presence.js +140 -11
  130. package/dist/priority-embed.js +164 -5
  131. package/dist/providers/embedding-provider.js +196 -1
  132. package/dist/readonly-gate.js +29 -1
  133. package/dist/receipt.js +43 -1
  134. package/dist/rehydration.js +157 -9
  135. package/dist/reindex.js +88 -1
  136. package/dist/render-target.js +544 -21
  137. package/dist/render.js +280 -4
  138. package/dist/repl-guard.js +173 -1
  139. package/dist/replication-daemon-entrypoint.js +31 -1
  140. package/dist/replication-daemon.js +262 -2
  141. package/dist/rerank.js +142 -1
  142. package/dist/resilience.js +591 -1
  143. package/dist/reverse-bridge.js +360 -5
  144. package/dist/security.js +244 -1
  145. package/dist/session-seen.js +51 -3
  146. package/dist/setup.js +260 -1
  147. package/dist/skill-author.js +168 -5
  148. package/dist/spec-kit.js +191 -1
  149. package/dist/sqlite-busy.js +154 -1
  150. package/dist/statusline.js +315 -11
  151. package/dist/sub-agent.js +262 -13
  152. package/dist/summarizer.js +139 -13
  153. package/dist/symbols.js +283 -7
  154. package/dist/sync.js +359 -5
  155. package/dist/tasks-dispatch.js +84 -1
  156. package/dist/tasks.js +282 -1
  157. package/dist/token-budget.js +143 -1
  158. package/dist/tool-analytics.js +129 -7
  159. package/dist/tool-annotations.js +365 -1
  160. package/dist/tool-manifest-v2.json +1 -1
  161. package/dist/tool-manifest.json +1 -1
  162. package/dist/tool-profiles.js +75 -1
  163. package/dist/trace-harvest.js +244 -6
  164. package/dist/types.js +30 -1
  165. package/dist/ui-dashboard.js +50 -41
  166. package/dist/ulid.js +81 -1
  167. package/dist/usage-tracker.js +66 -1
  168. package/dist/validate.js +129 -1
  169. package/dist/vault.js +534 -1
  170. package/dist/vector-init.js +67 -1
  171. package/dist/vectors.js +184 -3
  172. package/dist/version-check.js +136 -4
  173. package/dist/visibility.js +155 -19
  174. package/dist/wyrm-cli.js +2845 -101
  175. package/dist/wyrm-cli.js.map +1 -1
  176. package/dist/wyrm-guard.d.ts.map +1 -1
  177. package/dist/wyrm-guard.js +475 -14
  178. package/dist/wyrm-guard.js.map +1 -1
  179. package/dist/wyrm-loop.js +150 -3
  180. package/dist/wyrm-manifest.json +1 -1
  181. package/dist/wyrm-statusline-daemon.js +11 -1
  182. package/dist/wyrm-statusline.js +56 -4
  183. package/dist/wyrm-ui.js +77 -9
  184. package/package.json +1 -1
package/dist/database.js CHANGED
@@ -1,11 +1,264 @@
1
- import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync as D,statSync as L,writeFileSync as I}from"fs";import{homedir as O}from"os";import{join as d,basename as A,resolve as M,normalize as w}from"path";import{spawnSync as R}from"child_process";import{getResilienceManager as v}from"./resilience.js";import{WyrmLogger as j}from"./logger.js";import{runMigrations as H,getSchemaVersion as F}from"./migrations.js";import{validateProjectPath as N,buildFtsMatchQuery as h}from"./security.js";import{emitEvent as m,eventsSince as W,subscribeEvents as U,isLiveMemoryEnabled as x,ingestRemoteEvent as P,eventsForPush as B,pruneEvents as q,getMeta as Y,setMeta as $}from"./events.js";import{getActor as C}from"./handlers/boundary.js";import{readSkillContent as y,slugify as G,skillContentSha as V}from"./skill-author.js";function ae(){return process.env.WYRM_DB_PATH??process.env.WYRM_DB??d(O(),".wyrm","wyrm.db")}class ce{db;BATCH_SIZE=1e3;resilience;logger;dbPath;constructor(t){const e=d(O(),".wyrm");u(e)||b(e,{recursive:!0}),this.dbPath=t||d(e,"wyrm.db"),this.logger=new j,this.resilience=v(),this.db=this.initializeDatabase(this.dbPath),this.db.pragma("journal_mode = WAL"),this.db.pragma("synchronous = NORMAL"),this.db.pragma("cache_size = -64000"),this.db.pragma("temp_store = MEMORY"),this.db.pragma("busy_timeout = 5000"),this.db.pragma("mmap_size = 268435456"),this.db.pragma("page_size = 4096"),this.db.pragma("foreign_keys = ON");const s=H(this.db);s.length>0&&this.logger.info(`Applied ${s.length} migration(s), now at v${F(this.db)}`),this.recoverIncompleteOperations()}getDatabase(){return this.db}getDatabasePath(){return this.dbPath}initializeDatabase(t){const e=this.resilience.withRetrySync(()=>new k(t),"database_init",{maxAttempts:3,baseDelayMs:500});if(!e.success)throw this.logger.error("Failed to initialize database",{path:t,error:e.error?.message}),e.error||new Error("Database initialization failed");return e.data}recoverIncompleteOperations(){const t=this.resilience.getIncompleteOperations();for(const e of t)this.logger.warn("Found incomplete operation from previous session",{operation:e.operation,stage:e.stage,id:e.id}),e.operation==="batch_insert"&&this.logger.info("Batch insert was incomplete - data may need re-import"),this.resilience.completeCheckpoint(e.id)}addWatchDir(t,e=!0){return N(t),this.db.prepare(`
1
+ /**
2
+ * Wyrm Database - SQLite storage for infinite memory with data lake support
3
+ *
4
+ * @copyright 2026 Ghost Protocol (Pvt) Ltd.
5
+ * @license Proprietary — (c) 2026 Ghost Protocol (Pvt) Ltd. All rights reserved. See LICENSE.
6
+ *
7
+ * Features:
8
+ * - Auto-discovers projects in configured directories
9
+ * - Handles large datasets with pagination and streaming
10
+ * - Write-Ahead Logging (WAL) for concurrent performance
11
+ * - Full-text search for fast context retrieval
12
+ * - Batch operations for bulk imports
13
+ * - Resilient operations with automatic recovery
14
+ */
15
+ import Database from 'better-sqlite3';
16
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'fs';
17
+ import { homedir } from 'os';
18
+ import { join, basename, resolve, normalize } from 'path';
19
+ import { spawnSync } from 'child_process';
20
+ import { getResilienceManager } from './resilience.js';
21
+ import { WyrmLogger } from './logger.js';
22
+ import { runMigrations, getSchemaVersion } from './migrations.js';
23
+ import { validateProjectPath, buildFtsMatchQuery } from './security.js';
24
+ import { emitEvent, eventsSince, subscribeEvents, isLiveMemoryEnabled, ingestRemoteEvent, eventsForPush, pruneEvents, getMeta, setMeta } from './events.js';
25
+ import { getActor } from './handlers/boundary.js';
26
+ import { readSkillContent, slugify as slugifySkillName, skillContentSha } from './skill-author.js';
27
+ /**
28
+ * Canonical DB-path resolution for every bare WyrmDB consumer (the stdio MCP
29
+ * server, statusline, cloud sync). Honors WYRM_DB_PATH (canonical — what the
30
+ * CLI, HTTP server and docs already use); accepts the legacy WYRM_DB that
31
+ * autoconfig historically wrote, for back-compat; otherwise the default store.
32
+ * The constructor itself stays env-free, so an explicit path (e.g. a test
33
+ * sandbox) always wins.
34
+ */
35
+ export function resolveDbPath() {
36
+ return process.env.WYRM_DB_PATH ?? process.env.WYRM_DB ?? join(homedir(), '.wyrm', 'wyrm.db');
37
+ }
38
+ export class WyrmDB {
39
+ db;
40
+ BATCH_SIZE = 1000;
41
+ resilience;
42
+ logger;
43
+ dbPath;
44
+ constructor(dbPath) {
45
+ const wyrmDir = join(homedir(), '.wyrm');
46
+ if (!existsSync(wyrmDir)) {
47
+ mkdirSync(wyrmDir, { recursive: true });
48
+ }
49
+ this.dbPath = dbPath || join(wyrmDir, 'wyrm.db');
50
+ this.logger = new WyrmLogger();
51
+ this.resilience = getResilienceManager();
52
+ // Initialize database with resilience
53
+ this.db = this.initializeDatabase(this.dbPath);
54
+ // Enable WAL mode for better concurrent performance and crash recovery
55
+ this.db.pragma('journal_mode = WAL');
56
+ this.db.pragma('synchronous = NORMAL');
57
+ this.db.pragma('cache_size = -64000'); // 64MB cache
58
+ this.db.pragma('temp_store = MEMORY');
59
+ // busy_timeout = 5000 — the documented multi-process choice (v7 F2, T011).
60
+ // Under WAL there is exactly ONE writer at a time across ALL processes
61
+ // sharing this file; a contended writer spins inside SQLite for up to 5s
62
+ // before SQLITE_BUSY surfaces. Typical Wyrm row writes are sub-millisecond,
63
+ // so 5s absorbs entire bursts of concurrent fleet writers; anything still
64
+ // BUSY after 5s means a genuinely long-held lock (bulk import / vacuum),
65
+ // which the MCP dispatcher surfaces as the structured WYRM_BUSY retry body
66
+ // (sqlite-busy.ts) instead of an opaque error string.
67
+ this.db.pragma('busy_timeout = 5000');
68
+ this.db.pragma('mmap_size = 268435456'); // 256MB memory-mapped I/O
69
+ this.db.pragma('page_size = 4096'); // Optimal page size
70
+ this.db.pragma('foreign_keys = ON'); // Enforce referential integrity
71
+ // Run versioned migrations
72
+ const applied = runMigrations(this.db);
73
+ if (applied.length > 0) {
74
+ this.logger.info(`Applied ${applied.length} migration(s), now at v${getSchemaVersion(this.db)}`);
75
+ }
76
+ // Recover any incomplete operations from previous session
77
+ this.recoverIncompleteOperations();
78
+ }
79
+ /** Expose the raw database instance for analytics and other modules */
80
+ getDatabase() {
81
+ return this.db;
82
+ }
83
+ /** Get the database file path */
84
+ getDatabasePath() {
85
+ return this.dbPath;
86
+ }
87
+ /**
88
+ * Initialize database with retry logic for handling corruption/locks
89
+ */
90
+ initializeDatabase(path) {
91
+ const result = this.resilience.withRetrySync(() => new Database(path), 'database_init', { maxAttempts: 3, baseDelayMs: 500 });
92
+ if (!result.success) {
93
+ this.logger.error('Failed to initialize database', { path, error: result.error?.message });
94
+ throw result.error || new Error('Database initialization failed');
95
+ }
96
+ return result.data;
97
+ }
98
+ /**
99
+ * Recover incomplete operations from previous session
100
+ */
101
+ recoverIncompleteOperations() {
102
+ const incomplete = this.resilience.getIncompleteOperations();
103
+ for (const op of incomplete) {
104
+ this.logger.warn('Found incomplete operation from previous session', {
105
+ operation: op.operation,
106
+ stage: op.stage,
107
+ id: op.id,
108
+ });
109
+ // For now, just log - specific recovery logic can be added
110
+ // based on operation type
111
+ if (op.operation === 'batch_insert') {
112
+ this.logger.info('Batch insert was incomplete - data may need re-import');
113
+ }
114
+ // Mark as handled
115
+ this.resilience.completeCheckpoint(op.id);
116
+ }
117
+ }
118
+ // ==================== WATCH DIRECTORIES ====================
119
+ addWatchDir(path, recursive = true) {
120
+ // Validate path is within allowed directories to prevent directory traversal
121
+ validateProjectPath(path);
122
+ return this.db.prepare(`
2
123
  INSERT INTO watch_dirs (path, recursive)
3
124
  VALUES (?, ?)
4
125
  ON CONFLICT(path) DO UPDATE SET recursive = excluded.recursive
5
126
  RETURNING *
6
- `).get(t,e?1:0)}getWatchDirs(){return this.db.prepare("SELECT * FROM watch_dirs").all()}removeWatchDir(t){this.db.prepare("DELETE FROM watch_dirs WHERE path = ?").run(t)}scanForProjects(t,e=!0){N(t);const s=[],r=(n,i=0)=>{if(!(i>3&&e))try{const a=D(n,{withFileTypes:!0});for(const o of a){if(!o.isDirectory()||o.name.startsWith(".")&&o.name!==".git")continue;const c=d(n,o.name),l=d(c,".git");if(u(l)){const p=this.registerProjectFromPath(c);p&&s.push(p)}else e&&i<3&&r(c,i+1)}}catch{}};return r(t),this.db.prepare(`
127
+ `).get(path, recursive ? 1 : 0);
128
+ }
129
+ getWatchDirs() {
130
+ return this.db.prepare('SELECT * FROM watch_dirs').all();
131
+ }
132
+ removeWatchDir(path) {
133
+ this.db.prepare('DELETE FROM watch_dirs WHERE path = ?').run(path);
134
+ }
135
+ // ==================== AUTO-DISCOVERY ====================
136
+ scanForProjects(rootPath, recursive = true) {
137
+ // Validate path is within allowed directories to prevent directory traversal
138
+ validateProjectPath(rootPath);
139
+ const discovered = [];
140
+ const scan = (dir, depth = 0) => {
141
+ if (depth > 3 && recursive)
142
+ return; // Max 3 levels deep
143
+ try {
144
+ const entries = readdirSync(dir, { withFileTypes: true });
145
+ for (const entry of entries) {
146
+ if (!entry.isDirectory())
147
+ continue;
148
+ if (entry.name.startsWith('.') && entry.name !== '.git')
149
+ continue;
150
+ const fullPath = join(dir, entry.name);
151
+ // Check if it's a git repo
152
+ const gitDir = join(fullPath, '.git');
153
+ if (existsSync(gitDir)) {
154
+ const project = this.registerProjectFromPath(fullPath);
155
+ if (project)
156
+ discovered.push(project);
157
+ }
158
+ else if (recursive && depth < 3) {
159
+ scan(fullPath, depth + 1);
160
+ }
161
+ }
162
+ }
163
+ catch {
164
+ // Skip inaccessible directories
165
+ }
166
+ };
167
+ scan(rootPath);
168
+ // Update last scan time
169
+ this.db.prepare(`
7
170
  UPDATE watch_dirs SET last_scan = datetime('now') WHERE path = ?
8
- `).run(t),s}scanAllWatchDirs(){const t=this.getWatchDirs(),e=[];for(const s of t){const r=this.scanForProjects(s.path,!!s.recursive);e.push(...r)}return e}registerProjectFromPath(t){try{const e=w(M(t));if(!u(e)||!L(e).isDirectory())return null;const s=A(e);let r,n,i,a;try{const o=R("git",["config","--get","remote.origin.url"],{cwd:e,encoding:"utf-8",timeout:5e3,shell:!1});o.status===0&&(r=o.stdout.trim());const c=R("git",["rev-parse","--abbrev-ref","HEAD"],{cwd:e,encoding:"utf-8",timeout:5e3,shell:!1});c.status===0&&(n=c.stdout.trim());const l=R("git",["log","-1","--format=%h %s"],{cwd:e,encoding:"utf-8",timeout:5e3,shell:!1});l.status===0&&(i=l.stdout.trim())}catch{}return u(d(e,"package.json"))?(a="Node.js",u(d(e,"next.config.js"))||u(d(e,"next.config.ts"))||u(d(e,"next.config.mjs"))?a="Next.js":u(d(e,"vite.config.ts"))&&(a="Vite")):u(d(e,"requirements.txt"))||u(d(e,"pyproject.toml"))?a="Python":u(d(e,"composer.json"))?a="PHP":u(d(e,"Cargo.toml"))?a="Rust":u(d(e,"go.mod"))&&(a="Go"),this.registerProject(s,e,r,a,i,n)}catch{return null}}registerProject(t,e,s,r,n,i){return this.db.prepare(`
171
+ `).run(rootPath);
172
+ return discovered;
173
+ }
174
+ scanAllWatchDirs() {
175
+ const dirs = this.getWatchDirs();
176
+ const all = [];
177
+ for (const dir of dirs) {
178
+ const found = this.scanForProjects(dir.path, !!dir.recursive);
179
+ all.push(...found);
180
+ }
181
+ return all;
182
+ }
183
+ registerProjectFromPath(projectPath) {
184
+ try {
185
+ // SECURITY: Validate path is a real directory before any operations
186
+ const normalizedPath = normalize(resolve(projectPath));
187
+ if (!existsSync(normalizedPath) || !statSync(normalizedPath).isDirectory()) {
188
+ return null;
189
+ }
190
+ const name = basename(normalizedPath);
191
+ let repo;
192
+ let branch;
193
+ let lastCommit;
194
+ let stack;
195
+ try {
196
+ // SECURITY: Use spawnSync with shell: false to prevent command injection
197
+ const repoResult = spawnSync('git', ['config', '--get', 'remote.origin.url'], {
198
+ cwd: normalizedPath,
199
+ encoding: 'utf-8',
200
+ timeout: 5000,
201
+ shell: false // CRITICAL: No shell interpretation
202
+ });
203
+ if (repoResult.status === 0) {
204
+ repo = repoResult.stdout.trim();
205
+ }
206
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
207
+ cwd: normalizedPath,
208
+ encoding: 'utf-8',
209
+ timeout: 5000,
210
+ shell: false
211
+ });
212
+ if (branchResult.status === 0) {
213
+ branch = branchResult.stdout.trim();
214
+ }
215
+ const commitResult = spawnSync('git', ['log', '-1', '--format=%h %s'], {
216
+ cwd: normalizedPath,
217
+ encoding: 'utf-8',
218
+ timeout: 5000,
219
+ shell: false
220
+ });
221
+ if (commitResult.status === 0) {
222
+ lastCommit = commitResult.stdout.trim();
223
+ }
224
+ }
225
+ catch {
226
+ // Not a git repo or git not available
227
+ }
228
+ // Detect stack
229
+ if (existsSync(join(normalizedPath, 'package.json'))) {
230
+ stack = 'Node.js';
231
+ if (existsSync(join(normalizedPath, 'next.config.js')) ||
232
+ existsSync(join(normalizedPath, 'next.config.ts')) ||
233
+ existsSync(join(normalizedPath, 'next.config.mjs'))) {
234
+ stack = 'Next.js';
235
+ }
236
+ else if (existsSync(join(normalizedPath, 'vite.config.ts'))) {
237
+ stack = 'Vite';
238
+ }
239
+ }
240
+ else if (existsSync(join(normalizedPath, 'requirements.txt')) ||
241
+ existsSync(join(normalizedPath, 'pyproject.toml'))) {
242
+ stack = 'Python';
243
+ }
244
+ else if (existsSync(join(normalizedPath, 'composer.json'))) {
245
+ stack = 'PHP';
246
+ }
247
+ else if (existsSync(join(normalizedPath, 'Cargo.toml'))) {
248
+ stack = 'Rust';
249
+ }
250
+ else if (existsSync(join(normalizedPath, 'go.mod'))) {
251
+ stack = 'Go';
252
+ }
253
+ return this.registerProject(name, normalizedPath, repo, stack, lastCommit, branch);
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ }
259
+ // ==================== PROJECTS ====================
260
+ registerProject(name, path, repo, stack, lastCommit, branch) {
261
+ const stmt = this.db.prepare(`
9
262
  INSERT INTO projects (name, path, repo, stack, last_commit, branch)
10
263
  VALUES (?, ?, ?, ?, ?, ?)
11
264
  ON CONFLICT(path) DO UPDATE SET
@@ -16,33 +269,214 @@ import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync
16
269
  branch = COALESCE(excluded.branch, projects.branch),
17
270
  updated_at = datetime('now')
18
271
  RETURNING *
19
- `).get(t,e,s||null,r||null,n||null,i||null)}getProject(t){return this.db.prepare("SELECT * FROM projects WHERE path = ?").get(t)}getProjectById(t){return this.db.prepare("SELECT * FROM projects WHERE id = ?").get(t)}getProjectByName(t){return this.db.prepare("SELECT * FROM projects WHERE name = ?").get(t)}getAllProjects(t=100,e=0){return this.db.prepare(`
272
+ `);
273
+ return stmt.get(name, path, repo || null, stack || null, lastCommit || null, branch || null);
274
+ }
275
+ getProject(path) {
276
+ return this.db.prepare('SELECT * FROM projects WHERE path = ?').get(path);
277
+ }
278
+ getProjectById(id) {
279
+ return this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
280
+ }
281
+ getProjectByName(name) {
282
+ return this.db.prepare('SELECT * FROM projects WHERE name = ?').get(name);
283
+ }
284
+ getAllProjects(limit = 100, offset = 0) {
285
+ return this.db.prepare(`
20
286
  SELECT * FROM projects ORDER BY updated_at DESC LIMIT ? OFFSET ?
21
- `).all(t,e)}searchProjects(t){const e=`%${t}%`;return this.db.prepare(`
287
+ `).all(limit, offset);
288
+ }
289
+ searchProjects(query) {
290
+ const pattern = `%${query}%`;
291
+ return this.db.prepare(`
22
292
  SELECT * FROM projects
23
293
  WHERE name LIKE ? OR stack LIKE ? OR repo LIKE ?
24
294
  ORDER BY updated_at DESC
25
295
  LIMIT 50
26
- `).all(e,e,e)}createSession(t,e){const s=this.estimateTokens((e.objectives||"")+(e.completed||"")+(e.issues||"")+(e.notes||"")),r=C(),n=this.resilience.withRetrySync(()=>this.db.prepare(`
296
+ `).all(pattern, pattern, pattern);
297
+ }
298
+ // ==================== SESSIONS ====================
299
+ createSession(projectId, data) {
300
+ const tokensEstimate = this.estimateTokens((data.objectives || '') + (data.completed || '') + (data.issues || '') + (data.notes || ''));
301
+ // v7 F2 (T009): stamp the writing actor (NULL outside a fleet context).
302
+ const ambient = getActor();
303
+ const result = this.resilience.withRetrySync(() => {
304
+ const stmt = this.db.prepare(`
27
305
  INSERT INTO sessions (project_id, date, objectives, completed, issues, commits, files_changed, notes, tokens_estimate, agent_id, run_id)
28
306
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
29
307
  RETURNING *
30
- `).get(t,e.date||new Date().toISOString().split("T")[0],e.objectives||"",e.completed||"",e.issues||"",e.commits||"",e.files_changed||"",e.notes||"",s,r.agent_id,r.run_id),"createSession");if(!n.success)throw n.error||new Error("Failed to create session");return m(this.db,{projectId:t,kind:"session_update",refTable:"sessions",refId:n.data.id}),n.data}updateSession(t,e){const s=[],r=[],n=new Set(["objectives","completed","issues","commits","files_changed","notes","summary","is_archived"]);for(const[a,o]of Object.entries(e))n.has(a)&&(s.push(`${a} = ?`),r.push(o));if(s.length===0)return this.getSession(t);if(e.objectives||e.completed||e.issues||e.notes){const a=this.getSession(t);if(a){const o=this.estimateTokens((e.objectives||a.objectives)+(e.completed||a.completed)+(e.issues||a.issues)+(e.notes||a.notes));s.push("tokens_estimate = ?"),r.push(o)}}r.push(t);const i=this.resilience.withRetrySync(()=>this.db.prepare(`
31
- UPDATE sessions SET ${s.join(", ")} WHERE id = ? RETURNING *
32
- `).get(...r),"updateSession");if(!i.success)throw i.error||new Error("Failed to update session");return m(this.db,{projectId:i.data.project_id,kind:"session_update",refTable:"sessions",refId:t}),i.data}getSession(t){return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(t)}updateContinuity(t,e,s){const r=e.trim();if(!r)return;const n=12e3,i=this.db.prepare("SELECT thread, turn_count FROM project_continuity WHERE project_id = ?").get(t),a=new Date().toISOString(),o=`[${a}${s?" \xB7 "+s:""}] ${r}`;let c=i?.thread?i.thread+`
33
-
34
- `+o:o;c.length>n&&(c=`[\u2026earlier continuity trimmed \u2014 gist retained below\u2026]
35
-
36
- `+c.slice(c.length-n));const l=(i?.turn_count??0)+1;this.resilience.withRetrySync(()=>(this.db.prepare(`
308
+ `);
309
+ return stmt.get(projectId, data.date || new Date().toISOString().split('T')[0], data.objectives || '', data.completed || '', data.issues || '', data.commits || '', data.files_changed || '', data.notes || '', tokensEstimate, ambient.agent_id, ambient.run_id);
310
+ }, 'createSession');
311
+ if (!result.success) {
312
+ throw result.error || new Error('Failed to create session');
313
+ }
314
+ // Live Memory v6.4: failure-isolated event emit (never throws, never rolls back).
315
+ emitEvent(this.db, { projectId, kind: 'session_update', refTable: 'sessions', refId: result.data.id });
316
+ return result.data;
317
+ }
318
+ updateSession(id, data) {
319
+ const updates = [];
320
+ const values = [];
321
+ // Allowlist column names to prevent SQL injection via crafted key names
322
+ const ALLOWED_COLUMNS = new Set(['objectives', 'completed', 'issues', 'commits',
323
+ 'files_changed', 'notes', 'summary', 'is_archived']);
324
+ for (const [key, value] of Object.entries(data)) {
325
+ if (ALLOWED_COLUMNS.has(key)) {
326
+ updates.push(`${key} = ?`);
327
+ values.push(value);
328
+ }
329
+ }
330
+ if (updates.length === 0)
331
+ return this.getSession(id);
332
+ // Recalculate tokens if content changed
333
+ if (data.objectives || data.completed || data.issues || data.notes) {
334
+ const session = this.getSession(id);
335
+ if (session) {
336
+ const newTokens = this.estimateTokens((data.objectives || session.objectives) +
337
+ (data.completed || session.completed) +
338
+ (data.issues || session.issues) +
339
+ (data.notes || session.notes));
340
+ updates.push('tokens_estimate = ?');
341
+ values.push(newTokens);
342
+ }
343
+ }
344
+ values.push(id);
345
+ const result = this.resilience.withRetrySync(() => {
346
+ const stmt = this.db.prepare(`
347
+ UPDATE sessions SET ${updates.join(', ')} WHERE id = ? RETURNING *
348
+ `);
349
+ return stmt.get(...values);
350
+ }, 'updateSession');
351
+ if (!result.success) {
352
+ throw result.error || new Error('Failed to update session');
353
+ }
354
+ emitEvent(this.db, { projectId: result.data.project_id, kind: 'session_update', refTable: 'sessions', refId: id });
355
+ return result.data;
356
+ }
357
+ getSession(id) {
358
+ return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
359
+ }
360
+ // ── Continuity Layer (migration 27): the rolling per-project living thread ──
361
+ /**
362
+ * Append [note] to the project's continuity thread — the running narrative of what we're working
363
+ * on / talking / thinking about. Bounded (oldest trimmed with a marker) so it never grows
364
+ * unbounded yet never loses the gist. This is what lets a fresh chat — or a different surface
365
+ * (phone, Hermes, Ember, Gemini) — re-open already knowing where we left off.
366
+ */
367
+ updateContinuity(projectId, note, by) {
368
+ const trimmed = note.trim();
369
+ if (!trimmed)
370
+ return;
371
+ const CAP = 12000;
372
+ const row = this.db
373
+ .prepare('SELECT thread, turn_count FROM project_continuity WHERE project_id = ?')
374
+ .get(projectId);
375
+ const stamp = new Date().toISOString();
376
+ const entry = `[${stamp}${by ? ' · ' + by : ''}] ${trimmed}`;
377
+ let thread = row?.thread ? row.thread + '\n\n' + entry : entry;
378
+ if (thread.length > CAP) {
379
+ thread = '[…earlier continuity trimmed — gist retained below…]\n\n' + thread.slice(thread.length - CAP);
380
+ }
381
+ const turns = (row?.turn_count ?? 0) + 1;
382
+ this.resilience.withRetrySync(() => {
383
+ this.db
384
+ .prepare(`
37
385
  INSERT INTO project_continuity (project_id, thread, updated_at, updated_by, turn_count)
38
386
  VALUES (?, ?, ?, ?, ?)
39
387
  ON CONFLICT(project_id) DO UPDATE SET
40
388
  thread = excluded.thread, updated_at = excluded.updated_at,
41
389
  updated_by = excluded.updated_by, turn_count = excluded.turn_count
42
- `).run(t,c,a,s??null,l),!0),"updateContinuity")}getContinuity(t){return this.db.prepare("SELECT thread, updated_at, updated_by, turn_count FROM project_continuity WHERE project_id = ?").get(t)??null}recordRouting(t){const e=t.projectId==null?-1:t.projectId,s=(t.taskType??"").trim(),r=(t.modelId??"").trim();if(!s)throw new Error("recordRouting: taskType is required");if(!r)throw new Error("recordRouting: modelId is required");let n=null;if(Array.isArray(t.toolNames)){const o=[...new Set(t.toolNames.map(c=>String(c).trim()).filter(Boolean))];n=o.length?o.join(","):null}else if(typeof t.toolNames=="string"){const o=[...new Set(t.toolNames.split(",").map(c=>c.trim()).filter(Boolean))];n=o.length?o.join(","):null}const i=t.latencyMs==null||!Number.isFinite(t.latencyMs)?null:Math.max(0,Math.round(t.latencyMs));let a=-1;return this.resilience.withRetrySync(()=>{const o=this.db.prepare(`
390
+ `)
391
+ .run(projectId, thread, stamp, by ?? null, turns);
392
+ return true;
393
+ }, 'updateContinuity');
394
+ }
395
+ /** The project's current continuity thread (the living "where we left off"), or null if none yet. */
396
+ getContinuity(projectId) {
397
+ const row = this.db
398
+ .prepare('SELECT thread, updated_at, updated_by, turn_count FROM project_continuity WHERE project_id = ?')
399
+ .get(projectId);
400
+ return row ?? null;
401
+ }
402
+ // ==================== ROUTING MEMORY (migration 28) ====================
403
+ //
404
+ // The durable, queryable home for the dragon-cli multi-model router's
405
+ // decision+outcome log. recordRouting() appends one row per delegated run;
406
+ // recallRouting() aggregates per (model, tool-set) candidate so the router
407
+ // can steer toward proven winners and away from past failures (negative
408
+ // learning) for a given task_type.
409
+ /** Append one routing decision+outcome. Append-only (an event log); never
410
+ * updates a prior row. project_id defaults to -1 (global/unscoped). */
411
+ recordRouting(rec) {
412
+ const projectId = rec.projectId == null ? -1 : rec.projectId;
413
+ const taskType = (rec.taskType ?? '').trim();
414
+ const modelId = (rec.modelId ?? '').trim();
415
+ if (!taskType)
416
+ throw new Error('recordRouting: taskType is required');
417
+ if (!modelId)
418
+ throw new Error('recordRouting: modelId is required');
419
+ // Normalize tool_names to a comma-joined string (or null). Accepts an
420
+ // array (preferred) or a pre-joined string; blanks/dupes are dropped so
421
+ // the recall GROUP BY treats the same tool-set as one candidate.
422
+ let toolNames = null;
423
+ if (Array.isArray(rec.toolNames)) {
424
+ const cleaned = [...new Set(rec.toolNames.map((t) => String(t).trim()).filter(Boolean))];
425
+ toolNames = cleaned.length ? cleaned.join(',') : null;
426
+ }
427
+ else if (typeof rec.toolNames === 'string') {
428
+ const cleaned = [...new Set(rec.toolNames.split(',').map((t) => t.trim()).filter(Boolean))];
429
+ toolNames = cleaned.length ? cleaned.join(',') : null;
430
+ }
431
+ const latencyMs = rec.latencyMs == null || !Number.isFinite(rec.latencyMs)
432
+ ? null
433
+ : Math.max(0, Math.round(rec.latencyMs));
434
+ let id = -1;
435
+ this.resilience.withRetrySync(() => {
436
+ const info = this.db
437
+ .prepare(`
43
438
  INSERT INTO routing_memory (project_id, task_type, model_id, tool_names, success, latency_ms)
44
439
  VALUES (?, ?, ?, ?, ?, ?)
45
- `).run(e,s,r,n,t.success?1:0,i);return a=Number(o.lastInsertRowid),!0},"recordRouting"),a}recallRouting(t,e={}){const s=(t??"").trim();if(!s)return[];const r=Math.min(100,Math.max(1,e.limit??10)),n=["task_type = ?"],i=[s];e.projectId!=null&&(n.push("(project_id = ? OR project_id = -1)"),i.push(e.projectId)),e.sinceDays!=null&&Number.isFinite(e.sinceDays)&&e.sinceDays>0&&(n.push("ts >= datetime('now', ?)"),i.push(`-${Math.round(e.sinceDays)} days`)),i.push(r);try{return this.db.prepare(`
440
+ `)
441
+ .run(projectId, taskType, modelId, toolNames, rec.success ? 1 : 0, latencyMs);
442
+ id = Number(info.lastInsertRowid);
443
+ return true;
444
+ }, 'recordRouting');
445
+ return id;
446
+ }
447
+ /**
448
+ * Recall the routing candidates for a task_type, aggregated per
449
+ * (model_id, tool-set) and ranked for negative learning: highest success
450
+ * rate first, then most runs (more evidence), then most recent. Scoped to
451
+ * `projectId` when given (and ALWAYS includes global/-1 rows so a router
452
+ * that never registered a project still benefits); global across all
453
+ * projects when omitted.
454
+ *
455
+ * @param taskType the router's task classification (the recall key)
456
+ * @param opts.projectId scope to this project (+ global -1 rows); omit for global
457
+ * @param opts.limit max candidates returned (default 10, cap 100)
458
+ * @param opts.sinceDays only consider runs within this recency window (optional)
459
+ */
460
+ recallRouting(taskType, opts = {}) {
461
+ const tt = (taskType ?? '').trim();
462
+ if (!tt)
463
+ return [];
464
+ const limit = Math.min(100, Math.max(1, opts.limit ?? 10));
465
+ const where = ['task_type = ?'];
466
+ const params = [tt];
467
+ if (opts.projectId != null) {
468
+ // Always fold in global/-1 rows alongside the project's own.
469
+ where.push('(project_id = ? OR project_id = -1)');
470
+ params.push(opts.projectId);
471
+ }
472
+ if (opts.sinceDays != null && Number.isFinite(opts.sinceDays) && opts.sinceDays > 0) {
473
+ where.push(`ts >= datetime('now', ?)`);
474
+ params.push(`-${Math.round(opts.sinceDays)} days`);
475
+ }
476
+ params.push(limit);
477
+ try {
478
+ const rows = this.db
479
+ .prepare(`
46
480
  SELECT
47
481
  model_id,
48
482
  COALESCE(tool_names, '') AS tool_names,
@@ -52,30 +486,69 @@ import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync
52
486
  AVG(CASE WHEN latency_ms IS NOT NULL THEN latency_ms END) AS avg_latency_ms,
53
487
  MAX(ts) AS last_ts
54
488
  FROM routing_memory
55
- WHERE ${n.join(" AND ")}
489
+ WHERE ${where.join(' AND ')}
56
490
  GROUP BY model_id, COALESCE(tool_names, '')
57
491
  ORDER BY (CAST(SUM(success) AS REAL) / COUNT(*)) DESC, runs DESC, last_ts DESC
58
492
  LIMIT ?
59
- `).all(...i).map(o=>({model_id:o.model_id,tool_names:o.tool_names,runs:o.runs,successes:o.successes,failures:o.failures,success_rate:o.runs>0?o.successes/o.runs:0,avg_latency_ms:o.avg_latency_ms==null?null:Math.round(o.avg_latency_ms),last_ts:o.last_ts}))}catch{return[]}}getRecentSessions(t,e=5){return this.db.prepare(`
493
+ `)
494
+ .all(...params);
495
+ return rows.map((r) => ({
496
+ model_id: r.model_id,
497
+ tool_names: r.tool_names,
498
+ runs: r.runs,
499
+ successes: r.successes,
500
+ failures: r.failures,
501
+ success_rate: r.runs > 0 ? r.successes / r.runs : 0,
502
+ avg_latency_ms: r.avg_latency_ms == null ? null : Math.round(r.avg_latency_ms),
503
+ last_ts: r.last_ts,
504
+ }));
505
+ }
506
+ catch {
507
+ return [];
508
+ }
509
+ }
510
+ getRecentSessions(projectId, limit = 5) {
511
+ return this.db.prepare(`
60
512
  SELECT * FROM sessions
61
513
  WHERE project_id = ? AND is_archived = 0
62
514
  ORDER BY date DESC, id DESC
63
515
  LIMIT ?
64
- `).all(t,e)}getTodaySession(t){const e=new Date().toISOString().split("T")[0];return this.db.prepare(`
516
+ `).all(projectId, limit);
517
+ }
518
+ getTodaySession(projectId) {
519
+ const today = new Date().toISOString().split('T')[0];
520
+ return this.db.prepare(`
65
521
  SELECT * FROM sessions WHERE project_id = ? AND date = ?
66
- `).get(t,e)}searchSessions(t,e){const s=h(t);if(!s)return[];try{return e?this.db.prepare(`
522
+ `).get(projectId, today);
523
+ }
524
+ searchSessions(query, projectId) {
525
+ const match = buildFtsMatchQuery(query);
526
+ if (!match)
527
+ return [];
528
+ try {
529
+ if (projectId) {
530
+ return this.db.prepare(`
67
531
  SELECT s.* FROM sessions s
68
532
  JOIN sessions_fts fts ON s.id = fts.rowid
69
533
  WHERE sessions_fts MATCH ? AND s.project_id = ?
70
534
  ORDER BY bm25(sessions_fts), s.date DESC
71
535
  LIMIT 50
72
- `).all(s,e):this.db.prepare(`
536
+ `).all(match, projectId);
537
+ }
538
+ return this.db.prepare(`
73
539
  SELECT s.* FROM sessions s
74
540
  JOIN sessions_fts fts ON s.id = fts.rowid
75
541
  WHERE sessions_fts MATCH ?
76
542
  ORDER BY bm25(sessions_fts), s.date DESC
77
543
  LIMIT 50
78
- `).all(s)}catch{return[]}}archiveOldSessions(t,e=10){return this.db.prepare(`
544
+ `).all(match);
545
+ }
546
+ catch {
547
+ return [];
548
+ }
549
+ }
550
+ archiveOldSessions(projectId, keepRecent = 10) {
551
+ const result = this.db.prepare(`
79
552
  UPDATE sessions
80
553
  SET is_archived = 1
81
554
  WHERE project_id = ?
@@ -86,16 +559,39 @@ import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync
86
559
  ORDER BY date DESC, id DESC
87
560
  LIMIT ?
88
561
  )
89
- `).run(t,t,e).changes}getSessionTokenUsage(t){return this.db.prepare(`
562
+ `).run(projectId, projectId, keepRecent);
563
+ return result.changes;
564
+ }
565
+ getSessionTokenUsage(projectId) {
566
+ const result = this.db.prepare(`
90
567
  SELECT COALESCE(SUM(tokens_estimate), 0) as total
91
568
  FROM sessions WHERE project_id = ? AND is_archived = 0
92
- `).get(t).total}addQuest(t,e,s,r="medium",n){const i=C(),a=this.db.prepare(`
569
+ `).get(projectId);
570
+ return result.total;
571
+ }
572
+ // ==================== QUESTS ====================
573
+ addQuest(projectId, title, description, priority = 'medium', tags) {
574
+ // v7 F2 (T009): stamp the writing actor (NULL outside a fleet context).
575
+ const ambient = getActor();
576
+ const quest = this.db.prepare(`
93
577
  INSERT INTO quests (project_id, title, description, priority, tags, agent_id, run_id)
94
578
  VALUES (?, ?, ?, ?, ?, ?, ?)
95
579
  RETURNING *
96
- `).get(t,e,s||"",r,n||null,i.agent_id,i.run_id);return m(this.db,{projectId:t,kind:"quest",refTable:"quests",refId:a.id}),a}updateQuest(t,e){const s=e==="completed"?new Date().toISOString():null,r=this.db.prepare(`
580
+ `).get(projectId, title, description || '', priority, tags || null, ambient.agent_id, ambient.run_id);
581
+ emitEvent(this.db, { projectId, kind: 'quest', refTable: 'quests', refId: quest.id });
582
+ return quest;
583
+ }
584
+ updateQuest(id, status) {
585
+ const completedAt = status === 'completed' ? new Date().toISOString() : null;
586
+ const quest = this.db.prepare(`
97
587
  UPDATE quests SET status = ?, completed_at = ? WHERE id = ? RETURNING *
98
- `).get(e,s,t);return r&&m(this.db,{projectId:r.project_id,kind:"quest",refTable:"quests",refId:t}),r}getPendingQuests(t){return this.db.prepare(`
588
+ `).get(status, completedAt, id);
589
+ if (quest)
590
+ emitEvent(this.db, { projectId: quest.project_id, kind: 'quest', refTable: 'quests', refId: id });
591
+ return quest;
592
+ }
593
+ getPendingQuests(projectId) {
594
+ return this.db.prepare(`
99
595
  SELECT * FROM quests
100
596
  WHERE project_id = ? AND status IN ('pending', 'in_progress')
101
597
  ORDER BY
@@ -106,7 +602,10 @@ import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync
106
602
  WHEN 'low' THEN 4
107
603
  END,
108
604
  created_at ASC
109
- `).all(t)}getAllPendingQuests(){return this.db.prepare(`
605
+ `).all(projectId);
606
+ }
607
+ getAllPendingQuests() {
608
+ return this.db.prepare(`
110
609
  SELECT q.*, p.name as project_name FROM quests q
111
610
  JOIN projects p ON q.project_id = p.id
112
611
  WHERE q.status IN ('pending', 'in_progress')
@@ -118,36 +617,136 @@ import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync
118
617
  WHEN 'low' THEN 4
119
618
  END,
120
619
  q.created_at ASC
121
- `).all()}searchQuests(t){const e=h(t);if(!e)return[];try{return this.db.prepare(`
620
+ `).all();
621
+ }
622
+ searchQuests(query) {
623
+ const match = buildFtsMatchQuery(query);
624
+ if (!match)
625
+ return [];
626
+ try {
627
+ return this.db.prepare(`
122
628
  SELECT q.* FROM quests q
123
629
  JOIN quests_fts fts ON q.id = fts.rowid
124
630
  WHERE quests_fts MATCH ?
125
631
  ORDER BY bm25(quests_fts), q.created_at DESC
126
632
  LIMIT 50
127
- `).all(e)}catch{return[]}}getRecentlyCompleted(t,e=5){return this.db.prepare(`
633
+ `).all(match);
634
+ }
635
+ catch {
636
+ return [];
637
+ } // defense-in-depth: a malformed MATCH yields [], never throws
638
+ }
639
+ getRecentlyCompleted(projectId, limit = 5) {
640
+ return this.db.prepare(`
128
641
  SELECT * FROM quests
129
642
  WHERE project_id = ? AND status = 'completed'
130
643
  ORDER BY completed_at DESC
131
644
  LIMIT ?
132
- `).all(t,e)}setContext(t,e,s){this.db.prepare(`
645
+ `).all(projectId, limit);
646
+ }
647
+ // ==================== CONTEXT ====================
648
+ setContext(projectId, key, value) {
649
+ this.db.prepare(`
133
650
  INSERT INTO context (project_id, key, value)
134
651
  VALUES (?, ?, ?)
135
652
  ON CONFLICT(project_id, key) DO UPDATE SET
136
653
  value = excluded.value,
137
654
  updated_at = datetime('now')
138
- `).run(t,e,s)}getContext(t,e){return this.db.prepare(`
655
+ `).run(projectId, key, value);
656
+ }
657
+ getContext(projectId, key) {
658
+ const row = this.db.prepare(`
139
659
  SELECT value FROM context WHERE project_id = ? AND key = ?
140
- `).get(t,e)?.value}getAllContext(t){const e=this.db.prepare(`
660
+ `).get(projectId, key);
661
+ return row?.value;
662
+ }
663
+ getAllContext(projectId) {
664
+ const rows = this.db.prepare(`
141
665
  SELECT key, value FROM context WHERE project_id = ?
142
- `).all(t),s={};for(const r of e)s[r.key]=r.value;return s}liveMemoryEnabled(){return x()}eventsSince(t,e=0,s=100){return W(this.db,t,e,s)}subscribeEvents(t,e=20){return U(this.db,t,e)}publishEvent(t){m(this.db,t)}pruneEvents(t={}){return q(this.db,t)}ingestRemoteEvent(t,e){return P(this.db,t,e)}eventsForPush(t,e=0,s=200){return B(this.db,t,e,s)}getMeta(t){return Y(this.db,t)}setMeta(t,e){$(this.db,t,e)}setGlobalContext(t,e){this.db.prepare(`
666
+ `).all(projectId);
667
+ const result = {};
668
+ for (const row of rows) {
669
+ result[row.key] = row.value;
670
+ }
671
+ return result;
672
+ }
673
+ // ==================== LIVE MEMORY (v6.4) ====================
674
+ /** Is the Live Memory event log active? (WYRM_LIVE_MEMORY, default ON.) */
675
+ liveMemoryEnabled() {
676
+ return isLiveMemoryEnabled();
677
+ }
678
+ /** Pull events newer than a cursor for a project (one-shot, idempotent). */
679
+ eventsSince(projectId, sinceCursor = 0, limit = 100) {
680
+ return eventsSince(this.db, projectId, sinceCursor, limit);
681
+ }
682
+ /** Initial subscribe: current head cursor + a recent chronological window. */
683
+ subscribeEvents(projectId, window = 20) {
684
+ return subscribeEvents(this.db, projectId, window);
685
+ }
686
+ /** Manually publish an event (e.g. a tool_call marker). Failure-isolated. */
687
+ publishEvent(input) {
688
+ emitEvent(this.db, input);
689
+ }
690
+ /** Retention sweep for the Live Memory event log. Bounded + failure-isolated. */
691
+ pruneEvents(opts = {}) {
692
+ return pruneEvents(this.db, opts);
693
+ }
694
+ // Phase 3 — cross-device replication primitives.
695
+ /** Ingest a peer's event (PULL). Echo-suppressed + idempotent. */
696
+ ingestRemoteEvent(localProjectId, ev) {
697
+ return ingestRemoteEvent(this.db, localProjectId, ev);
698
+ }
699
+ /** Shared events past a watermark, for PUSH up to a peer (privacy-gated). */
700
+ eventsForPush(projectId, sinceCursor = 0, limit = 200) {
701
+ return eventsForPush(this.db, projectId, sinceCursor, limit);
702
+ }
703
+ /** Persistent KV in wyrm_meta — replication watermarks live here. */
704
+ getMeta(key) { return getMeta(this.db, key); }
705
+ setMeta(key, value) { setMeta(this.db, key, value); }
706
+ // ==================== GLOBAL CONTEXT ====================
707
+ setGlobalContext(key, value) {
708
+ this.db.prepare(`
143
709
  INSERT INTO global_context (key, value)
144
710
  VALUES (?, ?)
145
711
  ON CONFLICT(key) DO UPDATE SET
146
712
  value = excluded.value,
147
713
  updated_at = datetime('now')
148
- `).run(t,e)}getGlobalContext(t){return this.db.prepare(`
714
+ `).run(key, value);
715
+ }
716
+ getGlobalContext(key) {
717
+ const row = this.db.prepare(`
149
718
  SELECT value FROM global_context WHERE key = ?
150
- `).get(t)?.value}getAllGlobalContext(){const t=this.db.prepare("SELECT key, value FROM global_context").all(),e={};for(const s of t)e[s.key]=s.value;return e}registerSkill(t,e,s,r,n,i,a,o){const c=o?.tier??"atomic",l=JSON.stringify(Array.isArray(o?.governs)?o.governs:[]),p=JSON.stringify(Array.isArray(o?.composes)?o.composes:[]),_=y({skillPath:s,name:t}),g=_?.content??null,S=_?.sha256??null,f=_?new Date().toISOString():null,E=this.resilience.withRetrySync(()=>this.db.prepare(`
719
+ `).get(key);
720
+ return row?.value;
721
+ }
722
+ getAllGlobalContext() {
723
+ const rows = this.db.prepare('SELECT key, value FROM global_context').all();
724
+ const result = {};
725
+ for (const row of rows) {
726
+ result[row.key] = row.value;
727
+ }
728
+ return result;
729
+ }
730
+ // ==================== SKILLS MANAGEMENT ====================
731
+ registerSkill(name, description, skillPath, category, author, version, tags, governance) {
732
+ // Normalize governance to safe defaults so existing callers (no 8th arg)
733
+ // keep producing 'atomic' / '[]' / '[]' — fully backward-compatible.
734
+ const tier = governance?.tier ?? 'atomic';
735
+ const governsJson = JSON.stringify(Array.isArray(governance?.governs) ? governance.governs : []);
736
+ const composesJson = JSON.stringify(Array.isArray(governance?.composes) ? governance.composes : []);
737
+ // Migration 25: capture the SKILL.md body so the skill is portable across
738
+ // machines. Best-effort — an unreadable/missing file stores NULL and never
739
+ // blocks registration. On re-register we only OVERWRITE stored content when
740
+ // the file is freshly readable (excluded.content IS NOT NULL); otherwise we
741
+ // KEEP whatever was backfilled so a transient bad path can't wipe it.
742
+ // `cross_project_visibility`/`is_shared` are deliberately NOT touched here:
743
+ // they keep their private-by-default value across re-registrations (the
744
+ // egress gate must never silently flip on a routine re-register).
745
+ const captured = readSkillContent({ skillPath, name });
746
+ const content = captured?.content ?? null;
747
+ const contentSha = captured?.sha256 ?? null;
748
+ const contentUpdatedAt = captured ? new Date().toISOString() : null;
749
+ const result = this.resilience.withRetrySync(() => this.db.prepare(`
151
750
  INSERT INTO skills (name, description, skill_path, category, author, version, tags, tier, governs, composes, content, content_sha256, content_updated_at, is_active, usage_count)
152
751
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)
153
752
  ON CONFLICT(name) DO UPDATE SET
@@ -169,17 +768,299 @@ import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync
169
768
  updated_at = datetime('now'),
170
769
  is_active = 1
171
770
  RETURNING *
172
- `).get(t,e,s,r||null,n||null,i||null,a||null,c,l,p,g,S,f),"registerSkill");if(!E.success)throw E.error||new Error("Failed to register skill");return E.data}getSkill(t){const e=this.db.prepare("SELECT * FROM skills WHERE name = ?").get(t);return e&&this.db.prepare("UPDATE skills SET last_used = datetime('now'), usage_count = usage_count + 1 WHERE id = ?").run(e.id),e}backfillSkillContent(t={}){const e=t.read??y,s=this.db.prepare("SELECT id, name, skill_path, content_sha256 FROM skills").all();let r=0,n=0,i=0;const a=this.db.prepare("UPDATE skills SET content = ?, content_sha256 = ?, content_updated_at = ?, updated_at = datetime('now') WHERE id = ?");return this.db.transaction(()=>{for(const c of s){const l=e({skillPath:c.skill_path,name:c.name,skillsDir:t.skillsDir});if(!l){i++;continue}if(c.content_sha256&&c.content_sha256===l.sha256){n++;continue}a.run(l.content,l.sha256,new Date().toISOString(),c.id),r++}})(),{total:s.length,filled:r,unchanged:n,missing:i}}exportSkillContent(t,e={}){const s=e.includeInactive?"":" WHERE is_active = 1",r=this.db.prepare(`SELECT name, content FROM skills${s} ORDER BY name`).all();let n=0,i=0,a=0;const o=new Set;for(const c of r){if(c.content==null){i++;continue}let l=G(c.name);if(!l){i++;continue}o.has(l)&&(l=`${l}-${V(c.name).slice(0,8)}`,a++),o.add(l);const p=d(t,l);b(p,{recursive:!0}),I(d(p,"SKILL.md"),c.content,"utf-8"),n++}return{total:r.length,written:n,skipped_no_content:i,collisions:a}}setSkillVisibility(t,e){return this.db.prepare("UPDATE skills SET cross_project_visibility = ?, updated_at = datetime('now') WHERE name = ? RETURNING *").get(e,t)}setAllSkillsVisibility(t,e={}){const s=[],r=[t];e.includeInactive||s.push("is_active = 1"),e.tier&&(s.push("tier = ?"),r.push(e.tier));const n=s.length?" WHERE "+s.join(" AND "):"";return this.db.prepare(`UPDATE skills SET cross_project_visibility = ?, updated_at = datetime('now')${n}`).run(...r).changes}listSkills(t,e,s,r){let n="SELECT * FROM skills WHERE 1=1";const i=[];if(t!==void 0&&(n+=" AND is_active = ?",i.push(t?1:0)),e&&(n+=" AND category = ?",i.push(e)),r&&(n+=" AND tier = ?",i.push(r)),s){const a=h(s);a&&(n+=" AND id IN (SELECT rowid FROM skills_fts WHERE skills_fts MATCH ?)",i.push(a))}return n+=" ORDER BY updated_at DESC",this.db.prepare(n).all(...i)}searchSkills(t,e=20,s){const r=h(t);if(!r)return[];const n=s?" AND s.tier = ?":"",i=this.db.prepare(`
771
+ `).get(name, description, skillPath, category || null, author || null, version || null, tags || null, tier, governsJson, composesJson, content, contentSha, contentUpdatedAt), 'registerSkill');
772
+ if (!result.success) {
773
+ throw result.error || new Error('Failed to register skill');
774
+ }
775
+ return result.data;
776
+ }
777
+ getSkill(name) {
778
+ const skill = this.db.prepare('SELECT * FROM skills WHERE name = ?').get(name);
779
+ if (skill) {
780
+ // Update last_used
781
+ this.db.prepare('UPDATE skills SET last_used = datetime(\'now\'), usage_count = usage_count + 1 WHERE id = ?').run(skill.id);
782
+ }
783
+ return skill;
784
+ }
785
+ /**
786
+ * Backfill SKILL.md content into the registry (migration 25). Scans every
787
+ * registered skill, reads its SKILL.md (skill_path, fallback
788
+ * <skillsDir>/<slug>/SKILL.md) and stores content + sha. IDEMPOTENT: a skill
789
+ * whose on-disk sha already matches the stored sha is skipped (unchanged).
790
+ * Never throws on an unreadable file — that skill is counted `missing`.
791
+ *
792
+ * `read` is injected (defaults to readSkillContent) so tests can sandbox the
793
+ * filesystem; it returns {content, sha256} or null.
794
+ */
795
+ backfillSkillContent(opts = {}) {
796
+ const read = opts.read ?? readSkillContent;
797
+ const skills = this.db.prepare('SELECT id, name, skill_path, content_sha256 FROM skills')
798
+ .all();
799
+ let filled = 0, unchanged = 0, missing = 0;
800
+ const update = this.db.prepare(`UPDATE skills SET content = ?, content_sha256 = ?, content_updated_at = ?, updated_at = datetime('now') WHERE id = ?`);
801
+ const tx = this.db.transaction(() => {
802
+ for (const s of skills) {
803
+ const got = read({ skillPath: s.skill_path, name: s.name, skillsDir: opts.skillsDir });
804
+ if (!got) {
805
+ missing++;
806
+ continue;
807
+ }
808
+ if (s.content_sha256 && s.content_sha256 === got.sha256) {
809
+ unchanged++;
810
+ continue;
811
+ }
812
+ update.run(got.content, got.sha256, new Date().toISOString(), s.id);
813
+ filled++;
814
+ }
815
+ });
816
+ tx();
817
+ return { total: skills.length, filled, unchanged, missing };
818
+ }
819
+ /**
820
+ * Materialize skills from the registry to disk (migration 25): write
821
+ * <targetDir>/<slug>/SKILL.md from stored `content` for every skill that has
822
+ * content. This is how a fresh machine reconstitutes the actual skill files
823
+ * after pulling the synced DB. Round-trips byte-faithfully (content stored
824
+ * verbatim, written verbatim). Skills with NULL content are skipped (counted
825
+ * `skipped_no_content`). `includeInactive=false` exports only active skills.
826
+ */
827
+ exportSkillContent(targetDir, opts = {}) {
828
+ const where = opts.includeInactive ? '' : ' WHERE is_active = 1';
829
+ // ORDER BY name = deterministic export (which row keeps the bare slug on a
830
+ // collision no longer depends on rowid/insertion order).
831
+ const skills = this.db.prepare(`SELECT name, content FROM skills${where} ORDER BY name`)
832
+ .all();
833
+ let written = 0, skipped = 0, collisions = 0;
834
+ const usedSlugs = new Set();
835
+ for (const s of skills) {
836
+ if (s.content == null) {
837
+ skipped++;
838
+ continue;
839
+ }
840
+ let slug = slugifySkillName(s.name);
841
+ if (!slug) {
842
+ skipped++;
843
+ continue;
844
+ }
845
+ // skills.name is UNIQUE but slugs are NOT (case/punctuation folding + the
846
+ // 48-char cap collapse distinct names). Disambiguate deterministically so a
847
+ // later skill never silently clobbers an earlier one's SKILL.md — the
848
+ // authoring path (deploySkill) refuses clobber, and export must not lose data.
849
+ if (usedSlugs.has(slug)) {
850
+ slug = `${slug}-${skillContentSha(s.name).slice(0, 8)}`;
851
+ collisions++;
852
+ }
853
+ usedSlugs.add(slug);
854
+ const dir = join(targetDir, slug);
855
+ mkdirSync(dir, { recursive: true });
856
+ writeFileSync(join(dir, 'SKILL.md'), s.content, 'utf-8');
857
+ written++;
858
+ }
859
+ return { total: skills.length, written, skipped_no_content: skipped, collisions };
860
+ }
861
+ /**
862
+ * Promote (or re-privatize) a skill's cloud-sync visibility — the egress
863
+ * gate from migration 25. Default-private skills only leave the machine once
864
+ * promoted to 'org'/'public'. Returns the updated row, or undefined if the
865
+ * skill does not exist.
866
+ */
867
+ setSkillVisibility(name, visibility) {
868
+ const row = this.db.prepare(`UPDATE skills SET cross_project_visibility = ?, updated_at = datetime('now') WHERE name = ? RETURNING *`).get(visibility, name);
869
+ return row;
870
+ }
871
+ /**
872
+ * Bulk visibility set — the egress lever for promoting many skills at once
873
+ * (e.g. share the whole library to a teammate / another machine). Optional
874
+ * `tier` filter; active-only unless `includeInactive`. Returns rows changed.
875
+ * `tier` is bound (never interpolated). Egress still gated downstream by the
876
+ * per-row visibility the sync engine reads — this is just how the operator
877
+ * flips many rows in one command instead of one `share <name>` at a time.
878
+ */
879
+ setAllSkillsVisibility(visibility, opts = {}) {
880
+ const conds = [];
881
+ const params = [visibility];
882
+ if (!opts.includeInactive)
883
+ conds.push('is_active = 1');
884
+ if (opts.tier) {
885
+ conds.push('tier = ?');
886
+ params.push(opts.tier);
887
+ }
888
+ const where = conds.length ? ' WHERE ' + conds.join(' AND ') : '';
889
+ const res = this.db.prepare(`UPDATE skills SET cross_project_visibility = ?, updated_at = datetime('now')${where}`).run(...params);
890
+ return res.changes;
891
+ }
892
+ listSkills(active, category, search, tier) {
893
+ let query = 'SELECT * FROM skills WHERE 1=1';
894
+ const params = [];
895
+ if (active !== undefined) {
896
+ query += ' AND is_active = ?';
897
+ params.push(active ? 1 : 0);
898
+ }
899
+ if (category) {
900
+ query += ' AND category = ?';
901
+ params.push(category);
902
+ }
903
+ if (tier) {
904
+ query += ' AND tier = ?';
905
+ params.push(tier);
906
+ }
907
+ if (search) {
908
+ const match = buildFtsMatchQuery(search);
909
+ if (match) {
910
+ query += ' AND id IN (SELECT rowid FROM skills_fts WHERE skills_fts MATCH ?)';
911
+ params.push(match);
912
+ }
913
+ }
914
+ query += ' ORDER BY updated_at DESC';
915
+ return this.db.prepare(query).all(...params);
916
+ }
917
+ searchSkills(query, limit = 20, tier) {
918
+ const match = buildFtsMatchQuery(query);
919
+ if (!match)
920
+ return [];
921
+ // Optional tier filter is applied in SQL so the FTS rank ordering is preserved.
922
+ const tierClause = tier ? ' AND s.tier = ?' : '';
923
+ const stmt = this.db.prepare(`
173
924
  SELECT s.* FROM skills s
174
925
  JOIN skills_fts ON s.id = skills_fts.rowid
175
- WHERE skills_fts MATCH ?${n}
926
+ WHERE skills_fts MATCH ?${tierClause}
176
927
  ORDER BY rank
177
928
  LIMIT ?
178
- `);return s?i.all(r,s,e):i.all(r,e)}getSkillGraph(t){const e=this.db.prepare("SELECT * FROM skills").all(),s=new Map;for(const o of e)s.set(o.name,o);const r=o=>{if(!o)return[];try{const c=JSON.parse(o);return Array.isArray(c)?c.filter(l=>typeof l=="string"):[]}catch{return[]}},n=(o,c)=>{const l=s.get(o);if(!l)return null;const p=r(l.governs),_=r(l.composes),g={name:l.name,tier:l.tier??"atomic",description:l.description,governs:p,composes:_,children:[]},S=[];if(!c.has(o)){const f=new Set(c).add(o);for(const E of p){const T=n(E,f);T?g.children.push(T):S.push(E)}}return S.length&&(g.missing=S),g};if(t){const o=n(t,new Set);return o?[o]:[]}const i=e.filter(o=>(o.tier??"atomic")==="god").map(o=>o.name).sort(),a=[];for(const o of i){const c=n(o,new Set);c&&a.push(c)}return a}updateSkill(t,e){const s=[],r=[];return e.description!==void 0&&(s.push("description = ?"),r.push(e.description)),e.skill_path!==void 0&&(s.push("skill_path = ?"),r.push(e.skill_path)),e.category!==void 0&&(s.push("category = ?"),r.push(e.category)),e.is_active!==void 0&&(s.push("is_active = ?"),r.push(e.is_active?1:0)),e.tags!==void 0&&(s.push("tags = ?"),r.push(e.tags)),e.version!==void 0&&(s.push("version = ?"),r.push(e.version)),s.length===0?this.getSkill(t):(s.push("updated_at = datetime('now')"),r.push(t),this.db.prepare(`
179
- UPDATE skills SET ${s.join(", ")} WHERE name = ? RETURNING *
180
- `).get(...r))}deleteSkill(t){return this.db.prepare("DELETE FROM skills WHERE name = ?").run(t).changes>0}deactivateSkill(t){return this.updateSkill(t,{is_active:!1})}activateSkill(t){return this.updateSkill(t,{is_active:!0})}getSkillStats(){const t=this.db.prepare("SELECT COUNT(*) as count FROM skills").get().count,e=this.db.prepare("SELECT COUNT(*) as count FROM skills WHERE is_active = 1").get().count,s=this.db.prepare(`
929
+ `);
930
+ return (tier ? stmt.all(match, tier, limit) : stmt.all(match, limit));
931
+ }
932
+ /**
933
+ * Build the GOD-SKILL SPEC v2 governance graph.
934
+ *
935
+ * Walks tier routing edges: a node's `governs` list names the skills it routes
936
+ * DOWN to (god → mega → atomic). Each node also exposes its `composes` list
937
+ * (lateral pull-ins). When `root` is given, returns that single tree; otherwise
938
+ * returns a forest of every 'god' tier skill (the apexes). Cycle-safe.
939
+ */
940
+ getSkillGraph(root) {
941
+ const all = this.db.prepare('SELECT * FROM skills').all();
942
+ const byName = new Map();
943
+ for (const s of all)
944
+ byName.set(s.name, s);
945
+ const parseArr = (v) => {
946
+ if (!v)
947
+ return [];
948
+ try {
949
+ const parsed = JSON.parse(v);
950
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : [];
951
+ }
952
+ catch {
953
+ return [];
954
+ }
955
+ };
956
+ const build = (name, seen) => {
957
+ const skill = byName.get(name);
958
+ if (!skill)
959
+ return null;
960
+ const governs = parseArr(skill.governs);
961
+ const composes = parseArr(skill.composes);
962
+ const node = {
963
+ name: skill.name,
964
+ tier: skill.tier ?? 'atomic',
965
+ description: skill.description,
966
+ governs,
967
+ composes,
968
+ children: [],
969
+ };
970
+ const missing = [];
971
+ if (!seen.has(name)) {
972
+ const nextSeen = new Set(seen).add(name);
973
+ for (const childName of governs) {
974
+ const child = build(childName, nextSeen);
975
+ if (child)
976
+ node.children.push(child);
977
+ else
978
+ missing.push(childName);
979
+ }
980
+ }
981
+ if (missing.length)
982
+ node.missing = missing;
983
+ return node;
984
+ };
985
+ if (root) {
986
+ const node = build(root, new Set());
987
+ return node ? [node] : [];
988
+ }
989
+ // No root → forest of all god-tier apexes (deterministic by name).
990
+ const gods = all
991
+ .filter((s) => (s.tier ?? 'atomic') === 'god')
992
+ .map((s) => s.name)
993
+ .sort();
994
+ const forest = [];
995
+ for (const g of gods) {
996
+ const node = build(g, new Set());
997
+ if (node)
998
+ forest.push(node);
999
+ }
1000
+ return forest;
1001
+ }
1002
+ updateSkill(name, updates) {
1003
+ const setClauses = [];
1004
+ const values = [];
1005
+ if (updates.description !== undefined) {
1006
+ setClauses.push('description = ?');
1007
+ values.push(updates.description);
1008
+ }
1009
+ if (updates.skill_path !== undefined) {
1010
+ setClauses.push('skill_path = ?');
1011
+ values.push(updates.skill_path);
1012
+ }
1013
+ if (updates.category !== undefined) {
1014
+ setClauses.push('category = ?');
1015
+ values.push(updates.category);
1016
+ }
1017
+ if (updates.is_active !== undefined) {
1018
+ setClauses.push('is_active = ?');
1019
+ values.push(updates.is_active ? 1 : 0);
1020
+ }
1021
+ if (updates.tags !== undefined) {
1022
+ setClauses.push('tags = ?');
1023
+ values.push(updates.tags);
1024
+ }
1025
+ if (updates.version !== undefined) {
1026
+ setClauses.push('version = ?');
1027
+ values.push(updates.version);
1028
+ }
1029
+ if (setClauses.length === 0) {
1030
+ return this.getSkill(name);
1031
+ }
1032
+ setClauses.push('updated_at = datetime(\'now\')');
1033
+ values.push(name);
1034
+ return this.db.prepare(`
1035
+ UPDATE skills SET ${setClauses.join(', ')} WHERE name = ? RETURNING *
1036
+ `).get(...values);
1037
+ }
1038
+ deleteSkill(name) {
1039
+ const result = this.db.prepare('DELETE FROM skills WHERE name = ?').run(name);
1040
+ return result.changes > 0;
1041
+ }
1042
+ deactivateSkill(name) {
1043
+ return this.updateSkill(name, { is_active: false });
1044
+ }
1045
+ activateSkill(name) {
1046
+ return this.updateSkill(name, { is_active: true });
1047
+ }
1048
+ getSkillStats() {
1049
+ const total = this.db.prepare('SELECT COUNT(*) as count FROM skills').get().count;
1050
+ const active = this.db.prepare('SELECT COUNT(*) as count FROM skills WHERE is_active = 1').get().count;
1051
+ const byCategoryRows = this.db.prepare(`
181
1052
  SELECT category, COUNT(*) as count FROM skills WHERE category IS NOT NULL GROUP BY category
182
- `).all(),r={};for(const n of s)r[n.category]=n.count;return{total:t,active:e,byCategory:r}}upsertSpec(t,e,s,r,n=0){return this.db.prepare(`
1053
+ `).all();
1054
+ const byCategory = {};
1055
+ for (const row of byCategoryRows) {
1056
+ byCategory[row.category] = row.count;
1057
+ }
1058
+ return { total, active, byCategory };
1059
+ }
1060
+ // ==================== SPEC-KIT REGISTRY ====================
1061
+ /** Upsert a spec→project link (idempotent by project_id + spec_dir). */
1062
+ upsertSpec(projectId, specDir, title, summary, taskCount = 0) {
1063
+ return this.db.prepare(`
183
1064
  INSERT INTO specs (project_id, spec_dir, title, summary, task_count)
184
1065
  VALUES (?, ?, ?, ?, ?)
185
1066
  ON CONFLICT(project_id, spec_dir) DO UPDATE SET
@@ -188,62 +1069,406 @@ import k from"better-sqlite3";import{existsSync as u,mkdirSync as b,readdirSync
188
1069
  task_count = excluded.task_count,
189
1070
  updated_at = datetime('now')
190
1071
  RETURNING *
191
- `).get(t,e,s||null,r||null,n)}getSpec(t,e){return this.db.prepare("SELECT * FROM specs WHERE project_id = ? AND spec_dir = ?").get(t,e)}listSpecs(t){return t!==void 0?this.db.prepare("SELECT * FROM specs WHERE project_id = ? ORDER BY updated_at DESC").all(t):this.db.prepare("SELECT * FROM specs ORDER BY updated_at DESC").all()}findQuestBySpecSignature(t,e){return this.db.prepare(`
1072
+ `).get(projectId, specDir, title || null, summary || null, taskCount);
1073
+ }
1074
+ getSpec(projectId, specDir) {
1075
+ return this.db.prepare('SELECT * FROM specs WHERE project_id = ? AND spec_dir = ?').get(projectId, specDir);
1076
+ }
1077
+ listSpecs(projectId) {
1078
+ if (projectId !== undefined) {
1079
+ return this.db.prepare('SELECT * FROM specs WHERE project_id = ? ORDER BY updated_at DESC').all(projectId);
1080
+ }
1081
+ return this.db.prepare('SELECT * FROM specs ORDER BY updated_at DESC').all();
1082
+ }
1083
+ /**
1084
+ * Find an existing quest for a project carrying a specific spec-task tag
1085
+ * signature. Used by wyrm_spec_register to dedupe (update-not-duplicate) on
1086
+ * re-run. The signature lives in the quest's comma-separated `tags` column.
1087
+ */
1088
+ findQuestBySpecSignature(projectId, signature) {
1089
+ return this.db.prepare(`
192
1090
  SELECT * FROM quests
193
1091
  WHERE project_id = ?
194
1092
  AND (tags = ? OR tags LIKE ? OR tags LIKE ? OR tags LIKE ?)
195
1093
  LIMIT 1
196
- `).get(t,e,`${e},%`,`%,${e}`,`%,${e},%`)}updateQuestFields(t,e){const s=[],r=[];if(e.title!==void 0&&(s.push("title = ?"),r.push(e.title)),e.description!==void 0&&(s.push("description = ?"),r.push(e.description)),e.tags!==void 0&&(s.push("tags = ?"),r.push(e.tags)),s.length===0)return this.db.prepare("SELECT * FROM quests WHERE id = ?").get(t);r.push(t);const n=this.db.prepare(`UPDATE quests SET ${s.join(", ")} WHERE id = ? RETURNING *`).get(...r);return n&&m(this.db,{projectId:n.project_id,kind:"quest",refTable:"quests",refId:t}),n}insertData(t,e,s,r,n){const i=this.resilience.withRetrySync(()=>this.db.prepare(`
1094
+ `).get(projectId, signature, `${signature},%`, `%,${signature}`, `%,${signature},%`);
1095
+ }
1096
+ /** Update a quest's title/description/tags in place (used by spec re-registration). */
1097
+ updateQuestFields(id, fields) {
1098
+ const setClauses = [];
1099
+ const values = [];
1100
+ if (fields.title !== undefined) {
1101
+ setClauses.push('title = ?');
1102
+ values.push(fields.title);
1103
+ }
1104
+ if (fields.description !== undefined) {
1105
+ setClauses.push('description = ?');
1106
+ values.push(fields.description);
1107
+ }
1108
+ if (fields.tags !== undefined) {
1109
+ setClauses.push('tags = ?');
1110
+ values.push(fields.tags);
1111
+ }
1112
+ if (setClauses.length === 0) {
1113
+ return this.db.prepare('SELECT * FROM quests WHERE id = ?').get(id);
1114
+ }
1115
+ values.push(id);
1116
+ const quest = this.db.prepare(`UPDATE quests SET ${setClauses.join(', ')} WHERE id = ? RETURNING *`).get(...values);
1117
+ if (quest)
1118
+ emitEvent(this.db, { projectId: quest.project_id, kind: 'quest', refTable: 'quests', refId: id });
1119
+ return quest;
1120
+ }
1121
+ // ==================== DATA LAKE ====================
1122
+ insertData(projectId, category, key, value, metadata) {
1123
+ const result = this.resilience.withRetrySync(() => this.db.prepare(`
197
1124
  INSERT INTO data_lake (project_id, category, key, value, metadata)
198
1125
  VALUES (?, ?, ?, ?, ?)
199
1126
  RETURNING *
200
- `).get(t,e,s,r,n?JSON.stringify(n):null),"insertData");if(!i.success)throw i.error||new Error("Insert data failed");return i.data}batchWrites(t,e){return this.db.transaction(r=>r.map(n=>e(n)))(t)}insertDataBatch(t){const e=this.resilience.generateOperationId("batch_insert"),s=this.BATCH_SIZE;let r=0;this.resilience.createCheckpoint(e,"batch_insert","started",{totalItems:t.length,batchSize:s});const n=this.db.prepare(`
1127
+ `).get(projectId, category, key, value, metadata ? JSON.stringify(metadata) : null), 'insertData');
1128
+ if (!result.success) {
1129
+ throw result.error || new Error('Insert data failed');
1130
+ }
1131
+ return result.data;
1132
+ }
1133
+ /**
1134
+ * v7 F2 (T011): batch N arbitrary writes into ONE better-sqlite3 transaction.
1135
+ *
1136
+ * Cross-process write story — under WAL every transaction takes the single
1137
+ * cross-process write lock; batching N writes into one transaction acquires
1138
+ * it ONCE per batch instead of once per row, so a fleet writer holds the
1139
+ * lock for one short window rather than N tiny contended ones. The whole
1140
+ * batch is atomic: if any `write(item)` throws, better-sqlite3 rolls the
1141
+ * transaction back and NO item persists (the thrown error propagates).
1142
+ *
1143
+ * Why this is a transaction helper and NOT an in-process write queue:
1144
+ * better-sqlite3 is fully synchronous — two writes issued by the SAME
1145
+ * process can never interleave by construction (each statement completes
1146
+ * before the next starts), so an in-process queue would serialize something
1147
+ * that is already serial. The only real contention is CROSS-process, which
1148
+ * is handled by busy_timeout=5000 (constructor above) plus the structured
1149
+ * WYRM_BUSY dispatcher body (sqlite-busy.ts).
1150
+ *
1151
+ * NOTE: `write` runs inside the transaction — keep it to synchronous DB
1152
+ * work (no awaits; better-sqlite3 transactions cannot span async gaps).
1153
+ */
1154
+ batchWrites(items, write) {
1155
+ const tx = this.db.transaction((batch) => batch.map((item) => write(item)));
1156
+ return tx(items);
1157
+ }
1158
+ /**
1159
+ * Batch insert with resilience - uses checkpointing for large batches.
1160
+ *
1161
+ * Contract: returns the inserted count ONLY when every chunk committed.
1162
+ * A failed chunk must surface as a structured retryable error, not a
1163
+ * success-shaped partial count — each chunk commits through one batchWrites
1164
+ * transaction, so a failed chunk rolled back atomically and rethrowing lets
1165
+ * the dispatcher map SQLITE_BUSY to the structured WYRM_BUSY retry body
1166
+ * exactly like every sibling write path (insertData, createSession, ...).
1167
+ * Cross-chunk progress rides on the thrown error as `inserted` so a caller
1168
+ * batching more than one chunk (> BATCH_SIZE items) can surface how many
1169
+ * rows landed in the chunks that DID commit.
1170
+ */
1171
+ insertDataBatch(data) {
1172
+ const operationId = this.resilience.generateOperationId('batch_insert');
1173
+ const batchSize = this.BATCH_SIZE;
1174
+ let totalInserted = 0;
1175
+ // Checkpoint for recovery
1176
+ this.resilience.createCheckpoint(operationId, 'batch_insert', 'started', {
1177
+ totalItems: data.length,
1178
+ batchSize,
1179
+ });
1180
+ const insert = this.db.prepare(`
201
1181
  INSERT INTO data_lake (project_id, category, key, value, metadata)
202
1182
  VALUES (?, ?, ?, ?, ?)
203
- `);try{for(let i=0;i<t.length;i+=s){const a=t.slice(i,i+s),o=Math.floor(i/s)+1;this.resilience.updateCheckpoint(e,`batch_${o}`,{processed:i,currentBatch:o});const c=this.resilience.withRetrySync(()=>this.batchWrites(a,l=>(n.run(l.projectId,l.category,l.key,l.value,l.metadata?JSON.stringify(l.metadata):null),1)).length,`batch_insert_${o}`,{maxAttempts:3});if(!c.success){this.logger.error("Batch insert failed",{batch:o,processed:r,error:c.error?.message}),this.resilience.updateCheckpoint(e,"partial_failure",{inserted:r,failedAt:i});const l=c.error??new Error(`Batch insert failed at chunk ${o}`);throw l.inserted=r,l}r+=c.data}return this.resilience.completeCheckpoint(e),r}catch(i){throw i!==null&&typeof i=="object"&&"inserted"in i||(this.logger.error("Batch insert exception",{inserted:r,error:i.message}),this.resilience.updateCheckpoint(e,"exception",{inserted:r,error:i.message}),i!==null&&typeof i=="object"&&(i.inserted=r)),i}}queryData(t,e,s=100,r=0){return e?this.db.prepare(`
1183
+ `);
1184
+ try {
1185
+ // Process in batches for large datasets
1186
+ for (let i = 0; i < data.length; i += batchSize) {
1187
+ const batch = data.slice(i, i + batchSize);
1188
+ const batchNum = Math.floor(i / batchSize) + 1;
1189
+ this.resilience.updateCheckpoint(operationId, `batch_${batchNum}`, {
1190
+ processed: i,
1191
+ currentBatch: batchNum,
1192
+ });
1193
+ // One transaction per chunk via batchWrites (v7 F2 review fix: this
1194
+ // IS the production call site of the T011 helper — the write lock is
1195
+ // taken once per chunk instead of once per row, the cross-process
1196
+ // story sqlite-busy.ts documents as mitigation (b)).
1197
+ const result = this.resilience.withRetrySync(() => this.batchWrites(batch, (item) => {
1198
+ insert.run(item.projectId, item.category, item.key, item.value, item.metadata ? JSON.stringify(item.metadata) : null);
1199
+ return 1;
1200
+ }).length, `batch_insert_${batchNum}`, { maxAttempts: 3 });
1201
+ if (!result.success) {
1202
+ this.logger.error('Batch insert failed', {
1203
+ batch: batchNum,
1204
+ processed: totalInserted,
1205
+ error: result.error?.message,
1206
+ });
1207
+ this.resilience.updateCheckpoint(operationId, 'partial_failure', {
1208
+ inserted: totalInserted,
1209
+ failedAt: i,
1210
+ });
1211
+ // A failed chunk must surface as a structured retryable error, not
1212
+ // a success-shaped partial count: the chunk transaction rolled back
1213
+ // atomically (nothing partial within it), and rethrowing with the
1214
+ // SQLite code intact is what lets the dispatcher emit the WYRM_BUSY
1215
+ // retry body. Cross-chunk progress rides on the error (see doc).
1216
+ const error = result.error ?? new Error(`Batch insert failed at chunk ${batchNum}`);
1217
+ error.inserted = totalInserted;
1218
+ throw error;
1219
+ }
1220
+ totalInserted += result.data;
1221
+ }
1222
+ this.resilience.completeCheckpoint(operationId);
1223
+ return totalInserted;
1224
+ }
1225
+ catch (error) {
1226
+ // Same invariant as the chunk-failure branch: swallowing here would
1227
+ // re-shape the failure into a success-looking count. A chunk failure
1228
+ // already logged + checkpointed before throwing (it carries the
1229
+ // cross-chunk progress); anything else is an unexpected exception —
1230
+ // record it, attach progress, and keep it loud either way.
1231
+ const alreadyHandled = error !== null && typeof error === 'object' && 'inserted' in error;
1232
+ if (!alreadyHandled) {
1233
+ this.logger.error('Batch insert exception', {
1234
+ inserted: totalInserted,
1235
+ error: error.message,
1236
+ });
1237
+ this.resilience.updateCheckpoint(operationId, 'exception', {
1238
+ inserted: totalInserted,
1239
+ error: error.message,
1240
+ });
1241
+ if (error !== null && typeof error === 'object') {
1242
+ error.inserted = totalInserted;
1243
+ }
1244
+ }
1245
+ throw error;
1246
+ }
1247
+ }
1248
+ queryData(projectId, category, limit = 100, offset = 0) {
1249
+ if (category) {
1250
+ return this.db.prepare(`
204
1251
  SELECT * FROM data_lake
205
1252
  WHERE project_id = ? AND category = ?
206
1253
  ORDER BY created_at DESC
207
1254
  LIMIT ? OFFSET ?
208
- `).all(t,e,s,r):this.db.prepare(`
1255
+ `).all(projectId, category, limit, offset);
1256
+ }
1257
+ return this.db.prepare(`
209
1258
  SELECT * FROM data_lake
210
1259
  WHERE project_id = ?
211
1260
  ORDER BY created_at DESC
212
1261
  LIMIT ? OFFSET ?
213
- `).all(t,s,r)}searchData(t,e){const s=h(t);if(!s)return[];try{return e?this.db.prepare(`
1262
+ `).all(projectId, limit, offset);
1263
+ }
1264
+ searchData(query, projectId) {
1265
+ const match = buildFtsMatchQuery(query);
1266
+ if (!match)
1267
+ return [];
1268
+ try {
1269
+ if (projectId) {
1270
+ return this.db.prepare(`
214
1271
  SELECT d.* FROM data_lake d
215
1272
  JOIN data_lake_fts fts ON d.id = fts.rowid
216
1273
  WHERE data_lake_fts MATCH ? AND d.project_id = ?
217
1274
  ORDER BY bm25(data_lake_fts), d.created_at DESC
218
1275
  LIMIT 100
219
- `).all(s,e):this.db.prepare(`
1276
+ `).all(match, projectId);
1277
+ }
1278
+ return this.db.prepare(`
220
1279
  SELECT d.* FROM data_lake d
221
1280
  JOIN data_lake_fts fts ON d.id = fts.rowid
222
1281
  WHERE data_lake_fts MATCH ?
223
1282
  ORDER BY bm25(data_lake_fts), d.created_at DESC
224
1283
  LIMIT 100
225
- `).all(s)}catch{return[]}}searchArtifacts(t,e){const s=h(t);if(!s)return[];try{return e?this.db.prepare(`
1284
+ `).all(match);
1285
+ }
1286
+ catch {
1287
+ return [];
1288
+ }
1289
+ }
1290
+ /**
1291
+ * Lexical (FTS5) search over ACTIVE memory artifacts — the keyword peer of
1292
+ * the vector channel wyrm_search already fuses. Mirrors searchData (OR-
1293
+ * semantics + porter stemming via buildFtsMatchQuery, bm25 ranking). Only
1294
+ * surfaces live artifacts (needs_review = 0, not superseded), matching the
1295
+ * recall contract. Fixes the gap where wyrm_search found artifacts
1296
+ * semantically but never lexically.
1297
+ */
1298
+ searchArtifacts(query, projectId) {
1299
+ const match = buildFtsMatchQuery(query);
1300
+ if (!match)
1301
+ return [];
1302
+ try {
1303
+ if (projectId) {
1304
+ return this.db.prepare(`
226
1305
  SELECT a.* FROM memory_artifacts a
227
1306
  JOIN memory_artifacts_fts fts ON a.id = fts.rowid
228
1307
  WHERE memory_artifacts_fts MATCH ? AND a.project_id = ?
229
1308
  AND a.needs_review = 0 AND a.supersedes_id IS NULL
230
1309
  ORDER BY bm25(memory_artifacts_fts), a.confidence DESC
231
1310
  LIMIT 100
232
- `).all(s,e):this.db.prepare(`
1311
+ `).all(match, projectId);
1312
+ }
1313
+ return this.db.prepare(`
233
1314
  SELECT a.* FROM memory_artifacts a
234
1315
  JOIN memory_artifacts_fts fts ON a.id = fts.rowid
235
1316
  WHERE memory_artifacts_fts MATCH ?
236
1317
  AND a.needs_review = 0 AND a.supersedes_id IS NULL
237
1318
  ORDER BY bm25(memory_artifacts_fts), a.confidence DESC
238
1319
  LIMIT 100
239
- `).all(s)}catch{return[]}}getDataCategories(t){return this.db.prepare(`
1320
+ `).all(match);
1321
+ }
1322
+ catch {
1323
+ return [];
1324
+ }
1325
+ }
1326
+ getDataCategories(projectId) {
1327
+ return this.db.prepare(`
240
1328
  SELECT category, COUNT(*) as count
241
1329
  FROM data_lake
242
1330
  WHERE project_id = ?
243
1331
  GROUP BY category
244
1332
  ORDER BY count DESC
245
- `).all(t)}deleteDataCategory(t,e){return this.db.prepare(`
1333
+ `).all(projectId);
1334
+ }
1335
+ deleteDataCategory(projectId, category) {
1336
+ const result = this.db.prepare(`
246
1337
  DELETE FROM data_lake WHERE project_id = ? AND category = ?
247
- `).run(t,e).changes}*streamSessions(t){const e=this.db.prepare(`
1338
+ `).run(projectId, category);
1339
+ return result.changes;
1340
+ }
1341
+ // ==================== STREAMING ====================
1342
+ *streamSessions(projectId) {
1343
+ const stmt = this.db.prepare(`
248
1344
  SELECT * FROM sessions WHERE project_id = ? ORDER BY date DESC
249
- `);for(const s of e.iterate(t))yield s}*streamData(t,e){const s=e?this.db.prepare("SELECT * FROM data_lake WHERE project_id = ? AND category = ?"):this.db.prepare("SELECT * FROM data_lake WHERE project_id = ?"),r=e?[t,e]:[t];for(const n of s.iterate(...r))yield n}getStats(){const t=this.db.prepare("SELECT COUNT(*) as count FROM projects").get(),e=this.db.prepare("SELECT COUNT(*) as count FROM sessions").get(),s=this.db.prepare("SELECT COUNT(*) as count FROM quests").get(),r=this.db.prepare("SELECT COUNT(*) as count FROM memory_artifacts").get(),n=this.db.prepare("SELECT COUNT(*) as count FROM data_lake").get(),i=this.db.prepare("SELECT COALESCE(SUM(tokens_estimate), 0) as total FROM sessions WHERE is_archived = 0").get(),a=this.db.pragma("page_count",{simple:!0}),o=this.db.pragma("page_size",{simple:!0}),c=a*o/(1024*1024);return{projects:t.count,sessions:e.count,quests:s.count,artifacts:r.count,dataPoints:n.count,totalTokens:i.total,dbSize:`${c.toFixed(2)} MB`}}getProjectStats(t){const e=this.db.prepare("SELECT COUNT(*) as count FROM sessions WHERE project_id = ?").get(t),s=this.db.prepare("SELECT COUNT(*) as count FROM quests WHERE project_id = ? AND status IN ('pending', 'in_progress')").get(t),r=this.db.prepare("SELECT COUNT(*) as count FROM quests WHERE project_id = ? AND status = 'completed'").get(t),n=this.db.prepare("SELECT COUNT(*) as count FROM data_lake WHERE project_id = ?").get(t),i=this.db.prepare("SELECT COALESCE(SUM(tokens_estimate), 0) as total FROM sessions WHERE project_id = ? AND is_archived = 0").get(t);return{sessions:e.count,quests:{pending:s.count,completed:r.count},dataPoints:n.count,tokens:i.total}}estimateTokens(t){return Math.ceil(t.length/4)}vacuum(){this.db.exec("VACUUM")}checkpoint(){this.db.pragma("wal_checkpoint(TRUNCATE)")}getResilienceStatus(){const t=this.resilience.getCircuitStatus(),e=this.resilience.getIncompleteOperations();return{circuitState:t.state,failures:t.failures,incompleteOps:e.length}}resetCircuitBreaker(){this.resilience.resetCircuit()}close(){try{this.checkpoint(),this.logger.info("Database checkpoint completed")}catch(t){this.logger.error("Checkpoint failed during close",{error:t.message})}try{this.db.close(),this.logger.info("Database closed successfully")}catch(t){this.logger.error("Database close failed",{error:t.message})}}checkIntegrity(){const t=[];try{const e=this.db.pragma("integrity_check",{simple:!0});e!=="ok"&&t.push(`Integrity check failed: ${e}`)}catch(e){t.push(`Integrity check error: ${e.message}`)}try{const e=this.db.pragma("foreign_key_check");e.length>0&&t.push(`Foreign key violations: ${e.length}`)}catch(e){t.push(`FK check error: ${e.message}`)}return{ok:t.length===0,issues:t}}}export{ce as WyrmDB,ae as resolveDbPath};
1345
+ `);
1346
+ for (const row of stmt.iterate(projectId)) {
1347
+ yield row;
1348
+ }
1349
+ }
1350
+ *streamData(projectId, category) {
1351
+ const stmt = category
1352
+ ? this.db.prepare('SELECT * FROM data_lake WHERE project_id = ? AND category = ?')
1353
+ : this.db.prepare('SELECT * FROM data_lake WHERE project_id = ?');
1354
+ const params = category ? [projectId, category] : [projectId];
1355
+ for (const row of stmt.iterate(...params)) {
1356
+ yield row;
1357
+ }
1358
+ }
1359
+ // ==================== STATS & UTILITIES ====================
1360
+ getStats() {
1361
+ const projects = this.db.prepare('SELECT COUNT(*) as count FROM projects').get();
1362
+ const sessions = this.db.prepare('SELECT COUNT(*) as count FROM sessions').get();
1363
+ const quests = this.db.prepare('SELECT COUNT(*) as count FROM quests').get();
1364
+ const artifacts = this.db.prepare('SELECT COUNT(*) as count FROM memory_artifacts').get();
1365
+ const dataPoints = this.db.prepare('SELECT COUNT(*) as count FROM data_lake').get();
1366
+ const tokens = this.db.prepare('SELECT COALESCE(SUM(tokens_estimate), 0) as total FROM sessions WHERE is_archived = 0').get();
1367
+ const pageCount = this.db.pragma('page_count', { simple: true });
1368
+ const pageSize = this.db.pragma('page_size', { simple: true });
1369
+ const dbSize = (pageCount * pageSize) / (1024 * 1024);
1370
+ return {
1371
+ projects: projects.count,
1372
+ sessions: sessions.count,
1373
+ quests: quests.count,
1374
+ artifacts: artifacts.count,
1375
+ dataPoints: dataPoints.count,
1376
+ totalTokens: tokens.total,
1377
+ dbSize: `${dbSize.toFixed(2)} MB`
1378
+ };
1379
+ }
1380
+ getProjectStats(projectId) {
1381
+ const sessions = this.db.prepare('SELECT COUNT(*) as count FROM sessions WHERE project_id = ?').get(projectId);
1382
+ const pendingQuests = this.db.prepare(`SELECT COUNT(*) as count FROM quests WHERE project_id = ? AND status IN ('pending', 'in_progress')`).get(projectId);
1383
+ const completedQuests = this.db.prepare(`SELECT COUNT(*) as count FROM quests WHERE project_id = ? AND status = 'completed'`).get(projectId);
1384
+ const dataPoints = this.db.prepare('SELECT COUNT(*) as count FROM data_lake WHERE project_id = ?').get(projectId);
1385
+ const tokens = this.db.prepare('SELECT COALESCE(SUM(tokens_estimate), 0) as total FROM sessions WHERE project_id = ? AND is_archived = 0').get(projectId);
1386
+ return {
1387
+ sessions: sessions.count,
1388
+ quests: { pending: pendingQuests.count, completed: completedQuests.count },
1389
+ dataPoints: dataPoints.count,
1390
+ tokens: tokens.total
1391
+ };
1392
+ }
1393
+ estimateTokens(text) {
1394
+ // Rough estimate: ~4 chars per token
1395
+ return Math.ceil(text.length / 4);
1396
+ }
1397
+ vacuum() {
1398
+ this.db.exec('VACUUM');
1399
+ }
1400
+ checkpoint() {
1401
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
1402
+ }
1403
+ /**
1404
+ * Get resilience status for monitoring
1405
+ */
1406
+ getResilienceStatus() {
1407
+ const circuit = this.resilience.getCircuitStatus();
1408
+ const incomplete = this.resilience.getIncompleteOperations();
1409
+ return {
1410
+ circuitState: circuit.state,
1411
+ failures: circuit.failures,
1412
+ incompleteOps: incomplete.length,
1413
+ };
1414
+ }
1415
+ /**
1416
+ * Reset circuit breaker (manual recovery)
1417
+ */
1418
+ resetCircuitBreaker() {
1419
+ this.resilience.resetCircuit();
1420
+ }
1421
+ /**
1422
+ * Safe close with WAL checkpoint and cleanup
1423
+ */
1424
+ close() {
1425
+ try {
1426
+ // Checkpoint WAL to ensure all data is persisted
1427
+ this.checkpoint();
1428
+ this.logger.info('Database checkpoint completed');
1429
+ }
1430
+ catch (error) {
1431
+ this.logger.error('Checkpoint failed during close', {
1432
+ error: error.message,
1433
+ });
1434
+ }
1435
+ try {
1436
+ this.db.close();
1437
+ this.logger.info('Database closed successfully');
1438
+ }
1439
+ catch (error) {
1440
+ this.logger.error('Database close failed', {
1441
+ error: error.message,
1442
+ });
1443
+ }
1444
+ }
1445
+ /**
1446
+ * Check database integrity
1447
+ */
1448
+ checkIntegrity() {
1449
+ const issues = [];
1450
+ try {
1451
+ const result = this.db.pragma('integrity_check', { simple: true });
1452
+ if (result !== 'ok') {
1453
+ issues.push(`Integrity check failed: ${result}`);
1454
+ }
1455
+ }
1456
+ catch (error) {
1457
+ issues.push(`Integrity check error: ${error.message}`);
1458
+ }
1459
+ try {
1460
+ const fk = this.db.pragma('foreign_key_check');
1461
+ if (fk.length > 0) {
1462
+ issues.push(`Foreign key violations: ${fk.length}`);
1463
+ }
1464
+ }
1465
+ catch (error) {
1466
+ issues.push(`FK check error: ${error.message}`);
1467
+ }
1468
+ return {
1469
+ ok: issues.length === 0,
1470
+ issues,
1471
+ };
1472
+ }
1473
+ }
1474
+ //# sourceMappingURL=database.js.map