@duckcodeailabs/dql-project 1.6.18 → 1.6.19

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.
@@ -0,0 +1,1521 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname } from 'node:path';
3
+ import { mkdirSync } from 'node:fs';
4
+ const require = createRequire(import.meta.url);
5
+ let databaseCtor = null;
6
+ const HAS_SQL_SQL = "(COALESCE(NULLIF(TRIM(reviewed_sql), ''), NULLIF(TRIM(generated_sql), '')) IS NOT NULL)";
7
+ const HAS_REVIEWED_SQL = "(NULLIF(TRIM(reviewed_sql), '') IS NOT NULL)";
8
+ const HAS_GENERATED_SQL = "(NULLIF(TRIM(generated_sql), '') IS NOT NULL)";
9
+ const HAS_PREVIEW_SQL = "(result_preview IS NOT NULL AND TRIM(result_preview) <> '')";
10
+ const HAS_DRAFT_SQL = "(draft_block_path IS NOT NULL AND TRIM(draft_block_path) <> '')";
11
+ const HAS_EVIDENCE_SQL = "((evidence IS NOT NULL AND TRIM(evidence) <> '' AND TRIM(evidence) <> 'null') OR (context_pack_id IS NOT NULL AND TRIM(context_pack_id) <> ''))";
12
+ const DRAFT_READY_SQL = `(TRIM(question) <> '' AND ${HAS_REVIEWED_SQL} AND ${HAS_EVIDENCE_SQL} AND status <> 'error')`;
13
+ const CERTIFICATION_READY_SQL = `(${DRAFT_READY_SQL} AND ${HAS_PREVIEW_SQL} AND ${HAS_DRAFT_SQL} AND COALESCE(dql_promotion_action, '') NOT IN ('reuse_existing', 'review_required'))`;
14
+ const BLOCKED_SQL = `(status = 'error' OR TRIM(question) = '' OR NOT ${HAS_SQL_SQL})`;
15
+ const NEXT_ACTION_SQL = `CASE
16
+ WHEN review_status IN ('completed', 'rejected', 'certified') THEN 'continue_review'
17
+ WHEN ${BLOCKED_SQL} THEN 'fix_blockers'
18
+ WHEN NOT ${HAS_REVIEWED_SQL} AND ${HAS_GENERATED_SQL} THEN 'review_sql'
19
+ WHEN NOT ${HAS_EVIDENCE_SQL} THEN 'review_context'
20
+ WHEN NOT ${HAS_PREVIEW_SQL} THEN 'run_preview'
21
+ WHEN dql_promotion_action = 'reuse_existing' THEN 'reuse_existing'
22
+ WHEN NOT ${HAS_DRAFT_SQL} AND ${DRAFT_READY_SQL} THEN 'create_dql_draft'
23
+ WHEN ${CERTIFICATION_READY_SQL} THEN 'open_certification'
24
+ WHEN ${HAS_DRAFT_SQL} THEN 'complete_review'
25
+ ELSE 'continue_review'
26
+ END`;
27
+ const NEXT_ACTION_PRIORITY_SQL = `CASE (${NEXT_ACTION_SQL})
28
+ WHEN 'fix_blockers' THEN 0
29
+ WHEN 'review_sql' THEN 1
30
+ WHEN 'review_context' THEN 2
31
+ WHEN 'run_preview' THEN 3
32
+ WHEN 'reuse_existing' THEN 4
33
+ WHEN 'create_dql_draft' THEN 5
34
+ WHEN 'open_certification' THEN 6
35
+ WHEN 'complete_review' THEN 7
36
+ WHEN 'continue_review' THEN 8
37
+ ELSE 9
38
+ END`;
39
+ const SEARCH_INDEX_VERSION = '3';
40
+ const RESEARCH_DOMAIN_GROUP_LIMIT = 100;
41
+ const RESEARCH_INTENT_GROUP_LIMIT = 50;
42
+ const RESEARCH_NOTEBOOK_GROUP_LIMIT = 100;
43
+ const RESEARCH_OWNER_GROUP_LIMIT = 100;
44
+ function loadDatabase() {
45
+ databaseCtor ??= require('better-sqlite3');
46
+ return databaseCtor;
47
+ }
48
+ const NOTEBOOK_NEXT_ACTION_PRIORITY = [
49
+ 'fix_blockers',
50
+ 'review_sql',
51
+ 'review_context',
52
+ 'run_preview',
53
+ 'reuse_existing',
54
+ 'create_dql_draft',
55
+ 'open_certification',
56
+ 'complete_review',
57
+ 'continue_review',
58
+ ];
59
+ function notebookSummaryNextAction(counts) {
60
+ for (const action of NOTEBOOK_NEXT_ACTION_PRIORITY) {
61
+ const count = counts[action];
62
+ if (count > 0)
63
+ return { action, count };
64
+ }
65
+ return undefined;
66
+ }
67
+ function actionCountsFromRow(row) {
68
+ return {
69
+ fix_blockers: row.nextFixBlockers ?? 0,
70
+ review_sql: row.nextReviewSql ?? 0,
71
+ review_context: row.nextReviewContext ?? 0,
72
+ run_preview: row.nextRunPreview ?? 0,
73
+ reuse_existing: row.nextReuseExisting ?? 0,
74
+ create_dql_draft: row.nextCreateDqlDraft ?? 0,
75
+ open_certification: row.nextOpenCertification ?? 0,
76
+ complete_review: row.nextCompleteReview ?? 0,
77
+ continue_review: row.nextContinueReview ?? 0,
78
+ };
79
+ }
80
+ export function defaultNotebookResearchDbPath(projectRoot) {
81
+ return `${projectRoot}/.dql/local/notebook-research.sqlite`;
82
+ }
83
+ export class LocalNotebookResearchStorage {
84
+ db;
85
+ searchIndexAvailable = false;
86
+ constructor(dbPath) {
87
+ mkdirSync(dirname(dbPath), { recursive: true });
88
+ const Database = loadDatabase();
89
+ this.db = new Database(dbPath);
90
+ this.db.pragma('journal_mode = WAL');
91
+ this.initSchema();
92
+ }
93
+ close() {
94
+ this.db.close();
95
+ }
96
+ createRun(input) {
97
+ const now = new Date().toISOString();
98
+ const question = cleanOptionalString(input.question) ?? 'Notebook research';
99
+ const sourceCell = normalizeSourceCellInput(input.sourceCell);
100
+ const sourceSql = sourceCell?.sql ?? sourceCell?.content ?? sourceCell?.source;
101
+ const run = {
102
+ id: input.id ?? `nbr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
103
+ notebookPath: input.notebookPath,
104
+ domain: cleanOptionalString(input.domain) ?? inferResearchDomain({
105
+ context: input.context,
106
+ notebookPath: input.notebookPath,
107
+ }),
108
+ owner: cleanOptionalString(input.owner),
109
+ sourceCellId: cleanOptionalString(input.sourceCellId) ?? sourceCell?.id,
110
+ sourceCellName: cleanOptionalString(input.sourceCellName) ?? sourceCell?.name,
111
+ sourceCellFingerprint: cleanOptionalString(input.sourceCellFingerprint)
112
+ ?? sourceCell?.fingerprint
113
+ ?? fingerprintSql(cleanOptionalString(input.reviewedSql) ?? cleanOptionalString(input.generatedSql) ?? sourceSql),
114
+ title: cleanOptionalString(input.title) ?? titleFromQuestion(question),
115
+ question,
116
+ intent: input.intent ?? inferIntent(question),
117
+ context: input.context,
118
+ status: 'draft',
119
+ generatedSql: cleanOptionalString(input.generatedSql),
120
+ reviewedSql: cleanOptionalString(input.reviewedSql),
121
+ warnings: [],
122
+ reviewStatus: 'needs_review',
123
+ dqlCandidateIds: [],
124
+ createdAt: now,
125
+ updatedAt: now,
126
+ };
127
+ this.db.prepare(`
128
+ INSERT INTO notebook_research_runs (
129
+ id, notebook_path, domain, owner, source_cell_id, source_cell_name, source_cell_fingerprint, title, question,
130
+ intent, context, status, summary, recommendation, result_preview,
131
+ evidence, research_plan, generated_sql, reviewed_sql, display, context_pack_id,
132
+ route_decision, warnings, review_status, error, draft_block_path,
133
+ dql_import_id, dql_candidate_ids, dql_promotion_action, dql_promotion, created_at, updated_at, last_run_at
134
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
135
+ `).run(run.id, run.notebookPath, run.domain ?? null, run.owner ?? null, run.sourceCellId ?? null, run.sourceCellName ?? null, run.sourceCellFingerprint ?? null, run.title, run.question, run.intent, json(run.context), run.status, null, null, null, null, null, run.generatedSql ?? null, run.reviewedSql ?? null, null, null, null, json(run.warnings), run.reviewStatus, null, null, null, json(run.dqlCandidateIds), null, null, run.createdAt, run.updatedAt, null);
136
+ this.upsertSearchIndex(run);
137
+ return run;
138
+ }
139
+ listRuns(query) {
140
+ return this.listRunsPage(query).runs;
141
+ }
142
+ listRunsPage(query) {
143
+ const normalized = normalizeListQuery(query);
144
+ const { whereSql, params } = researchListWhere(normalized, { useSearchIndex: this.searchIndexAvailable });
145
+ const countScope = { ...normalized, status: undefined, reviewStatus: undefined, promotionAction: undefined, readiness: undefined, age: undefined, nextAction: undefined };
146
+ const { whereSql: countWhereSql, params: countParams } = researchListWhere(countScope, { useSearchIndex: this.searchIndexAvailable });
147
+ const domainScope = { ...countScope, domain: undefined };
148
+ const { whereSql: domainWhereSql, params: domainParams } = researchListWhere(domainScope, { useSearchIndex: this.searchIndexAvailable });
149
+ const ownerScope = { ...countScope, owner: undefined };
150
+ const { whereSql: ownerWhereSql, params: ownerParams } = researchListWhere(ownerScope, { useSearchIndex: this.searchIndexAvailable });
151
+ const intentScope = { ...countScope, intent: undefined };
152
+ const { whereSql: intentWhereSql, params: intentParams } = researchListWhere(intentScope, { useSearchIndex: this.searchIndexAvailable });
153
+ const notebookScope = { ...countScope, notebookPath: undefined };
154
+ const { whereSql: notebookWhereSql, params: notebookParams } = researchListWhere(notebookScope, { useSearchIndex: this.searchIndexAvailable });
155
+ const groupCounts = this.db.prepare(`
156
+ SELECT
157
+ (SELECT COUNT(*) FROM (
158
+ SELECT COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized') AS domain
159
+ FROM notebook_research_runs
160
+ ${domainWhereSql}
161
+ GROUP BY COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized')
162
+ )) AS domains,
163
+ (SELECT COUNT(*) FROM (
164
+ SELECT intent
165
+ FROM notebook_research_runs
166
+ ${intentWhereSql}
167
+ GROUP BY intent
168
+ )) AS intents,
169
+ (SELECT COUNT(*) FROM (
170
+ SELECT COALESCE(NULLIF(TRIM(owner), ''), 'unassigned') AS owner
171
+ FROM notebook_research_runs
172
+ ${ownerWhereSql}
173
+ GROUP BY COALESCE(NULLIF(TRIM(owner), ''), 'unassigned')
174
+ )) AS owners,
175
+ (SELECT COUNT(*) FROM (
176
+ SELECT notebook_path
177
+ FROM notebook_research_runs
178
+ ${notebookWhereSql}
179
+ GROUP BY notebook_path
180
+ )) AS notebooks
181
+ `).get(...domainParams, ...intentParams, ...ownerParams, ...notebookParams);
182
+ const total = this.db.prepare(`
183
+ SELECT COUNT(*) AS count
184
+ FROM notebook_research_runs
185
+ ${whereSql}
186
+ `).get(...params)?.count ?? 0;
187
+ const staleCutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
188
+ const expiredCutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
189
+ const counts = this.db.prepare(`
190
+ SELECT
191
+ COUNT(*) AS total,
192
+ SUM(CASE WHEN status = 'ready' THEN 1 ELSE 0 END) AS ready,
193
+ SUM(CASE WHEN review_status = 'needs_review' THEN 1 ELSE 0 END) AS needsReview,
194
+ SUM(CASE WHEN review_status = 'draft_created' THEN 1 ELSE 0 END) AS dqlDrafts,
195
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors,
196
+ SUM(CASE WHEN dql_promotion_action = 'reuse_existing' THEN 1 ELSE 0 END) AS reuseExisting,
197
+ SUM(CASE WHEN dql_promotion_action = 'extend_existing' THEN 1 ELSE 0 END) AS extendExisting,
198
+ SUM(CASE WHEN dql_promotion_action = 'create_replacement' THEN 1 ELSE 0 END) AS replacements,
199
+ SUM(CASE WHEN dql_promotion_action = 'create_new' THEN 1 ELSE 0 END) AS createNew,
200
+ SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
201
+ SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
202
+ SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
203
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
204
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
205
+ COUNT(DISTINCT CASE WHEN source_cell_id IS NOT NULL AND TRIM(source_cell_id) <> '' THEN source_cell_id END) AS sourceLinked,
206
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
207
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
208
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
209
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
210
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
211
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
212
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
213
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
214
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview
215
+ FROM notebook_research_runs
216
+ ${countWhereSql}
217
+ `).get(staleCutoff, expiredCutoff, ...countParams);
218
+ const domains = this.db.prepare(`
219
+ SELECT
220
+ COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized') AS domain,
221
+ COUNT(*) AS total,
222
+ SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
223
+ SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
224
+ SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
225
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
226
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
227
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
228
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
229
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
230
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
231
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
232
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
233
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
234
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
235
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
236
+ MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
237
+ FROM notebook_research_runs
238
+ ${domainWhereSql}
239
+ GROUP BY COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized')
240
+ ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, domain ASC
241
+ LIMIT ${RESEARCH_DOMAIN_GROUP_LIMIT}
242
+ `).all(staleCutoff, expiredCutoff, ...domainParams);
243
+ const owners = this.db.prepare(`
244
+ SELECT
245
+ COALESCE(NULLIF(TRIM(owner), ''), 'unassigned') AS owner,
246
+ COUNT(*) AS total,
247
+ SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
248
+ SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
249
+ SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
250
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
251
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
252
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
253
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
254
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
255
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
256
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
257
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
258
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
259
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
260
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
261
+ MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
262
+ FROM notebook_research_runs
263
+ ${ownerWhereSql}
264
+ GROUP BY COALESCE(NULLIF(TRIM(owner), ''), 'unassigned')
265
+ ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, owner ASC
266
+ LIMIT ${RESEARCH_OWNER_GROUP_LIMIT}
267
+ `).all(staleCutoff, expiredCutoff, ...ownerParams);
268
+ const intents = this.db.prepare(`
269
+ SELECT
270
+ intent,
271
+ COUNT(*) AS total,
272
+ SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
273
+ SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
274
+ SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
275
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
276
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
277
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
278
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
279
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
280
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
281
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
282
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
283
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
284
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
285
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
286
+ MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
287
+ FROM notebook_research_runs
288
+ ${intentWhereSql}
289
+ GROUP BY intent
290
+ ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, intent ASC
291
+ LIMIT ${RESEARCH_INTENT_GROUP_LIMIT}
292
+ `).all(staleCutoff, expiredCutoff, ...intentParams);
293
+ const notebooks = this.db.prepare(`
294
+ SELECT
295
+ notebook_path AS path,
296
+ COUNT(*) AS total,
297
+ SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
298
+ SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
299
+ SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
300
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
301
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
302
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
303
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
304
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
305
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
306
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
307
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
308
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
309
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
310
+ SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
311
+ MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
312
+ FROM notebook_research_runs
313
+ ${notebookWhereSql}
314
+ GROUP BY notebook_path
315
+ ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, path ASC
316
+ LIMIT ${RESEARCH_NOTEBOOK_GROUP_LIMIT}
317
+ `).all(staleCutoff, expiredCutoff, ...notebookParams);
318
+ const orderSql = normalized.sort === 'priority'
319
+ ? `ORDER BY
320
+ ${NEXT_ACTION_PRIORITY_SQL} ASC,
321
+ updated_at DESC`
322
+ : 'ORDER BY updated_at DESC';
323
+ const pageSql = normalized.limit === undefined ? '' : 'LIMIT ? OFFSET ?';
324
+ const pageParams = normalized.limit === undefined ? [] : [normalized.limit, normalized.offset];
325
+ const rows = this.db.prepare(`
326
+ SELECT * FROM notebook_research_runs
327
+ ${whereSql}
328
+ ${orderSql}
329
+ ${pageSql}
330
+ `).all(...params, ...pageParams);
331
+ return {
332
+ runs: rows.map(rowToRun),
333
+ total,
334
+ domains: domains.map((row) => {
335
+ const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
336
+ return {
337
+ domain: row.domain ?? 'uncategorized',
338
+ total: row.total ?? 0,
339
+ draftReady: row.draftReady ?? 0,
340
+ certificationReady: row.certificationReady ?? 0,
341
+ blocked: row.blocked ?? 0,
342
+ staleOpen: row.staleOpen ?? 0,
343
+ expiredOpen: row.expiredOpen ?? 0,
344
+ ...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
345
+ };
346
+ }),
347
+ owners: owners.map((row) => {
348
+ const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
349
+ return {
350
+ owner: row.owner ?? 'unassigned',
351
+ total: row.total ?? 0,
352
+ draftReady: row.draftReady ?? 0,
353
+ certificationReady: row.certificationReady ?? 0,
354
+ blocked: row.blocked ?? 0,
355
+ staleOpen: row.staleOpen ?? 0,
356
+ expiredOpen: row.expiredOpen ?? 0,
357
+ ...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
358
+ };
359
+ }),
360
+ intents: intents.map((row) => {
361
+ const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
362
+ return {
363
+ intent: parseIntent(row.intent),
364
+ total: row.total ?? 0,
365
+ draftReady: row.draftReady ?? 0,
366
+ certificationReady: row.certificationReady ?? 0,
367
+ blocked: row.blocked ?? 0,
368
+ staleOpen: row.staleOpen ?? 0,
369
+ expiredOpen: row.expiredOpen ?? 0,
370
+ ...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
371
+ };
372
+ }),
373
+ notebooks: notebooks.map((row) => {
374
+ const path = row.path ?? 'notebooks/untitled.dqlnb';
375
+ const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
376
+ return {
377
+ path,
378
+ title: notebookTitleFromPath(path),
379
+ total: row.total ?? 0,
380
+ draftReady: row.draftReady ?? 0,
381
+ certificationReady: row.certificationReady ?? 0,
382
+ blocked: row.blocked ?? 0,
383
+ staleOpen: row.staleOpen ?? 0,
384
+ expiredOpen: row.expiredOpen ?? 0,
385
+ ...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
386
+ };
387
+ }),
388
+ counts: {
389
+ total: counts?.total ?? 0,
390
+ ready: counts?.ready ?? 0,
391
+ needsReview: counts?.needsReview ?? 0,
392
+ dqlDrafts: counts?.dqlDrafts ?? 0,
393
+ errors: counts?.errors ?? 0,
394
+ reuseExisting: counts?.reuseExisting ?? 0,
395
+ extendExisting: counts?.extendExisting ?? 0,
396
+ replacements: counts?.replacements ?? 0,
397
+ createNew: counts?.createNew ?? 0,
398
+ draftReady: counts?.draftReady ?? 0,
399
+ certificationReady: counts?.certificationReady ?? 0,
400
+ blocked: counts?.blocked ?? 0,
401
+ staleOpen: counts?.staleOpen ?? 0,
402
+ expiredOpen: counts?.expiredOpen ?? 0,
403
+ sourceLinked: counts?.sourceLinked ?? 0,
404
+ nextActions: {
405
+ fix_blockers: counts?.nextFixBlockers ?? 0,
406
+ review_sql: counts?.nextReviewSql ?? 0,
407
+ review_context: counts?.nextReviewContext ?? 0,
408
+ run_preview: counts?.nextRunPreview ?? 0,
409
+ reuse_existing: counts?.nextReuseExisting ?? 0,
410
+ create_dql_draft: counts?.nextCreateDqlDraft ?? 0,
411
+ open_certification: counts?.nextOpenCertification ?? 0,
412
+ complete_review: counts?.nextCompleteReview ?? 0,
413
+ continue_review: counts?.nextContinueReview ?? 0,
414
+ },
415
+ },
416
+ groupCounts: {
417
+ domains: groupCounts?.domains ?? domains.length,
418
+ owners: groupCounts?.owners ?? owners.length,
419
+ intents: groupCounts?.intents ?? intents.length,
420
+ notebooks: groupCounts?.notebooks ?? notebooks.length,
421
+ },
422
+ limit: normalized.limit,
423
+ offset: normalized.offset,
424
+ };
425
+ }
426
+ listLatestRunsBySourceCell(query) {
427
+ const notebookPath = cleanOptionalString(query.notebookPath);
428
+ if (!notebookPath)
429
+ return [];
430
+ const limit = typeof query.limit === 'number' && Number.isFinite(query.limit)
431
+ ? Math.max(1, Math.min(10_000, Math.floor(query.limit)))
432
+ : 10_000;
433
+ const sourceCells = normalizeSourceCoverageCells(query, limit);
434
+ const sourceCellIds = sourceCells.map((cell) => cell.id);
435
+ if (sourceCellIds.length === 0)
436
+ return [];
437
+ const bySourceCell = new Map();
438
+ const chunkSize = 400;
439
+ for (let index = 0; index < sourceCellIds.length; index += chunkSize) {
440
+ const chunk = sourceCellIds.slice(index, index + chunkSize);
441
+ const placeholders = chunk.map(() => '?').join(', ');
442
+ const rows = this.db.prepare(`
443
+ SELECT *
444
+ FROM notebook_research_runs
445
+ WHERE notebook_path = ?
446
+ AND source_cell_id IN (${placeholders})
447
+ ORDER BY source_cell_id ASC, updated_at DESC, rowid DESC
448
+ `).all(notebookPath, ...chunk);
449
+ for (const row of rows) {
450
+ const sourceCellId = optionalString(row.source_cell_id);
451
+ if (!sourceCellId || bySourceCell.has(sourceCellId))
452
+ continue;
453
+ bySourceCell.set(sourceCellId, rowToRun(row));
454
+ }
455
+ }
456
+ const unmatchedByFingerprint = new Map();
457
+ for (const sourceCell of sourceCells) {
458
+ if (bySourceCell.has(sourceCell.id) || !sourceCell.fingerprint)
459
+ continue;
460
+ const list = unmatchedByFingerprint.get(sourceCell.fingerprint) ?? [];
461
+ list.push(sourceCell);
462
+ unmatchedByFingerprint.set(sourceCell.fingerprint, list);
463
+ }
464
+ const fingerprints = Array.from(unmatchedByFingerprint.keys());
465
+ for (let index = 0; index < fingerprints.length; index += chunkSize) {
466
+ const chunk = fingerprints.slice(index, index + chunkSize);
467
+ const placeholders = chunk.map(() => '?').join(', ');
468
+ const rows = this.db.prepare(`
469
+ SELECT *
470
+ FROM notebook_research_runs
471
+ WHERE notebook_path = ?
472
+ AND source_cell_fingerprint IN (${placeholders})
473
+ AND (source_cell_id IS NULL OR TRIM(source_cell_id) = '')
474
+ ORDER BY source_cell_fingerprint ASC, updated_at DESC, rowid DESC
475
+ `).all(notebookPath, ...chunk);
476
+ const byFingerprint = new Set();
477
+ for (const row of rows) {
478
+ const fingerprint = optionalString(row.source_cell_fingerprint);
479
+ if (!fingerprint || byFingerprint.has(fingerprint))
480
+ continue;
481
+ byFingerprint.add(fingerprint);
482
+ const sourceCellMatches = unmatchedByFingerprint.get(fingerprint) ?? [];
483
+ const run = rowToRun(row);
484
+ for (const sourceCell of sourceCellMatches) {
485
+ if (bySourceCell.has(sourceCell.id))
486
+ continue;
487
+ bySourceCell.set(sourceCell.id, {
488
+ ...run,
489
+ sourceCellId: sourceCell.id,
490
+ sourceCellName: run.sourceCellName ?? sourceCell.name,
491
+ sourceCellFingerprint: run.sourceCellFingerprint ?? sourceCell.fingerprint,
492
+ });
493
+ }
494
+ }
495
+ }
496
+ return sourceCellIds
497
+ .map((sourceCellId) => bySourceCell.get(sourceCellId))
498
+ .filter((run) => Boolean(run));
499
+ }
500
+ listLatestRunsForMissingSourceCells(query) {
501
+ const notebookPath = cleanOptionalString(query.notebookPath);
502
+ if (!notebookPath)
503
+ return [];
504
+ const limit = typeof query.limit === 'number' && Number.isFinite(query.limit)
505
+ ? Math.max(1, Math.min(10_000, Math.floor(query.limit)))
506
+ : 10_000;
507
+ const currentSourceIds = new Set(normalizeSourceCoverageCells(query, Number.POSITIVE_INFINITY).map((cell) => cell.id));
508
+ const rows = this.db.prepare(`
509
+ SELECT *
510
+ FROM notebook_research_runs
511
+ WHERE notebook_path = ?
512
+ AND source_cell_id IS NOT NULL
513
+ AND TRIM(source_cell_id) <> ''
514
+ ORDER BY source_cell_id ASC, updated_at DESC, rowid DESC
515
+ `).all(notebookPath);
516
+ const byMissingSource = new Map();
517
+ for (const row of rows) {
518
+ const sourceCellId = optionalString(row.source_cell_id);
519
+ if (!sourceCellId || currentSourceIds.has(sourceCellId) || byMissingSource.has(sourceCellId))
520
+ continue;
521
+ byMissingSource.set(sourceCellId, rowToRun(row));
522
+ if (byMissingSource.size >= limit)
523
+ break;
524
+ }
525
+ return Array.from(byMissingSource.values());
526
+ }
527
+ getRun(id) {
528
+ const row = this.db.prepare('SELECT * FROM notebook_research_runs WHERE id = ?').get(id);
529
+ return row ? rowToRun(row) : null;
530
+ }
531
+ updateRun(id, input) {
532
+ const current = this.getRun(id);
533
+ if (!current)
534
+ return null;
535
+ const now = new Date().toISOString();
536
+ const nextPromotion = input.dqlPromotion === undefined ? current.dqlPromotion : input.dqlPromotion;
537
+ const nextContext = input.context === undefined ? current.context : input.context;
538
+ const nextDraftBlockPath = input.draftBlockPath === undefined ? current.draftBlockPath : cleanOptionalString(input.draftBlockPath);
539
+ const nextDomain = cleanOptionalString(input.domain)
540
+ ?? inferResearchDomain({
541
+ context: nextContext,
542
+ dqlPromotion: nextPromotion,
543
+ draftBlockPath: nextDraftBlockPath,
544
+ notebookPath: current.notebookPath,
545
+ })
546
+ ?? current.domain;
547
+ const nextPromotionAction = input.dqlPromotionAction
548
+ ?? normalizePromotionAction(nextPromotion?.recommendedAction)
549
+ ?? current.dqlPromotionAction
550
+ ?? normalizePromotionAction(current.dqlPromotion?.recommendedAction);
551
+ this.db.prepare(`
552
+ UPDATE notebook_research_runs
553
+ SET domain = ?, owner = ?, source_cell_id = ?, source_cell_name = ?, source_cell_fingerprint = ?, title = ?, question = ?,
554
+ intent = ?, context = ?, status = ?, summary = ?, recommendation = ?,
555
+ result_preview = ?, evidence = ?, research_plan = ?, generated_sql = ?, reviewed_sql = ?,
556
+ display = ?, context_pack_id = ?, route_decision = ?, warnings = ?,
557
+ review_status = ?, error = ?, draft_block_path = ?, dql_import_id = ?,
558
+ dql_candidate_ids = ?, dql_promotion_action = ?, dql_promotion = ?, updated_at = ?, last_run_at = ?
559
+ WHERE id = ?
560
+ `).run(nextDomain ?? null, input.owner === undefined ? (current.owner ?? null) : (cleanOptionalString(input.owner) ?? null), input.sourceCellId === undefined ? (current.sourceCellId ?? null) : (cleanOptionalString(input.sourceCellId) ?? null), input.sourceCellName === undefined ? (current.sourceCellName ?? null) : (cleanOptionalString(input.sourceCellName) ?? null), input.sourceCellFingerprint === undefined ? (current.sourceCellFingerprint ?? null) : (cleanOptionalString(input.sourceCellFingerprint) ?? null), cleanOptionalString(input.title) ?? current.title, cleanOptionalString(input.question) ?? current.question, input.intent ?? current.intent, json(nextContext), input.status ?? current.status, input.summary === undefined ? (current.summary ?? null) : (cleanOptionalString(input.summary) ?? null), input.recommendation === undefined ? (current.recommendation ?? null) : (cleanOptionalString(input.recommendation) ?? null), input.resultPreview === undefined ? json(current.resultPreview) : json(input.resultPreview), input.evidence === undefined ? json(current.evidence) : json(input.evidence), input.researchPlan === undefined ? json(current.researchPlan) : json(input.researchPlan), input.generatedSql === undefined ? (current.generatedSql ?? null) : (cleanOptionalString(input.generatedSql) ?? null), input.reviewedSql === undefined ? (current.reviewedSql ?? null) : (cleanOptionalString(input.reviewedSql) ?? null), input.display === undefined ? json(current.display) : json(input.display), input.contextPackId === undefined ? (current.contextPackId ?? null) : (cleanOptionalString(input.contextPackId) ?? null), input.routeDecision === undefined ? json(current.routeDecision) : json(input.routeDecision), input.warnings === undefined ? json(current.warnings) : json(input.warnings), input.reviewStatus ?? current.reviewStatus, input.error === undefined ? (current.error ?? null) : (cleanOptionalString(input.error) ?? null), nextDraftBlockPath ?? null, input.dqlImportId === undefined ? (current.dqlImportId ?? null) : (cleanOptionalString(input.dqlImportId) ?? null), input.dqlCandidateIds === undefined ? json(current.dqlCandidateIds) : json(input.dqlCandidateIds), nextPromotionAction ?? null, json(nextPromotion), now, input.lastRunAt === undefined ? (current.lastRunAt ?? null) : (cleanOptionalString(input.lastRunAt) ?? null), id);
561
+ const updated = this.getRun(id);
562
+ if (updated)
563
+ this.upsertSearchIndex(updated);
564
+ return updated;
565
+ }
566
+ markPromoted(id, input) {
567
+ return this.updateRun(id, {
568
+ reviewStatus: 'draft_created',
569
+ draftBlockPath: input.draftBlockPath,
570
+ dqlImportId: input.dqlImportId,
571
+ dqlCandidateIds: input.dqlCandidateIds,
572
+ dqlPromotionAction: normalizePromotionAction(input.dqlPromotion?.recommendedAction),
573
+ dqlPromotion: input.dqlPromotion,
574
+ });
575
+ }
576
+ seedRunsFromCells(input) {
577
+ const notebookPath = cleanOptionalString(input.notebookPath);
578
+ if (!notebookPath)
579
+ return { created: [], createdCount: 0, skippedCount: input.cells.length, limitApplied: false };
580
+ const limit = typeof input.limit === 'number' && Number.isFinite(input.limit)
581
+ ? Math.max(1, Math.min(1000, Math.floor(input.limit)))
582
+ : 1000;
583
+ const cells = Array.isArray(input.cells) ? input.cells : [];
584
+ const existingSourceIds = new Set(this.db.prepare(`
585
+ SELECT source_cell_id AS sourceCellId
586
+ FROM notebook_research_runs
587
+ WHERE notebook_path = ? AND source_cell_id IS NOT NULL AND TRIM(source_cell_id) <> ''
588
+ `).all(notebookPath)
589
+ .map((row) => cleanOptionalString(row.sourceCellId))
590
+ .filter((id) => Boolean(id)));
591
+ const existingSourceFingerprints = new Set(this.db.prepare(`
592
+ SELECT source_cell_fingerprint AS sourceCellFingerprint
593
+ FROM notebook_research_runs
594
+ WHERE notebook_path = ? AND source_cell_fingerprint IS NOT NULL AND TRIM(source_cell_fingerprint) <> ''
595
+ `).all(notebookPath)
596
+ .map((row) => cleanOptionalString(row.sourceCellFingerprint))
597
+ .filter((fingerprint) => Boolean(fingerprint)));
598
+ const created = [];
599
+ let skippedCount = Math.max(0, cells.length - limit);
600
+ for (const cell of cells.slice(0, limit)) {
601
+ const sourceCell = normalizeSourceCellInput(cell.sourceCell);
602
+ const sourceCellId = cleanOptionalString(cell.id) ?? cleanOptionalString(cell.sourceCellId) ?? sourceCell?.id;
603
+ const sql = cleanOptionalString(cell.sql) ?? cleanOptionalString(cell.content) ?? cleanOptionalString(cell.source) ?? sourceCell?.sql ?? sourceCell?.content ?? sourceCell?.source;
604
+ const sourceCellFingerprint = cleanOptionalString(cell.sourceCellFingerprint) ?? sourceCell?.fingerprint ?? fingerprintSql(sql);
605
+ if (!sourceCellId
606
+ || !sql
607
+ || existingSourceIds.has(sourceCellId)
608
+ || Boolean(sourceCellFingerprint && existingSourceFingerprints.has(sourceCellFingerprint))) {
609
+ skippedCount += 1;
610
+ continue;
611
+ }
612
+ const sourceCellName = cleanOptionalString(cell.name) ?? cleanOptionalString(cell.sourceCellName) ?? cleanOptionalString(cell.title) ?? sourceCell?.name ?? sourceCellId;
613
+ const question = cleanOptionalString(cell.question) ?? seedQuestionForCell(sourceCellName);
614
+ const run = this.createRun({
615
+ notebookPath,
616
+ domain: cleanOptionalString(input.domain),
617
+ owner: cleanOptionalString(input.owner),
618
+ sourceCellId,
619
+ sourceCellName,
620
+ title: cleanOptionalString(cell.title) ?? sourceCellName,
621
+ question,
622
+ intent: parseOptionalIntent(cell.intent),
623
+ context: {
624
+ notebookTitle: cleanOptionalString(input.notebookTitle),
625
+ sourceCell: {
626
+ id: sourceCellId,
627
+ name: sourceCellName,
628
+ type: cleanOptionalString(cell.type) ?? sourceCell?.type,
629
+ },
630
+ selectedDomain: cleanOptionalString(input.domain),
631
+ selectedOwner: cleanOptionalString(input.owner),
632
+ seededFromNotebook: true,
633
+ },
634
+ sourceCellFingerprint,
635
+ generatedSql: sql,
636
+ reviewedSql: sql,
637
+ });
638
+ existingSourceIds.add(sourceCellId);
639
+ if (sourceCellFingerprint)
640
+ existingSourceFingerprints.add(sourceCellFingerprint);
641
+ created.push(run);
642
+ }
643
+ return {
644
+ created,
645
+ createdCount: created.length,
646
+ skippedCount,
647
+ limitApplied: cells.length > limit,
648
+ };
649
+ }
650
+ getDiagnostics() {
651
+ const staleThresholdDays = 7;
652
+ const expiredThresholdDays = 30;
653
+ const staleCutoff = new Date(Date.now() - staleThresholdDays * 24 * 60 * 60 * 1000).toISOString();
654
+ const expiredCutoff = new Date(Date.now() - expiredThresholdDays * 24 * 60 * 60 * 1000).toISOString();
655
+ const counts = this.db.prepare(`
656
+ SELECT
657
+ COUNT(*) AS totalRuns,
658
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') THEN 1 ELSE 0 END) AS activeRuns,
659
+ SUM(CASE WHEN review_status IN ('completed', 'certified', 'rejected') THEN 1 ELSE 0 END) AS closedRuns,
660
+ COUNT(DISTINCT notebook_path) AS notebooks,
661
+ COUNT(DISTINCT COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized')) AS domains,
662
+ COUNT(DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'unassigned')) AS owners,
663
+ COUNT(CASE WHEN source_cell_id IS NOT NULL AND TRIM(source_cell_id) <> '' THEN 1 END) AS sourceLinkedRuns,
664
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpenRuns,
665
+ SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpenRuns,
666
+ MIN(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') THEN updated_at END) AS oldestOpenUpdatedAt,
667
+ MAX(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') THEN updated_at END) AS newestOpenUpdatedAt,
668
+ MIN(updated_at) AS oldestUpdatedAt,
669
+ MAX(updated_at) AS newestUpdatedAt
670
+ FROM notebook_research_runs
671
+ `).get(staleCutoff, expiredCutoff);
672
+ const totalRuns = counts?.totalRuns ?? 0;
673
+ const indexRows = this.searchIndexAvailable
674
+ ? this.scalarCount('SELECT COUNT(*) AS count FROM notebook_research_runs_fts')
675
+ : 0;
676
+ const indexVersion = this.searchIndexAvailable
677
+ ? this.getMeta('notebook_research_search_index_version')
678
+ : undefined;
679
+ const stale = this.searchIndexAvailable
680
+ ? indexRows !== totalRuns || indexVersion !== SEARCH_INDEX_VERSION
681
+ : false;
682
+ const warnings = [];
683
+ if (!this.searchIndexAvailable) {
684
+ warnings.push('Full-text search index is unavailable; research search falls back to local text scanning.');
685
+ }
686
+ else if (stale) {
687
+ warnings.push('Research search index is stale and will rebuild on the next storage initialization.');
688
+ }
689
+ if (totalRuns > 10_000) {
690
+ warnings.push('Research backlog is above 10,000 runs; use project, domain, next-action, and search filters before reviewing.');
691
+ }
692
+ if ((counts?.activeRuns ?? 0) > 500) {
693
+ warnings.push('Open research backlog is large; use Open next, next-action filters, and register snapshots for review handoff.');
694
+ }
695
+ if ((counts?.expiredOpenRuns ?? 0) > 0) {
696
+ warnings.push(`${counts?.expiredOpenRuns ?? 0} open research run(s) have not changed in ${expiredThresholdDays}+ days; revalidate or close stale investigations.`);
697
+ }
698
+ else if ((counts?.staleOpenRuns ?? 0) > 0) {
699
+ warnings.push(`${counts?.staleOpenRuns ?? 0} open research run(s) have not changed in ${staleThresholdDays}+ days; confirm they are still relevant.`);
700
+ }
701
+ return {
702
+ counts: {
703
+ totalRuns,
704
+ activeRuns: counts?.activeRuns ?? 0,
705
+ closedRuns: counts?.closedRuns ?? 0,
706
+ notebooks: counts?.notebooks ?? 0,
707
+ domains: counts?.domains ?? 0,
708
+ owners: counts?.owners ?? 0,
709
+ sourceLinkedRuns: counts?.sourceLinkedRuns ?? 0,
710
+ },
711
+ health: {
712
+ staleOpenRuns: counts?.staleOpenRuns ?? 0,
713
+ expiredOpenRuns: counts?.expiredOpenRuns ?? 0,
714
+ staleThresholdDays,
715
+ expiredThresholdDays,
716
+ oldestOpenUpdatedAt: optionalString(counts?.oldestOpenUpdatedAt),
717
+ newestOpenUpdatedAt: optionalString(counts?.newestOpenUpdatedAt),
718
+ },
719
+ search: {
720
+ indexed: this.searchIndexAvailable,
721
+ indexRows,
722
+ indexVersion,
723
+ stale,
724
+ },
725
+ updatedAt: {
726
+ oldest: optionalString(counts?.oldestUpdatedAt),
727
+ newest: optionalString(counts?.newestUpdatedAt),
728
+ },
729
+ limits: {
730
+ pageSize: 25,
731
+ maxPageSize: 500,
732
+ sourceCoverageLimit: 10_000,
733
+ seedCellLimit: 1_000,
734
+ },
735
+ warnings,
736
+ };
737
+ }
738
+ initSchema() {
739
+ this.db.exec(`
740
+ CREATE TABLE IF NOT EXISTS notebook_research_runs (
741
+ id TEXT PRIMARY KEY,
742
+ notebook_path TEXT NOT NULL,
743
+ domain TEXT,
744
+ owner TEXT,
745
+ source_cell_id TEXT,
746
+ source_cell_name TEXT,
747
+ source_cell_fingerprint TEXT,
748
+ title TEXT NOT NULL,
749
+ question TEXT NOT NULL,
750
+ intent TEXT NOT NULL,
751
+ context TEXT,
752
+ status TEXT NOT NULL,
753
+ summary TEXT,
754
+ recommendation TEXT,
755
+ result_preview TEXT,
756
+ evidence TEXT,
757
+ research_plan TEXT,
758
+ generated_sql TEXT,
759
+ reviewed_sql TEXT,
760
+ display TEXT,
761
+ context_pack_id TEXT,
762
+ route_decision TEXT,
763
+ warnings TEXT,
764
+ review_status TEXT NOT NULL,
765
+ error TEXT,
766
+ draft_block_path TEXT,
767
+ dql_import_id TEXT,
768
+ dql_candidate_ids TEXT,
769
+ dql_promotion_action TEXT,
770
+ dql_promotion TEXT,
771
+ created_at TEXT NOT NULL,
772
+ updated_at TEXT NOT NULL,
773
+ last_run_at TEXT
774
+ );
775
+
776
+ CREATE INDEX IF NOT EXISTS idx_notebook_research_path ON notebook_research_runs(notebook_path, updated_at);
777
+ CREATE INDEX IF NOT EXISTS idx_notebook_research_review ON notebook_research_runs(review_status);
778
+ CREATE INDEX IF NOT EXISTS idx_notebook_research_status ON notebook_research_runs(status);
779
+ CREATE INDEX IF NOT EXISTS idx_notebook_research_intent ON notebook_research_runs(intent, updated_at);
780
+ CREATE INDEX IF NOT EXISTS idx_notebook_research_source_cell ON notebook_research_runs(notebook_path, source_cell_id, updated_at);
781
+ CREATE INDEX IF NOT EXISTS idx_notebook_research_source_fingerprint ON notebook_research_runs(notebook_path, source_cell_fingerprint, updated_at);
782
+
783
+ CREATE TABLE IF NOT EXISTS notebook_research_meta (
784
+ key TEXT PRIMARY KEY,
785
+ value TEXT NOT NULL
786
+ );
787
+ `);
788
+ this.ensureColumn('notebook_research_runs', 'dql_promotion_action', 'TEXT');
789
+ this.ensureColumn('notebook_research_runs', 'dql_promotion', 'TEXT');
790
+ this.ensureColumn('notebook_research_runs', 'domain', 'TEXT');
791
+ this.ensureColumn('notebook_research_runs', 'owner', 'TEXT');
792
+ this.ensureColumn('notebook_research_runs', 'source_cell_fingerprint', 'TEXT');
793
+ this.ensureColumn('notebook_research_runs', 'research_plan', 'TEXT');
794
+ this.backfillDomains();
795
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_notebook_research_promotion_action ON notebook_research_runs(dql_promotion_action)');
796
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_notebook_research_domain ON notebook_research_runs(domain, updated_at)');
797
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_notebook_research_source_fingerprint ON notebook_research_runs(notebook_path, source_cell_fingerprint, updated_at)');
798
+ this.initSearchIndex();
799
+ }
800
+ ensureColumn(table, column, definition) {
801
+ const rows = this.db.prepare(`PRAGMA table_info(${table})`).all();
802
+ if (rows.some((row) => row.name === column))
803
+ return;
804
+ this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
805
+ }
806
+ backfillDomains() {
807
+ const rows = this.db.prepare(`
808
+ SELECT id, notebook_path, context, draft_block_path, dql_promotion
809
+ FROM notebook_research_runs
810
+ WHERE domain IS NULL OR TRIM(domain) = ''
811
+ `).all();
812
+ const update = this.db.prepare('UPDATE notebook_research_runs SET domain = ? WHERE id = ?');
813
+ for (const row of rows) {
814
+ const domain = inferResearchDomain({
815
+ context: parseJson(row.context),
816
+ dqlPromotion: parseDqlPromotion(parseJson(row.dql_promotion)),
817
+ draftBlockPath: optionalString(row.draft_block_path),
818
+ notebookPath: optionalString(row.notebook_path),
819
+ });
820
+ if (domain)
821
+ update.run(domain, row.id);
822
+ }
823
+ }
824
+ initSearchIndex() {
825
+ try {
826
+ this.dropSearchIndexIfMissingColumn('owner');
827
+ this.db.exec(`
828
+ CREATE VIRTUAL TABLE IF NOT EXISTS notebook_research_runs_fts USING fts5(
829
+ id UNINDEXED,
830
+ notebook_path,
831
+ domain,
832
+ owner,
833
+ source_cell_name,
834
+ title,
835
+ question,
836
+ intent,
837
+ summary,
838
+ recommendation,
839
+ warnings,
840
+ draft_block_path,
841
+ dql_promotion_action,
842
+ generated_sql,
843
+ reviewed_sql,
844
+ context_text,
845
+ evidence_text,
846
+ research_plan_text,
847
+ promotion_text,
848
+ tokenize = 'unicode61'
849
+ );
850
+ `);
851
+ this.searchIndexAvailable = true;
852
+ this.ensureSearchIndexFresh();
853
+ }
854
+ catch {
855
+ this.searchIndexAvailable = false;
856
+ }
857
+ }
858
+ ensureSearchIndexFresh() {
859
+ if (!this.searchIndexAvailable)
860
+ return;
861
+ const version = this.getMeta('notebook_research_search_index_version');
862
+ const runCount = this.scalarCount('SELECT COUNT(*) AS count FROM notebook_research_runs');
863
+ const indexCount = this.scalarCount('SELECT COUNT(*) AS count FROM notebook_research_runs_fts');
864
+ if (version === SEARCH_INDEX_VERSION && runCount === indexCount)
865
+ return;
866
+ this.rebuildSearchIndex();
867
+ }
868
+ rebuildSearchIndex() {
869
+ if (!this.searchIndexAvailable)
870
+ return;
871
+ this.db.prepare('DELETE FROM notebook_research_runs_fts').run();
872
+ const rows = this.db.prepare('SELECT * FROM notebook_research_runs ORDER BY rowid ASC').all();
873
+ const insert = this.searchIndexInsertStatement();
874
+ const transaction = this.db.transaction((items) => {
875
+ for (const row of items) {
876
+ insert.run(...searchIndexValues(rowToRun(row)));
877
+ }
878
+ });
879
+ transaction(rows);
880
+ this.setMeta('notebook_research_search_index_version', SEARCH_INDEX_VERSION);
881
+ }
882
+ upsertSearchIndex(run) {
883
+ if (!this.searchIndexAvailable)
884
+ return;
885
+ try {
886
+ this.db.prepare('DELETE FROM notebook_research_runs_fts WHERE id = ?').run(run.id);
887
+ this.searchIndexInsertStatement().run(...searchIndexValues(run));
888
+ this.setMeta('notebook_research_search_index_version', SEARCH_INDEX_VERSION);
889
+ }
890
+ catch {
891
+ this.searchIndexAvailable = false;
892
+ }
893
+ }
894
+ searchIndexInsertStatement() {
895
+ return this.db.prepare(`
896
+ INSERT INTO notebook_research_runs_fts (
897
+ id, notebook_path, domain, owner, source_cell_name, title, question, intent, summary, recommendation,
898
+ warnings, draft_block_path, dql_promotion_action, generated_sql, reviewed_sql,
899
+ context_text, evidence_text, research_plan_text, promotion_text
900
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
901
+ `);
902
+ }
903
+ dropSearchIndexIfMissingColumn(column) {
904
+ const exists = this.db.prepare(`
905
+ SELECT name
906
+ FROM sqlite_master
907
+ WHERE type = 'table' AND name = 'notebook_research_runs_fts'
908
+ `).get();
909
+ if (!exists)
910
+ return;
911
+ const rows = this.db.prepare('PRAGMA table_info(notebook_research_runs_fts)').all();
912
+ if (rows.some((row) => row.name === column))
913
+ return;
914
+ this.db.prepare('DROP TABLE notebook_research_runs_fts').run();
915
+ }
916
+ scalarCount(sql) {
917
+ return this.db.prepare(sql).get()?.count ?? 0;
918
+ }
919
+ getMeta(key) {
920
+ return optionalString(this.db.prepare('SELECT value FROM notebook_research_meta WHERE key = ?').get(key)?.value);
921
+ }
922
+ setMeta(key, value) {
923
+ this.db.prepare(`
924
+ INSERT INTO notebook_research_meta (key, value)
925
+ VALUES (?, ?)
926
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
927
+ `).run(key, value);
928
+ }
929
+ }
930
+ function rowToRun(row) {
931
+ return {
932
+ id: String(row.id),
933
+ notebookPath: String(row.notebook_path),
934
+ domain: optionalString(row.domain) ?? inferResearchDomain({
935
+ context: parseJson(row.context),
936
+ dqlPromotion: parseDqlPromotion(parseJson(row.dql_promotion)),
937
+ draftBlockPath: optionalString(row.draft_block_path),
938
+ notebookPath: optionalString(row.notebook_path),
939
+ }),
940
+ owner: optionalString(row.owner),
941
+ sourceCellId: optionalString(row.source_cell_id),
942
+ sourceCellName: optionalString(row.source_cell_name),
943
+ sourceCellFingerprint: optionalString(row.source_cell_fingerprint),
944
+ title: String(row.title),
945
+ question: String(row.question),
946
+ intent: parseIntent(row.intent),
947
+ context: parseJson(row.context),
948
+ status: parseStatus(row.status),
949
+ summary: optionalString(row.summary),
950
+ recommendation: optionalString(row.recommendation),
951
+ resultPreview: parseJson(row.result_preview),
952
+ evidence: parseJson(row.evidence),
953
+ researchPlan: parseResearchPlan(parseJson(row.research_plan)),
954
+ generatedSql: optionalString(row.generated_sql),
955
+ reviewedSql: optionalString(row.reviewed_sql),
956
+ display: parseJson(row.display),
957
+ contextPackId: optionalString(row.context_pack_id),
958
+ routeDecision: parseJson(row.route_decision),
959
+ warnings: stringArray(parseJson(row.warnings)),
960
+ reviewStatus: parseReviewStatus(row.review_status),
961
+ error: optionalString(row.error),
962
+ draftBlockPath: optionalString(row.draft_block_path),
963
+ dqlImportId: optionalString(row.dql_import_id),
964
+ dqlCandidateIds: stringArray(parseJson(row.dql_candidate_ids)),
965
+ dqlPromotionAction: normalizePromotionAction(row.dql_promotion_action),
966
+ dqlPromotion: parseDqlPromotion(parseJson(row.dql_promotion)),
967
+ createdAt: String(row.created_at),
968
+ updatedAt: String(row.updated_at),
969
+ lastRunAt: optionalString(row.last_run_at),
970
+ };
971
+ }
972
+ function inferIntent(question) {
973
+ const lower = question.toLowerCase();
974
+ if (/\b(trust|rely|certif|lineage|owner|caveat|gap)\b/.test(lower))
975
+ return 'trust_gap_review';
976
+ if (/\b(anomal|exception|outlier|spike|dip)\b/.test(lower))
977
+ return 'anomaly_investigation';
978
+ if (/\b(compare|versus| vs |segment|cohort)\b/.test(lower))
979
+ return 'segment_compare';
980
+ if (/\b(why|changed|change|drop|decline|increase|decrease|month|week|quarter)\b/.test(lower))
981
+ return 'diagnose_change';
982
+ if (/\b(driver|drove|break down|breakdown|contribute|top mover|movers)\b/.test(lower))
983
+ return 'driver_breakdown';
984
+ if (/\b(profile|detail|drill|customer|account|user|client|merchant|product|player|team)\b/.test(lower))
985
+ return 'entity_drilldown';
986
+ return 'ad_hoc_analysis';
987
+ }
988
+ function parseIntent(value) {
989
+ if (value === 'ad_hoc_analysis'
990
+ || value === 'diagnose_change'
991
+ || value === 'driver_breakdown'
992
+ || value === 'segment_compare'
993
+ || value === 'entity_drilldown'
994
+ || value === 'anomaly_investigation'
995
+ || value === 'trust_gap_review')
996
+ return value;
997
+ return 'ad_hoc_analysis';
998
+ }
999
+ function notebookTitleFromPath(path) {
1000
+ const file = path.split(/[\\/]/).pop() ?? path;
1001
+ return file
1002
+ .replace(/\.dqlnb$/i, '')
1003
+ .replace(/[-_]+/g, ' ')
1004
+ .replace(/\s+/g, ' ')
1005
+ .trim()
1006
+ || 'Untitled notebook';
1007
+ }
1008
+ function parseOptionalIntent(value) {
1009
+ if (value === 'ad_hoc_analysis'
1010
+ || value === 'diagnose_change'
1011
+ || value === 'driver_breakdown'
1012
+ || value === 'segment_compare'
1013
+ || value === 'entity_drilldown'
1014
+ || value === 'anomaly_investigation'
1015
+ || value === 'trust_gap_review')
1016
+ return value;
1017
+ return undefined;
1018
+ }
1019
+ function parseStatus(value) {
1020
+ return value === 'running' || value === 'ready' || value === 'error' ? value : 'draft';
1021
+ }
1022
+ function parseReviewStatus(value) {
1023
+ return value === 'draft_created' || value === 'completed' || value === 'certified' || value === 'rejected' ? value : 'needs_review';
1024
+ }
1025
+ function normalizePromotionAction(value) {
1026
+ return value === 'reuse_existing'
1027
+ || value === 'extend_existing'
1028
+ || value === 'create_replacement'
1029
+ || value === 'create_new'
1030
+ || value === 'review_required'
1031
+ ? value
1032
+ : undefined;
1033
+ }
1034
+ function normalizeReadinessFilter(value) {
1035
+ return value === 'draft_ready' || value === 'certification_ready' || value === 'blocked' ? value : undefined;
1036
+ }
1037
+ function normalizeAgeFilter(value) {
1038
+ return value === 'stale_open' || value === 'expired_open' ? value : undefined;
1039
+ }
1040
+ function normalizeNextActionFilter(value) {
1041
+ return value === 'fix_blockers'
1042
+ || value === 'review_sql'
1043
+ || value === 'review_context'
1044
+ || value === 'run_preview'
1045
+ || value === 'reuse_existing'
1046
+ || value === 'create_dql_draft'
1047
+ || value === 'open_certification'
1048
+ || value === 'complete_review'
1049
+ || value === 'continue_review'
1050
+ ? value
1051
+ : undefined;
1052
+ }
1053
+ function normalizeSort(value) {
1054
+ return value === 'updated_desc' ? 'updated_desc' : 'priority';
1055
+ }
1056
+ function titleFromQuestion(question) {
1057
+ const clean = question.replace(/\s+/g, ' ').trim();
1058
+ return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean || 'Notebook research';
1059
+ }
1060
+ function seedQuestionForCell(sourceCellName) {
1061
+ const cleanName = sourceCellName.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
1062
+ return `What reusable business logic does ${cleanName || 'this query'} represent, and should it become a DQL block?`;
1063
+ }
1064
+ function normalizeSourceCellInput(value) {
1065
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1066
+ return null;
1067
+ const source = value;
1068
+ const id = cleanOptionalString(source.id)
1069
+ ?? cleanOptionalString(source.sourceCellId)
1070
+ ?? cleanOptionalString(source.cellId);
1071
+ const name = cleanOptionalString(source.name)
1072
+ ?? cleanOptionalString(source.sourceCellName)
1073
+ ?? cleanOptionalString(source.title);
1074
+ const fingerprint = cleanOptionalString(source.fingerprint)
1075
+ ?? cleanOptionalString(source.sourceCellFingerprint)
1076
+ ?? cleanOptionalString(source.sqlFingerprint);
1077
+ const type = cleanOptionalString(source.type);
1078
+ const sql = cleanOptionalString(source.sql);
1079
+ const content = cleanOptionalString(source.content);
1080
+ const sourceText = cleanOptionalString(source.source);
1081
+ if (!id && !name && !fingerprint && !type && !sql && !content && !sourceText)
1082
+ return null;
1083
+ return { id, name, fingerprint, type, sql, content, source: sourceText };
1084
+ }
1085
+ function normalizeSourceCoverageCells(query, limit) {
1086
+ const byId = new Map();
1087
+ for (const id of Array.isArray(query.sourceCellIds) ? query.sourceCellIds : []) {
1088
+ const cleanId = cleanOptionalString(id);
1089
+ if (cleanId && !byId.has(cleanId))
1090
+ byId.set(cleanId, { id: cleanId });
1091
+ }
1092
+ for (const rawCell of Array.isArray(query.sourceCells) ? query.sourceCells : []) {
1093
+ const sourceCell = normalizeSourceCellInput(rawCell);
1094
+ const id = sourceCell?.id;
1095
+ if (!id)
1096
+ continue;
1097
+ const current = byId.get(id) ?? { id };
1098
+ byId.set(id, {
1099
+ id,
1100
+ name: current.name ?? sourceCell.name,
1101
+ fingerprint: current.fingerprint ?? sourceCell.fingerprint,
1102
+ });
1103
+ }
1104
+ return Array.from(byId.values()).slice(0, limit);
1105
+ }
1106
+ function fingerprintSql(sql) {
1107
+ const normalized = sql?.replace(/\s+/g, ' ').trim();
1108
+ if (!normalized)
1109
+ return undefined;
1110
+ let hash = 2166136261;
1111
+ for (let index = 0; index < normalized.length; index += 1) {
1112
+ hash ^= normalized.charCodeAt(index);
1113
+ hash = Math.imul(hash, 16777619);
1114
+ }
1115
+ return `fnv1a:${(hash >>> 0).toString(16).padStart(8, '0')}`;
1116
+ }
1117
+ function normalizeListQuery(query) {
1118
+ if (typeof query === 'string') {
1119
+ return { notebookPath: cleanOptionalString(query), offset: 0 };
1120
+ }
1121
+ const limit = typeof query?.limit === 'number' && Number.isFinite(query.limit)
1122
+ ? Math.max(1, Math.min(500, Math.floor(query.limit)))
1123
+ : undefined;
1124
+ const offset = typeof query?.offset === 'number' && Number.isFinite(query.offset)
1125
+ ? Math.max(0, Math.floor(query.offset))
1126
+ : 0;
1127
+ return {
1128
+ notebookPath: cleanOptionalString(query?.notebookPath),
1129
+ sourceCellId: cleanOptionalString(query?.sourceCellId),
1130
+ domain: cleanOptionalString(query?.domain),
1131
+ owner: cleanOptionalString(query?.owner),
1132
+ intent: parseOptionalIntent(query?.intent),
1133
+ search: cleanOptionalString(query?.search),
1134
+ status: query?.status,
1135
+ reviewStatus: query?.reviewStatus,
1136
+ promotionAction: normalizePromotionAction(query?.promotionAction),
1137
+ readiness: normalizeReadinessFilter(query?.readiness),
1138
+ age: normalizeAgeFilter(query?.age),
1139
+ nextAction: normalizeNextActionFilter(query?.nextAction),
1140
+ activeOnly: query?.activeOnly === true,
1141
+ sort: normalizeSort(query?.sort),
1142
+ limit,
1143
+ offset,
1144
+ };
1145
+ }
1146
+ function researchListWhere(query, options = {}) {
1147
+ const clauses = [];
1148
+ const params = [];
1149
+ if (query.notebookPath) {
1150
+ clauses.push('notebook_path = ?');
1151
+ params.push(query.notebookPath);
1152
+ }
1153
+ if (query.sourceCellId) {
1154
+ clauses.push('source_cell_id = ?');
1155
+ params.push(query.sourceCellId);
1156
+ }
1157
+ if (query.domain) {
1158
+ if (query.domain.toLowerCase() === 'uncategorized') {
1159
+ clauses.push("(domain IS NULL OR TRIM(domain) = '')");
1160
+ }
1161
+ else {
1162
+ clauses.push('LOWER(domain) = ?');
1163
+ params.push(query.domain.toLowerCase());
1164
+ }
1165
+ }
1166
+ if (query.owner) {
1167
+ clauses.push('LOWER(owner) = ?');
1168
+ params.push(query.owner.toLowerCase());
1169
+ }
1170
+ if (query.intent) {
1171
+ clauses.push('intent = ?');
1172
+ params.push(query.intent);
1173
+ }
1174
+ if (query.status) {
1175
+ clauses.push('status = ?');
1176
+ params.push(query.status);
1177
+ }
1178
+ if (query.reviewStatus) {
1179
+ clauses.push('review_status = ?');
1180
+ params.push(query.reviewStatus);
1181
+ }
1182
+ if (query.activeOnly) {
1183
+ clauses.push("review_status NOT IN ('completed', 'certified', 'rejected')");
1184
+ }
1185
+ if (query.promotionAction) {
1186
+ clauses.push('dql_promotion_action = ?');
1187
+ params.push(query.promotionAction);
1188
+ }
1189
+ if (query.readiness === 'draft_ready') {
1190
+ clauses.push(`(${DRAFT_READY_SQL})`);
1191
+ }
1192
+ else if (query.readiness === 'certification_ready') {
1193
+ clauses.push(`(${CERTIFICATION_READY_SQL})`);
1194
+ }
1195
+ else if (query.readiness === 'blocked') {
1196
+ clauses.push(`(${BLOCKED_SQL})`);
1197
+ }
1198
+ if (query.age === 'stale_open' || query.age === 'expired_open') {
1199
+ const thresholdDays = query.age === 'expired_open' ? 30 : 7;
1200
+ const cutoff = new Date(Date.now() - thresholdDays * 24 * 60 * 60 * 1000).toISOString();
1201
+ clauses.push("review_status NOT IN ('completed', 'certified', 'rejected')");
1202
+ clauses.push('updated_at <= ?');
1203
+ params.push(cutoff);
1204
+ }
1205
+ if (query.nextAction) {
1206
+ clauses.push(`(${NEXT_ACTION_SQL}) = ?`);
1207
+ params.push(query.nextAction);
1208
+ }
1209
+ if (query.search) {
1210
+ const ftsQuery = options.useSearchIndex ? searchIndexQuery(query.search) : undefined;
1211
+ if (ftsQuery) {
1212
+ clauses.push(`id IN (
1213
+ SELECT id
1214
+ FROM notebook_research_runs_fts
1215
+ WHERE notebook_research_runs_fts MATCH ?
1216
+ )`);
1217
+ params.push(ftsQuery);
1218
+ }
1219
+ else {
1220
+ const fallback = researchSearchFallback(query.search);
1221
+ clauses.push(fallback.sql);
1222
+ params.push(...fallback.params);
1223
+ }
1224
+ }
1225
+ return {
1226
+ whereSql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
1227
+ params,
1228
+ };
1229
+ }
1230
+ function searchIndexQuery(search) {
1231
+ const tokens = researchSearchTokens(search);
1232
+ if (!tokens?.length)
1233
+ return undefined;
1234
+ return tokens.map((token) => `${token}*`).join(' AND ');
1235
+ }
1236
+ function researchSearchTokens(search) {
1237
+ const tokens = search
1238
+ .toLowerCase()
1239
+ .match(/[a-z0-9]+/g)
1240
+ ?.filter((token) => token.length >= 2)
1241
+ .slice(0, 12) ?? [];
1242
+ return Array.from(new Set(tokens));
1243
+ }
1244
+ const RESEARCH_SEARCH_FALLBACK_FIELDS = [
1245
+ 'LOWER(title)',
1246
+ 'LOWER(question)',
1247
+ "LOWER(COALESCE(domain, ''))",
1248
+ "LOWER(COALESCE(owner, ''))",
1249
+ 'LOWER(intent)',
1250
+ "LOWER(COALESCE(source_cell_id, ''))",
1251
+ "LOWER(COALESCE(source_cell_name, ''))",
1252
+ "LOWER(COALESCE(source_cell_fingerprint, ''))",
1253
+ 'LOWER(notebook_path)',
1254
+ "LOWER(COALESCE(summary, ''))",
1255
+ "LOWER(COALESCE(recommendation, ''))",
1256
+ "LOWER(COALESCE(warnings, ''))",
1257
+ "LOWER(COALESCE(draft_block_path, ''))",
1258
+ "LOWER(COALESCE(dql_promotion_action, ''))",
1259
+ "LOWER(COALESCE(generated_sql, ''))",
1260
+ "LOWER(COALESCE(reviewed_sql, ''))",
1261
+ "LOWER(COALESCE(context, ''))",
1262
+ "LOWER(COALESCE(result_preview, ''))",
1263
+ "LOWER(COALESCE(evidence, ''))",
1264
+ "LOWER(COALESCE(research_plan, ''))",
1265
+ "LOWER(COALESCE(context_pack_id, ''))",
1266
+ "LOWER(COALESCE(route_decision, ''))",
1267
+ "LOWER(COALESCE(display, ''))",
1268
+ "LOWER(COALESCE(dql_import_id, ''))",
1269
+ "LOWER(COALESCE(dql_candidate_ids, ''))",
1270
+ "LOWER(COALESCE(dql_promotion, ''))",
1271
+ "LOWER(COALESCE(error, ''))",
1272
+ ];
1273
+ function researchSearchFallback(search) {
1274
+ const terms = researchSearchTokens(search);
1275
+ const needles = terms.length ? terms : [search.trim().toLowerCase()].filter(Boolean);
1276
+ const clauses = needles.map(() => `(${RESEARCH_SEARCH_FALLBACK_FIELDS.map((field) => `${field} LIKE ? ESCAPE '\\'`).join(' OR ')})`);
1277
+ return {
1278
+ sql: `(${clauses.join(' AND ')})`,
1279
+ params: needles.flatMap((term) => RESEARCH_SEARCH_FALLBACK_FIELDS.map(() => `%${escapeLike(term)}%`)),
1280
+ };
1281
+ }
1282
+ function escapeLike(value) {
1283
+ return value.replace(/[\\%_]/g, (match) => `\\${match}`);
1284
+ }
1285
+ function searchIndexValues(run) {
1286
+ return [
1287
+ run.id,
1288
+ run.notebookPath,
1289
+ run.domain ?? '',
1290
+ run.owner ?? '',
1291
+ run.sourceCellName ?? '',
1292
+ run.title,
1293
+ run.question,
1294
+ run.intent,
1295
+ run.summary ?? '',
1296
+ run.recommendation ?? '',
1297
+ run.warnings.join(' '),
1298
+ run.draftBlockPath ?? '',
1299
+ run.dqlPromotionAction ?? '',
1300
+ run.generatedSql ?? '',
1301
+ run.reviewedSql ?? '',
1302
+ searchTextFromValue({
1303
+ sourceCellId: run.sourceCellId,
1304
+ sourceCellFingerprint: run.sourceCellFingerprint,
1305
+ context: run.context,
1306
+ resultPreview: run.resultPreview,
1307
+ display: run.display,
1308
+ }),
1309
+ searchTextFromValue({
1310
+ evidence: run.evidence,
1311
+ contextPackId: run.contextPackId,
1312
+ routeDecision: run.routeDecision,
1313
+ error: run.error,
1314
+ }),
1315
+ searchTextFromValue(run.researchPlan),
1316
+ searchTextFromValue(run.dqlPromotion),
1317
+ ];
1318
+ }
1319
+ function searchTextFromValue(value) {
1320
+ if (value === undefined || value === null)
1321
+ return '';
1322
+ if (typeof value === 'string')
1323
+ return value.slice(0, 20_000);
1324
+ if (typeof value === 'number' || typeof value === 'boolean')
1325
+ return String(value);
1326
+ try {
1327
+ return JSON.stringify(value).slice(0, 20_000);
1328
+ }
1329
+ catch {
1330
+ return '';
1331
+ }
1332
+ }
1333
+ function cleanOptionalString(value) {
1334
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
1335
+ }
1336
+ function inferResearchDomain(input) {
1337
+ const contextDomain = domainFromContext(input.context);
1338
+ if (contextDomain)
1339
+ return contextDomain;
1340
+ const promotedDomain = input.dqlPromotion?.candidates.find((candidate) => candidate.domain)?.domain;
1341
+ if (promotedDomain)
1342
+ return cleanOptionalString(promotedDomain);
1343
+ const draftDomain = domainFromPath(input.draftBlockPath);
1344
+ if (draftDomain)
1345
+ return draftDomain;
1346
+ return domainFromPath(input.notebookPath);
1347
+ }
1348
+ function domainFromContext(value) {
1349
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1350
+ return undefined;
1351
+ const record = value;
1352
+ return cleanOptionalString(record.selectedDomain)
1353
+ ?? cleanOptionalString(record.domain)
1354
+ ?? domainFromContext(record.block)
1355
+ ?? domainFromContext(record.notebook)
1356
+ ?? domainFromContext(record.metadata);
1357
+ }
1358
+ function domainFromPath(path) {
1359
+ if (!path)
1360
+ return undefined;
1361
+ const domainLayout = path.match(/(?:^|\/)domains\/([^/]+)/);
1362
+ if (domainLayout?.[1])
1363
+ return cleanOptionalString(domainLayout[1]);
1364
+ const draftLayout = path.match(/(?:^|\/)_drafts\/([^/]+)/);
1365
+ if (draftLayout?.[1])
1366
+ return cleanOptionalString(draftLayout[1]);
1367
+ return undefined;
1368
+ }
1369
+ function optionalString(value) {
1370
+ return typeof value === 'string' && value.trim() ? value : undefined;
1371
+ }
1372
+ function json(value) {
1373
+ return value === undefined ? null : JSON.stringify(value);
1374
+ }
1375
+ function parseJson(value) {
1376
+ if (typeof value !== 'string' || !value.trim())
1377
+ return undefined;
1378
+ try {
1379
+ return JSON.parse(value);
1380
+ }
1381
+ catch {
1382
+ return undefined;
1383
+ }
1384
+ }
1385
+ function stringArray(value) {
1386
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
1387
+ }
1388
+ function parseDqlPromotion(value) {
1389
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1390
+ return undefined;
1391
+ const record = value;
1392
+ const importId = optionalString(record.importId);
1393
+ if (!importId)
1394
+ return undefined;
1395
+ return {
1396
+ importId,
1397
+ candidateIds: stringArray(record.candidateIds),
1398
+ draftBlockPath: optionalString(record.draftBlockPath),
1399
+ recommendedAction: optionalString(record.recommendedAction),
1400
+ similarityMatches: parsePromotionMatches(record.similarityMatches),
1401
+ candidates: parsePromotionCandidates(record.candidates),
1402
+ createdAt: optionalString(record.createdAt) ?? new Date(0).toISOString(),
1403
+ };
1404
+ }
1405
+ function parsePromotionCandidates(value) {
1406
+ if (!Array.isArray(value))
1407
+ return [];
1408
+ return value.flatMap((item) => {
1409
+ if (!item || typeof item !== 'object' || Array.isArray(item))
1410
+ return [];
1411
+ const record = item;
1412
+ const id = optionalString(record.id);
1413
+ const name = optionalString(record.name);
1414
+ if (!id || !name)
1415
+ return [];
1416
+ return [{
1417
+ id,
1418
+ name,
1419
+ domain: optionalString(record.domain),
1420
+ draftPath: optionalString(record.draftPath),
1421
+ savedPath: optionalString(record.savedPath),
1422
+ reviewStatus: optionalString(record.reviewStatus),
1423
+ recommendedAction: optionalString(record.recommendedAction),
1424
+ similarityMatches: parsePromotionMatches(record.similarityMatches),
1425
+ parameterPolicy: parseParameterPolicy(record.parameterPolicy),
1426
+ allowedFilters: stringArray(record.allowedFilters),
1427
+ warnings: stringArray(record.warnings),
1428
+ }];
1429
+ });
1430
+ }
1431
+ function parsePromotionMatches(value) {
1432
+ if (!Array.isArray(value))
1433
+ return [];
1434
+ return value.flatMap((item) => {
1435
+ if (!item || typeof item !== 'object' || Array.isArray(item))
1436
+ return [];
1437
+ const record = item;
1438
+ const name = optionalString(record.name);
1439
+ const reason = optionalString(record.reason);
1440
+ if (!name || !reason)
1441
+ return [];
1442
+ const score = typeof record.score === 'number' && Number.isFinite(record.score) ? record.score : 0;
1443
+ return [{
1444
+ kind: optionalString(record.kind) ?? 'near_variant',
1445
+ objectKey: optionalString(record.objectKey),
1446
+ name,
1447
+ status: optionalString(record.status),
1448
+ source: optionalString(record.source),
1449
+ score,
1450
+ reason,
1451
+ recommendedAction: optionalString(record.recommendedAction) ?? 'review_required',
1452
+ }];
1453
+ });
1454
+ }
1455
+ function parseParameterPolicy(value) {
1456
+ if (!Array.isArray(value))
1457
+ return [];
1458
+ return value.flatMap((item) => {
1459
+ if (!item || typeof item !== 'object' || Array.isArray(item))
1460
+ return [];
1461
+ const record = item;
1462
+ const name = optionalString(record.name);
1463
+ const policy = optionalString(record.policy);
1464
+ return name && policy ? [{ name, policy }] : [];
1465
+ });
1466
+ }
1467
+ function parseResearchPlan(value) {
1468
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1469
+ return undefined;
1470
+ const record = value;
1471
+ const evidence = record.evidence && typeof record.evidence === 'object' && !Array.isArray(record.evidence)
1472
+ ? record.evidence
1473
+ : {};
1474
+ const preview = record.preview && typeof record.preview === 'object' && !Array.isArray(record.preview)
1475
+ ? record.preview
1476
+ : {};
1477
+ const promotion = record.promotion && typeof record.promotion === 'object' && !Array.isArray(record.promotion)
1478
+ ? record.promotion
1479
+ : {};
1480
+ const sqlState = record.sqlState === 'generated' || record.sqlState === 'reviewed' ? record.sqlState : 'missing';
1481
+ const previewStatus = preview.status === 'ready' || preview.status === 'error' ? preview.status : 'not_run';
1482
+ const promotionPath = parseResearchPlanPromotionPath(promotion.path);
1483
+ const rowCount = typeof preview.rowCount === 'number' && Number.isFinite(preview.rowCount) ? preview.rowCount : undefined;
1484
+ return {
1485
+ sqlState,
1486
+ grain: optionalString(record.grain),
1487
+ parameterPolicy: parseParameterPolicy(record.parameterPolicy),
1488
+ allowedFilters: stringArray(record.allowedFilters),
1489
+ evidence: {
1490
+ trustLabel: optionalString(evidence.trustLabel),
1491
+ contextPackId: optionalString(evidence.contextPackId),
1492
+ evidenceCount: finiteNumber(evidence.evidenceCount),
1493
+ relationCount: finiteNumber(evidence.relationCount),
1494
+ missingContextCount: finiteNumber(evidence.missingContextCount),
1495
+ },
1496
+ preview: {
1497
+ status: previewStatus,
1498
+ ...(rowCount === undefined ? {} : { rowCount }),
1499
+ },
1500
+ promotion: {
1501
+ path: promotionPath,
1502
+ duplicateDecision: optionalString(promotion.duplicateDecision),
1503
+ },
1504
+ reviewFocus: stringArray(record.reviewFocus),
1505
+ generatedAt: optionalString(record.generatedAt) ?? new Date(0).toISOString(),
1506
+ };
1507
+ }
1508
+ function parseResearchPlanPromotionPath(value) {
1509
+ return value === 'review_context'
1510
+ || value === 'run_preview'
1511
+ || value === 'reuse_existing'
1512
+ || value === 'create_dql_draft'
1513
+ || value === 'open_certification'
1514
+ || value === 'complete_review'
1515
+ ? value
1516
+ : 'needs_sql';
1517
+ }
1518
+ function finiteNumber(value) {
1519
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
1520
+ }
1521
+ //# sourceMappingURL=local-notebook-research-storage.js.map