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.
- package/README.md +8 -14
- package/dist/activation.js +59 -1
- package/dist/agent-daemon.js +281 -4
- package/dist/agent-loop.js +332 -7
- package/dist/analytics.js +236 -13
- package/dist/attribution.js +49 -1
- package/dist/audit.js +457 -2
- package/dist/auto-capture.js +138 -3
- package/dist/auto-orchestrator.js +325 -1
- package/dist/autoconfig.d.ts +50 -0
- package/dist/autoconfig.d.ts.map +1 -1
- package/dist/autoconfig.js +1115 -39
- package/dist/autoconfig.js.map +1 -1
- package/dist/buddy-runner.js +109 -1
- package/dist/buddy.js +564 -14
- package/dist/build-flags.js +15 -1
- package/dist/capabilities.js +183 -3
- package/dist/capture.js +56 -1
- package/dist/causality.js +148 -8
- package/dist/cli.js +281 -20
- package/dist/cloud/cli.js +541 -5
- package/dist/cloud/client.js +221 -1
- package/dist/cloud/crypto.js +85 -1
- package/dist/cloud/machine-id.js +113 -2
- package/dist/cloud/recovery.js +60 -1
- package/dist/cloud/sync-engine.js +543 -7
- package/dist/cloud-backup.js +579 -5
- package/dist/cloud-profile.js +138 -1
- package/dist/cloud-sync-entrypoint.js +47 -1
- package/dist/cloud-sync.js +309 -2
- package/dist/connectors/bridge-source.d.ts +46 -0
- package/dist/connectors/bridge-source.d.ts.map +1 -0
- package/dist/connectors/bridge-source.js +77 -0
- package/dist/connectors/bridge-source.js.map +1 -0
- package/dist/connectors/index.d.ts +24 -0
- package/dist/connectors/index.d.ts.map +1 -0
- package/dist/connectors/index.js +69 -0
- package/dist/connectors/index.js.map +1 -0
- package/dist/connectors/ingest.d.ts +16 -0
- package/dist/connectors/ingest.d.ts.map +1 -0
- package/dist/connectors/ingest.js +116 -0
- package/dist/connectors/ingest.js.map +1 -0
- package/dist/connectors/types.d.ts +99 -0
- package/dist/connectors/types.d.ts.map +1 -0
- package/dist/connectors/types.js +17 -0
- package/dist/connectors/types.js.map +1 -0
- package/dist/constellation.js +168 -12
- package/dist/content-signature.js +45 -1
- package/dist/context-build-budgeted.js +144 -4
- package/dist/context-ranking.js +69 -1
- package/dist/crypto.js +179 -1
- package/dist/daemon-write-endpoint.js +290 -1
- package/dist/daemon-writer.js +406 -2
- package/dist/database.js +1278 -53
- package/dist/deprecations.js +162 -2
- package/dist/design.js +141 -13
- package/dist/event-replication.js +112 -1
- package/dist/events-sse.js +43 -7
- package/dist/events.js +238 -6
- package/dist/failure-patterns.d.ts +107 -0
- package/dist/failure-patterns.d.ts.map +1 -1
- package/dist/failure-patterns.js +924 -43
- package/dist/failure-patterns.js.map +1 -1
- package/dist/federation.js +236 -12
- package/dist/goals.js +101 -13
- package/dist/golden.js +355 -3
- package/dist/handlers/agent.js +165 -4
- package/dist/handlers/alias-adapters.js +129 -1
- package/dist/handlers/aliases.js +171 -1
- package/dist/handlers/audit.js +87 -1
- package/dist/handlers/boundary.js +221 -1
- package/dist/handlers/capture.js +1114 -73
- package/dist/handlers/causality.js +119 -9
- package/dist/handlers/cloud.js +382 -85
- package/dist/handlers/companion.js +459 -28
- package/dist/handlers/datalake.js +187 -7
- package/dist/handlers/dispatch-context.js +22 -0
- package/dist/handlers/entity.js +256 -25
- package/dist/handlers/events.js +335 -16
- package/dist/handlers/failure.d.ts.map +1 -1
- package/dist/handlers/failure.js +408 -13
- package/dist/handlers/failure.js.map +1 -1
- package/dist/handlers/goals.js +296 -4
- package/dist/handlers/intelligence.js +681 -126
- package/dist/handlers/invoicing.js +70 -1
- package/dist/handlers/mcpclient.js +137 -6
- package/dist/handlers/orchestration.js +125 -40
- package/dist/handlers/output-schemas.js +24 -1
- package/dist/handlers/presence.js +99 -3
- package/dist/handlers/project.js +182 -28
- package/dist/handlers/prompts.js +157 -6
- package/dist/handlers/quest.js +224 -4
- package/dist/handlers/recall.js +237 -13
- package/dist/handlers/registry.js +167 -1
- package/dist/handlers/resources.js +288 -1
- package/dist/handlers/review.js +74 -11
- package/dist/handlers/run.js +498 -16
- package/dist/handlers/search.js +338 -15
- package/dist/handlers/session.js +643 -31
- package/dist/handlers/share.js +184 -8
- package/dist/handlers/shims.js +464 -1
- package/dist/handlers/skill.js +449 -67
- package/dist/handlers/survivors.js +120 -1
- package/dist/handlers/symbols.js +109 -8
- package/dist/handlers/syncops.js +302 -4
- package/dist/handlers/types.js +27 -1
- package/dist/harvest.js +191 -5
- package/dist/hours.js +156 -7
- package/dist/http-auth.js +321 -3
- package/dist/http-fast.js +1302 -22
- package/dist/icons.js +47 -1
- package/dist/importers.js +268 -1
- package/dist/index.js +840 -2
- package/dist/indexer.js +145 -4
- package/dist/intelligence.js +261 -31
- package/dist/internal-dispatch.js +212 -3
- package/dist/keyset.js +110 -1
- package/dist/knowledge-graph.js +176 -12
- package/dist/license.js +441 -2
- package/dist/logger.js +199 -2
- package/dist/maintenance.js +148 -2
- package/dist/mcp-client.js +262 -6
- package/dist/memory-artifacts.js +596 -32
- package/dist/migrate-prompt.js +124 -2
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +799 -42
- package/dist/migrations.js.map +1 -1
- package/dist/performance.js +228 -1
- package/dist/presence.js +140 -11
- package/dist/priority-embed.js +164 -5
- package/dist/providers/embedding-provider.js +196 -1
- package/dist/readonly-gate.js +29 -1
- package/dist/receipt.js +43 -1
- package/dist/rehydration.js +157 -9
- package/dist/reindex.js +88 -1
- package/dist/render-target.js +544 -21
- package/dist/render.js +280 -4
- package/dist/repl-guard.js +173 -1
- package/dist/replication-daemon-entrypoint.js +31 -1
- package/dist/replication-daemon.js +262 -2
- package/dist/rerank.js +142 -1
- package/dist/resilience.js +591 -1
- package/dist/reverse-bridge.js +360 -5
- package/dist/security.js +244 -1
- package/dist/session-seen.js +51 -3
- package/dist/setup.js +260 -1
- package/dist/skill-author.js +168 -5
- package/dist/spec-kit.js +191 -1
- package/dist/sqlite-busy.js +154 -1
- package/dist/statusline.js +315 -11
- package/dist/sub-agent.js +262 -13
- package/dist/summarizer.js +139 -13
- package/dist/symbols.js +283 -7
- package/dist/sync.js +359 -5
- package/dist/tasks-dispatch.js +84 -1
- package/dist/tasks.js +282 -1
- package/dist/token-budget.js +143 -1
- package/dist/tool-analytics.js +129 -7
- package/dist/tool-annotations.js +365 -1
- package/dist/tool-manifest-v2.json +1 -1
- package/dist/tool-manifest.json +1 -1
- package/dist/tool-profiles.js +75 -1
- package/dist/trace-harvest.js +244 -6
- package/dist/types.js +30 -1
- package/dist/ui-dashboard.js +50 -41
- package/dist/ulid.js +81 -1
- package/dist/usage-tracker.js +66 -1
- package/dist/validate.js +129 -1
- package/dist/vault.js +534 -1
- package/dist/vector-init.js +67 -1
- package/dist/vectors.js +184 -3
- package/dist/version-check.js +136 -4
- package/dist/visibility.js +155 -19
- package/dist/wyrm-cli.js +2845 -101
- package/dist/wyrm-cli.js.map +1 -1
- package/dist/wyrm-guard.d.ts.map +1 -1
- package/dist/wyrm-guard.js +475 -14
- package/dist/wyrm-guard.js.map +1 -1
- package/dist/wyrm-loop.js +150 -3
- package/dist/wyrm-manifest.json +1 -1
- package/dist/wyrm-statusline-daemon.js +11 -1
- package/dist/wyrm-statusline.js +56 -4
- package/dist/wyrm-ui.js +77 -9
- package/package.json +1 -1
package/dist/database.js
CHANGED
|
@@ -1,11 +1,264 @@
|
|
|
1
|
-
|
|
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(
|
|
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(
|
|
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
|
-
`)
|
|
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(
|
|
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(
|
|
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
|
-
`)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
`)
|
|
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
|
-
`)
|
|
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 ${
|
|
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
|
-
`)
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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()
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 ?${
|
|
926
|
+
WHERE skills_fts MATCH ?${tierClause}
|
|
176
927
|
ORDER BY rank
|
|
177
928
|
LIMIT ?
|
|
178
|
-
`);
|
|
179
|
-
|
|
180
|
-
|
|
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()
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
`);
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
`);
|
|
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
|