@duckcodeailabs/dql-cli 1.11.5 → 1.11.6

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.
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, s
5
5
  import { createRequire } from "node:module";
6
6
  import { homedir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
- import { dirname, extname, join, normalize, relative, resolve, } from "node:path";
8
+ import { dirname, extname, join, normalize, relative, resolve, sep, } from "node:path";
9
9
  import { Readable } from "node:stream";
10
10
  import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, planMixedSourceNotebookSql, planMixedSourceSql, } from "./mixed-source-sql.js";
11
11
  import { resolveNpmInvocation } from './npm-runtime.js';
@@ -19,7 +19,7 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
19
19
  import { resolveRetrievalHealthStatus } from './retrieval-health.js';
20
20
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
21
21
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
22
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, } from '@duckcodeailabs/dql-agent';
22
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordCorrectionTrace, listHintsFromGit, evaluateHint, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, } from '@duckcodeailabs/dql-agent';
23
23
  import { gatherProposeEnrichment } from './propose-enrich.js';
24
24
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
25
25
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -6359,6 +6359,128 @@ export async function startLocalServer(opts) {
6359
6359
  // the approval, so the derived candidate is approved immediately unless the
6360
6360
  // caller opts out. Advisory only: never overrides certified routing. The
6361
6361
  // multi-tenant review workflow + automated distillation stay a cloud feature.
6362
+ // Hint review queue. Corrections are captured as CANDIDATES; a hint only
6363
+ // starts shaping other people's answers once a human approves it here.
6364
+ if (req.method === 'GET' && path === '/api/agent/hints') {
6365
+ try {
6366
+ const status = url.searchParams.get('status');
6367
+ const all = listHintsFromGit(projectRoot);
6368
+ const hints = status ? all.filter((hint) => hint.status === status) : all;
6369
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6370
+ res.end(serializeJSON({
6371
+ hints: hints.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).slice(0, 200),
6372
+ counts: {
6373
+ candidate: all.filter((hint) => hint.status === 'candidate').length,
6374
+ approved: all.filter((hint) => hint.status === 'approved').length,
6375
+ rejected: all.filter((hint) => hint.status === 'rejected').length,
6376
+ },
6377
+ }));
6378
+ }
6379
+ catch (error) {
6380
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6381
+ res.end(serializeJSON({ hints: [], counts: { candidate: 0, approved: 0, rejected: 0 }, error: error instanceof Error ? error.message : String(error) }));
6382
+ }
6383
+ return;
6384
+ }
6385
+ const hintReviewMatch = /^\/api\/agent\/hints\/([^/]+)\/review$/.exec(path);
6386
+ if (req.method === 'POST' && hintReviewMatch) {
6387
+ try {
6388
+ const hintId = decodeURIComponent(hintReviewMatch[1]);
6389
+ const body = await readJSON(req).catch(() => null);
6390
+ const decision = body?.decision === 'approved' || body?.decision === 'rejected' ? body.decision : null;
6391
+ if (!decision) {
6392
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
6393
+ res.end(serializeJSON({ error: "decision must be 'approved' or 'rejected'." }));
6394
+ return;
6395
+ }
6396
+ const reviewer = typeof body?.reviewer === 'string' && body.reviewer.trim()
6397
+ ? body.reviewer.trim()
6398
+ : (resolveLocalOwner(projectRoot) ?? 'local');
6399
+ const note = typeof body?.note === 'string' && body.note.trim() ? body.note.trim() : undefined;
6400
+ const snapshotId = projectSnapshot().snapshotId;
6401
+ // dbt-first v3 requires a PASSED evaluation artifact recorded against the
6402
+ // same snapshot before a candidate may be approved. Approving from the
6403
+ // review queue therefore runs the hint's required evaluation first: the
6404
+ // corrected SQL is re-validated against the current project so a hint can
6405
+ // never be promoted on evidence that has gone stale.
6406
+ if (decision === 'approved') {
6407
+ const pending = listHintsFromGit(projectRoot).find((entry) => entry.id === hintId);
6408
+ if (pending?.status === 'candidate' && pending.requiredEvaluation && !pending.evaluationId) {
6409
+ // The evaluation must be a real check, not a rubber stamp: the
6410
+ // corrected SQL has to parse and reference at least one relation
6411
+ // against the CURRENT project. A correction that no longer analyses
6412
+ // cannot be promoted into everyone's prompt.
6413
+ const validation = (() => {
6414
+ const sql = pending.correctedSql?.trim();
6415
+ if (!sql)
6416
+ return { ok: false, error: 'The correction carries no SQL to verify.' };
6417
+ try {
6418
+ const analysis = analyzeSqlReferences(sql);
6419
+ if (!analysis.tables.length)
6420
+ return { ok: false, error: 'Corrected SQL references no known relation.' };
6421
+ return { ok: true };
6422
+ }
6423
+ catch (error) {
6424
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
6425
+ }
6426
+ })();
6427
+ try {
6428
+ evaluateHint(projectRoot, {
6429
+ hintId,
6430
+ snapshotId,
6431
+ evaluator: reviewer,
6432
+ evaluation: pending.requiredEvaluation,
6433
+ checks: [{
6434
+ name: 'corrected-sql-validates',
6435
+ passed: validation.ok,
6436
+ evidence: validation.ok ? 'Corrected SQL passed local context validation.' : validation.error,
6437
+ }],
6438
+ evidence: [`snapshot: ${snapshotId}`, `reviewer: ${reviewer}`],
6439
+ });
6440
+ }
6441
+ catch (evaluationError) {
6442
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
6443
+ res.end(serializeJSON({ ok: false, error: evaluationError instanceof Error ? evaluationError.message : String(evaluationError) }));
6444
+ return;
6445
+ }
6446
+ }
6447
+ }
6448
+ const reviewed = reviewHint(projectRoot, { hintId, decision, reviewer, snapshotId, ...(note ? { note } : {}) });
6449
+ if (!reviewed) {
6450
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
6451
+ res.end(serializeJSON({ ok: false, error: 'Hint not found.' }));
6452
+ return;
6453
+ }
6454
+ const hint = reviewed.hint;
6455
+ // Keep the mirrored advisory memory in step with the decision, so an
6456
+ // approved lesson is recalled and a rejected one stops being recalled.
6457
+ try {
6458
+ const memory = new MemoryStore(defaultMemoryPath(projectRoot));
6459
+ memory.upsert({
6460
+ id: `mem_${hint.id}`,
6461
+ scope: 'project',
6462
+ title: hint.title,
6463
+ content: hint.guidance,
6464
+ tags: [hint.scope.metric, hint.scope.domain, hint.scope.dbtModel].filter((x) => Boolean(x)),
6465
+ source: 'correction',
6466
+ confidence: 0.9,
6467
+ importance: 0.85,
6468
+ enabled: decision === 'approved',
6469
+ });
6470
+ memory.close();
6471
+ }
6472
+ catch {
6473
+ /* best-effort */
6474
+ }
6475
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6476
+ res.end(serializeJSON({ ok: true, hint }));
6477
+ }
6478
+ catch (error) {
6479
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
6480
+ res.end(serializeJSON({ ok: false, error: error instanceof Error ? error.message : String(error) }));
6481
+ }
6482
+ return;
6483
+ }
6362
6484
  if (req.method === 'POST' && path === '/api/agent/learnings/correction') {
6363
6485
  const body = await readJSON(req).catch(() => null);
6364
6486
  const question = body && typeof body.question === 'string' ? body.question.trim() : '';
@@ -6382,6 +6504,29 @@ export async function startLocalServer(opts) {
6382
6504
  const rationale = typeof body.rationale === 'string' && body.rationale.trim() ? body.rationale.trim() : undefined;
6383
6505
  const author = typeof body.author === 'string' ? body.author : (resolveLocalOwner(projectRoot) ?? undefined);
6384
6506
  try {
6507
+ // dbt-first manifest v3 requires reviewable provenance before a correction
6508
+ // may be recorded at all. The client cannot know most of it, so derive it
6509
+ // here: without this the endpoint rejects every correction on exactly the
6510
+ // governed projects the learning loop is meant to serve.
6511
+ const correctionEvidence = Array.isArray(body.evidence)
6512
+ ? body.evidence.map(String).filter(Boolean)
6513
+ : [
6514
+ `question: ${question}`,
6515
+ ...(wrongSql ? [`previous SQL: ${wrongSql.replace(/\s+/g, ' ').slice(0, 400)}`] : []),
6516
+ `corrected SQL: ${correctedSql.replace(/\s+/g, ' ').slice(0, 400)}`,
6517
+ ...(rationale ? [`rationale: ${rationale}`] : []),
6518
+ ];
6519
+ const correctionSnapshotId = typeof body.snapshotId === 'string' && body.snapshotId.trim()
6520
+ ? body.snapshotId.trim()
6521
+ : projectSnapshot().snapshotId;
6522
+ const correctionFailedRoute = typeof body.failedRoute === 'string' && body.failedRoute.trim()
6523
+ ? body.failedRoute.trim()
6524
+ : 'generated_answer';
6525
+ // Must match the name emitCorrectionEvalCase writes, so the required
6526
+ // evaluation and the emitted regression case are the same artifact.
6527
+ const correctionEvaluation = typeof body.requiredEvaluation === 'string' && body.requiredEvaluation.trim()
6528
+ ? body.requiredEvaluation.trim()
6529
+ : `correction: ${question.slice(0, 60)}`;
6385
6530
  const { trace, hint } = recordCorrectionTrace(projectRoot, {
6386
6531
  question,
6387
6532
  scope,
@@ -6390,19 +6535,29 @@ export async function startLocalServer(opts) {
6390
6535
  correctedSql,
6391
6536
  rationale,
6392
6537
  author,
6538
+ failedRoute: correctionFailedRoute,
6539
+ evidence: correctionEvidence,
6540
+ snapshotId: correctionSnapshotId,
6541
+ requiredEvaluation: correctionEvaluation,
6393
6542
  hintTitle: typeof body.title === 'string' && body.title.trim() ? body.title.trim() : undefined,
6394
6543
  hintGuidance: typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance.trim() : undefined,
6395
6544
  tags: Array.isArray(body.tags) ? body.tags.map(String) : undefined,
6396
6545
  });
6546
+ // Capture always, approve deliberately. A hint is injected into the
6547
+ // generation prompt as a "human-approved correction" and shapes every
6548
+ // future answer in scope — one analyst's local fix must not silently
6549
+ // become a team-wide rule across thousands of models. The trace and the
6550
+ // candidate are always recorded, so nothing is lost; approval is a
6551
+ // one-click review in Settings. Callers may still opt in explicitly.
6397
6552
  let approvedHint = hint;
6398
- if (body.approve !== false) {
6399
- reviewHint(projectRoot, { hintId: hint.id, decision: 'approved', reviewer: author ?? 'local', note: 'Self-approved (OSS single-user).' });
6553
+ if (body.approve === true) {
6554
+ reviewHint(projectRoot, { hintId: hint.id, decision: 'approved', reviewer: author ?? 'local', note: 'Approved on capture.' });
6400
6555
  approvedHint = { ...hint, status: 'approved' };
6401
6556
  }
6402
6557
  // W4.3 — turn the correction into a durable regression eval case so the wrong
6403
6558
  // answer can never silently return. Best-effort; never blocks the correction.
6404
6559
  try {
6405
- emitCorrectionEvalCase(projectRoot, { question, correctedSql });
6560
+ emitCorrectionEvalCase(projectRoot, { question, correctedSql, name: correctionEvaluation });
6406
6561
  }
6407
6562
  catch {
6408
6563
  /* best-effort */
@@ -6419,7 +6574,10 @@ export async function startLocalServer(opts) {
6419
6574
  source: 'correction',
6420
6575
  confidence: 0.9,
6421
6576
  importance: 0.85,
6422
- enabled: true,
6577
+ // A candidate is evidence, not a rule. Mirroring it as an ENABLED
6578
+ // memory would inject it into prompts through the side door and
6579
+ // defeat the approval gate; it is enabled when the hint is approved.
6580
+ enabled: approvedHint.status === 'approved',
6423
6581
  });
6424
6582
  memory.close();
6425
6583
  }
@@ -6941,6 +7099,55 @@ export async function startLocalServer(opts) {
6941
7099
  }
6942
7100
  return;
6943
7101
  }
7102
+ // DELETE /api/notebooks?path=notebooks/x.dqlnb — remove a notebook file.
7103
+ // Deliberately narrow: only .dqlnb/.dql files under notebooks/, resolved and
7104
+ // re-checked against the project root so a crafted path cannot escape it.
7105
+ if (req.method === 'DELETE' && path === '/api/notebooks') {
7106
+ try {
7107
+ const relativePath = url.searchParams.get('path')?.trim();
7108
+ if (!relativePath) {
7109
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7110
+ res.end(serializeJSON({ ok: false, error: 'Missing notebook path.' }));
7111
+ return;
7112
+ }
7113
+ const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\//, '');
7114
+ if (!normalized.startsWith('notebooks/') || !/\.(dqlnb|dql)$/i.test(normalized) || normalized.includes('..')) {
7115
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7116
+ res.end(serializeJSON({ ok: false, error: 'Only notebook files under notebooks/ can be deleted.' }));
7117
+ return;
7118
+ }
7119
+ const absolute = resolve(projectRoot, normalized);
7120
+ const rootPrefix = `${resolve(projectRoot)}${sep}`;
7121
+ if (!absolute.startsWith(rootPrefix)) {
7122
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
7123
+ res.end(serializeJSON({ ok: false, error: 'Path escapes the project root.' }));
7124
+ return;
7125
+ }
7126
+ if (!existsSync(absolute)) {
7127
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
7128
+ res.end(serializeJSON({ ok: false, error: 'Notebook not found.' }));
7129
+ return;
7130
+ }
7131
+ rmSync(absolute, { force: true });
7132
+ // Drop the run-state sidecar too, or the next notebook created with the
7133
+ // same slug inherits the deleted one's cached results.
7134
+ const sidecar = absolute.replace(/\.(dqlnb|dql)$/i, '.run.json');
7135
+ if (sidecar !== absolute)
7136
+ rmSync(sidecar, { force: true });
7137
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
7138
+ res.end(serializeJSON({ ok: true, path: normalized }));
7139
+ }
7140
+ catch (error) {
7141
+ if (error instanceof DQLAccessDeniedError) {
7142
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
7143
+ res.end(serializeJSON({ ok: false, error: error.message, code: 'unauthorized' }));
7144
+ return;
7145
+ }
7146
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
7147
+ res.end(serializeJSON({ ok: false, error: error instanceof Error ? error.message : String(error) }));
7148
+ }
7149
+ return;
7150
+ }
6944
7151
  if (req.method === 'PUT' && path === '/api/notebook-content') {
6945
7152
  try {
6946
7153
  const body = await readJSON(req);
@@ -15604,10 +15811,28 @@ export function validateBlockStudioSource(source, semanticLayer) {
15604
15811
  message: 'Semantic block is not runnable yet. Select a metric and complete any required time settings.',
15605
15812
  }
15606
15813
  : {
15607
- severity: 'warning',
15814
+ // A block that DECLARES a query section and leaves it blank is broken,
15815
+ // not merely incomplete: it can never execute, yet it saved cleanly
15816
+ // because this was only a warning. A bare skeleton with no query
15817
+ // section at all stays a warning so draft authoring is unbroken.
15818
+ severity: /query\s*=\s*"""/i.test(source) ? 'error' : 'warning',
15608
15819
  code: 'sql_missing',
15609
- message: 'No executable SQL found in the block source.',
15820
+ message: /query\s*=\s*"""/i.test(source)
15821
+ ? 'The block declares a query section but it is empty. Add the SQL, or remove the query section and define metrics and dimensions instead.'
15822
+ : 'No executable SQL found in the block source.',
15823
+ });
15824
+ }
15825
+ // The mirror image: a semantic block is metrics x dimensions and must not carry
15826
+ // a query. Both together is the corrupt shape the analyzer lets through silently.
15827
+ if (semanticConfig.blockType === 'semantic') {
15828
+ const semanticQueryBody = source.match(/query\s*=\s*"""([\s\S]*?)"""/i)?.[1] ?? '';
15829
+ if (semanticQueryBody.trim()) {
15830
+ diagnostics.push({
15831
+ severity: 'error',
15832
+ code: 'semantic_block_has_query',
15833
+ message: 'Semantic blocks are defined by metrics and dimensions and must not contain a query section. Move the SQL into a custom block.',
15610
15834
  });
15835
+ }
15611
15836
  }
15612
15837
  const parameterInvocation = prepareBlockInvocation({ source, surface: 'block_studio' });
15613
15838
  if (parameterInvocation.errors.length > 0) {