@duckcodeailabs/dql-cli 1.12.2 → 1.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/assets/dql-notebook/assets/AgentAnswerCard-BJAnjaLA.js +5 -0
  2. package/dist/assets/dql-notebook/assets/AgentLogPage-J72WR6hk.js +1 -0
  3. package/dist/assets/dql-notebook/assets/AiBuildDialog-CHJZfDmp.js +1 -0
  4. package/dist/assets/dql-notebook/assets/AiBuildResult-Da7_Cn12.js +33 -0
  5. package/dist/assets/dql-notebook/assets/AiSidePanel-BY6W_5aC.js +6 -0
  6. package/dist/assets/dql-notebook/assets/AnalyticsHome-u-buXgdv.js +6 -0
  7. package/dist/assets/dql-notebook/assets/AppsView-3k7ftDpd.js +4471 -0
  8. package/dist/assets/dql-notebook/assets/BlockStudio-CLX4EUyO.js +60 -0
  9. package/dist/assets/dql-notebook/assets/BusinessArtifactView-3THMNeyO.js +1 -0
  10. package/dist/assets/dql-notebook/assets/DbtFirstModelingPage-BwBPwtdA.js +22 -0
  11. package/dist/assets/dql-notebook/assets/GitPage-CvEki0pe.js +7 -0
  12. package/dist/assets/dql-notebook/assets/GlobalAiRail-NXdk9kNy.js +1 -0
  13. package/dist/assets/dql-notebook/assets/GovernedContextPage-DILbw-Vr.js +7 -0
  14. package/dist/assets/dql-notebook/assets/HelpDocsPage-DgCu4bvY.js +22 -0
  15. package/dist/assets/dql-notebook/assets/HomePage-CK3fXTQo.js +117 -0
  16. package/dist/assets/dql-notebook/assets/LineageDAG-DW_NO3P2.js +1 -0
  17. package/dist/assets/dql-notebook/assets/LineageDetailView-VT7DLJOB.js +1 -0
  18. package/dist/assets/dql-notebook/assets/LineageDrawer-DSTm8aXw.js +1 -0
  19. package/dist/assets/dql-notebook/assets/LineagePathBreadcrumb-DRlAYasJ.js +14 -0
  20. package/dist/assets/dql-notebook/assets/MiniLineageGraph-CR_nhwhZ.js +1 -0
  21. package/dist/assets/dql-notebook/assets/NewBlockModal-CJvjYiBF.js +1 -0
  22. package/dist/assets/dql-notebook/assets/NewNotebookModal-BEpPH93n.js +46 -0
  23. package/dist/assets/dql-notebook/assets/NotebookEditor-C7GDFwhF.js +202 -0
  24. package/dist/assets/dql-notebook/assets/ReadinessPage-rZiOp2xr.js +1 -0
  25. package/dist/assets/dql-notebook/assets/SetupOnboarding-BWjhTjPB.js +6 -0
  26. package/dist/assets/dql-notebook/assets/TrustBadge-RvZ9s6OQ.js +1 -0
  27. package/dist/assets/dql-notebook/assets/UnifiedAgentRunPanel-CGPcpY1B.js +83 -0
  28. package/dist/assets/dql-notebook/assets/ai-build-bus-1BR770pY.js +1 -0
  29. package/dist/assets/dql-notebook/assets/answer-to-notebook-Di227Cd4.js +2 -0
  30. package/dist/assets/dql-notebook/assets/arrow-left-BZHDcWHR.js +6 -0
  31. package/dist/assets/dql-notebook/assets/arrow-right-DlM1pC3_.js +6 -0
  32. package/dist/assets/dql-notebook/assets/book-open-text-DBcXFQAu.js +6 -0
  33. package/dist/assets/dql-notebook/assets/bot-BAywbvPm.js +6 -0
  34. package/dist/assets/dql-notebook/assets/chevron-up-B_GfKE_F.js +6 -0
  35. package/dist/assets/dql-notebook/assets/circle-x-BcxtZ-ym.js +6 -0
  36. package/dist/assets/dql-notebook/assets/{codemirror-Co0dCEDr.js → codemirror-1_I9gm6Z.js} +1 -1
  37. package/dist/assets/dql-notebook/assets/dagre-BZV40eAE.css +1 -0
  38. package/dist/assets/dql-notebook/assets/dagre.esm-Bqs0lsEz.js +23 -0
  39. package/dist/assets/dql-notebook/assets/external-link-B24Dk9_t.js +6 -0
  40. package/dist/assets/dql-notebook/assets/grip-vertical-D9g31N3j.js +6 -0
  41. package/dist/assets/dql-notebook/assets/{index-DvLT6Kls.css → index-DKu4CZt3.css} +1 -1
  42. package/dist/assets/dql-notebook/assets/index-DU7j3Sqk.js +644 -0
  43. package/dist/assets/dql-notebook/assets/link-2-B36oR5Bj.js +6 -0
  44. package/dist/assets/dql-notebook/assets/list-tree-B9ZzGc4U.js +6 -0
  45. package/dist/assets/dql-notebook/assets/maximize-2-BrvQLPoj.js +6 -0
  46. package/dist/assets/dql-notebook/assets/minimize-2-BrpFmsxS.js +6 -0
  47. package/dist/assets/dql-notebook/assets/panel-right-open-B1g1qhh5.js +6 -0
  48. package/dist/assets/dql-notebook/assets/pencil-DwywbTqQ.js +6 -0
  49. package/dist/assets/dql-notebook/assets/{react-BRY8hXab.js → react-CRB3T2We.js} +2 -2
  50. package/dist/assets/dql-notebook/assets/rotate-ccw-Db_KVs3J.js +6 -0
  51. package/dist/assets/dql-notebook/assets/semantic-fields-BIVuvP2l.js +3 -0
  52. package/dist/assets/dql-notebook/assets/triangle-alert-Cne6C2aG.js +6 -0
  53. package/dist/assets/dql-notebook/assets/usePersistedAgentThreadId-D6mtrJ-_.js +18 -0
  54. package/dist/assets/dql-notebook/assets/user-round-DkzxN1xD.js +11 -0
  55. package/dist/assets/dql-notebook/assets/wand-sparkles-CJUvFtur.js +6 -0
  56. package/dist/assets/dql-notebook/assets/workflow-BOvVTg7R.js +6 -0
  57. package/dist/assets/dql-notebook/assets/wrench-YeSpzZRt.js +6 -0
  58. package/dist/assets/dql-notebook/assets/x-C6tsHUR7.js +6 -0
  59. package/dist/assets/dql-notebook/index.html +3 -4
  60. package/dist/block-invocation.js +4 -0
  61. package/dist/block-invocation.js.map +1 -1
  62. package/dist/llm/answer-loop-tools.d.ts +8 -2
  63. package/dist/llm/answer-loop-tools.d.ts.map +1 -1
  64. package/dist/llm/answer-loop-tools.js +29 -2
  65. package/dist/llm/answer-loop-tools.js.map +1 -1
  66. package/dist/llm/providers/dql-agent-provider.d.ts +27 -1
  67. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  68. package/dist/llm/providers/dql-agent-provider.js +79 -15
  69. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  70. package/dist/llm/types.d.ts +8 -1
  71. package/dist/llm/types.d.ts.map +1 -1
  72. package/dist/local-runtime.d.ts +56 -2
  73. package/dist/local-runtime.d.ts.map +1 -1
  74. package/dist/local-runtime.js +735 -104
  75. package/dist/local-runtime.js.map +1 -1
  76. package/dist/package.json +10 -10
  77. package/dist/sql-result-filter.d.ts +39 -0
  78. package/dist/sql-result-filter.d.ts.map +1 -0
  79. package/dist/sql-result-filter.js +256 -0
  80. package/dist/sql-result-filter.js.map +1 -0
  81. package/dist/warehouse-metadata.d.ts +1 -1
  82. package/dist/warehouse-metadata.d.ts.map +1 -1
  83. package/dist/warehouse-metadata.js +2 -2
  84. package/dist/warehouse-metadata.js.map +1 -1
  85. package/package.json +10 -10
  86. package/dist/assets/dql-notebook/assets/index-BRCtmVPa.js +0 -5952
@@ -20,7 +20,8 @@ import { fetchLatestPublishedDqlVersion, resolveDqlRuntimeVersionStatus } from '
20
20
  import { resolveRetrievalHealthStatus } from './retrieval-health.js';
21
21
  import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
22
22
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
23
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, } from '@duckcodeailabs/dql-agent';
23
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, conversationHistoryFromContext, recallRelevantTurns, renderConversationEnvelopeForPrompt, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, composeSemanticQueryForQuestion, aggregationIntegrityIssuesForSql, buildLocalContextPack, applyContextPackCompatibility, toAgentRetrievalEvidence, prepareConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, compactSemanticRuntimeFailure, classifyAnalyticalFailure, propose, proposePlan, recordGovernedCorrection, HintStore, defaultHintIndexPath, ensureHintIndexFresh, listHintsFromGit, getHintEvaluationFromGit, getCorrectionTraceFromGit, inspectGovernedHint, editGovernedHintCandidate, reopenGovernedHint, retireHint, supersedeHint, hintsConflict, mineJoinPatterns, reviewGovernedHint, AgentRunEngine, SqliteAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, internalRelationIdsInSql, defaultAgentRunStorePath, defaultAgentRunSqlitePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, deriveAnalyticalRepair, reindexProject, invalidateAgentProjectState, recordAgentRuntimeVersion, resolveDomainContextEnvelope, projectEmbeddingProvider, isHashedEmbeddingProvider, clearProjectEmbeddingCache, upgradeVectorIndexForProject, openMetadataCatalog, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, loadAgentSemanticLayer, isTrustedConversationTurn, analyticalError, withAnalyticalErrorOrigin, withAnalyticalErrorOriginSync, } from '@duckcodeailabs/dql-agent';
24
+ import { addSqlResultFilter, filterableResultColumns, replaceBlockStudioSql } from './sql-result-filter.js';
24
25
  import { gatherProposeEnrichment } from './propose-enrich.js';
25
26
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
26
27
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -435,6 +436,9 @@ async function conversationContextFromThread(store, threadId, clientContext, que
435
436
  trustLabel: turn.trustLabel,
436
437
  runStatus: turn.runStatus,
437
438
  stopReason: turn.stopReason,
439
+ // Carried so every consumer can compute trust from the same fields.
440
+ refusalCode: turn.refusalCode,
441
+ executionError: turn.executionError,
438
442
  certification: turn.certification,
439
443
  contextPackId: turn.contextPackId,
440
444
  dqlArtifact: turn.dqlArtifact,
@@ -473,9 +477,30 @@ async function conversationContextFromThread(store, threadId, clientContext, que
473
477
  ...(serverSnapshot ? { conversationEnvelope: serverSnapshot } : {}),
474
478
  ...(serverSnapshot ? { serverSnapshot } : {}),
475
479
  ...(thread?.rollingSummary ? { conversationSummary: thread.rollingSummary } : {}),
476
- ...(turns.length > 0 ? { turns, activeTurnId: turns[turns.length - 1].id } : {}),
480
+ // `activeTurnId` is the FOLLOW-UP ANCHOR: the turn whose filters, prior DQL
481
+ // artifact, and source SQL the next question builds on. Anchoring it to the
482
+ // last turn unconditionally meant a failed turn became the anchor, and its
483
+ // broken artifact and failing SQL were handed to the next question as
484
+ // authoritative context — the sticky-error loop. `latestTurnId` keeps the
485
+ // real tail for presentation.
486
+ ...(turns.length > 0
487
+ ? {
488
+ turns,
489
+ activeTurnId: lastTrustedTurnId(turns) ?? undefined,
490
+ latestTurnId: turns[turns.length - 1].id,
491
+ }
492
+ : {}),
477
493
  };
478
494
  }
495
+ /** The most recent turn a follow-up may safely build on. */
496
+ function lastTrustedTurnId(turns) {
497
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
498
+ const turn = turns[index];
499
+ if (isTrustedConversationTurn(turn))
500
+ return turn.id;
501
+ }
502
+ return undefined;
503
+ }
479
504
  /** Best-effort: persist a completed run as a conversation turn (never throws). */
480
505
  function recordConversationTurn(store, threadId, run) {
481
506
  if (!store || !threadId)
@@ -489,8 +514,12 @@ function recordConversationTurn(store, threadId, run) {
489
514
  // turn folding its (possibly misparsed) filters and measures into working
490
515
  // state poisoned every subsequent ask: the SAME question that worked once
491
516
  // then failed forever because the bad turn's phantom state carried forward.
492
- const producedAnswer = run.status === 'completed' || run.status === 'needs_review';
493
- if (producedAnswer)
517
+ //
518
+ // The previous guard tested `run.status`, which cannot express this: a
519
+ // grounding-gap refusal and a perfectly good uncertified answer are BOTH
520
+ // `needs_review`, so the guard admitted exactly the failures its comment
521
+ // was written to exclude. Trust is now derived from answer state.
522
+ if (isTrustedConversationTurn(turn))
494
523
  advanceThreadState(store, threadId, turn);
495
524
  }
496
525
  catch {
@@ -529,6 +558,11 @@ export function conversationTurnInputFromRun(run) {
529
558
  trustLabel: agentRunString(payload?.trustLabel) ?? run.trustState,
530
559
  runStatus: run.status,
531
560
  stopReason: run.stopReason,
561
+ // Persisted so a later turn can tell a refusal from an answer. `runStatus`
562
+ // alone cannot: a grounding gap and a good uncertified answer are both
563
+ // `needs_review`.
564
+ refusalCode: agentRunString(payload?.refusalCode),
565
+ executionError: agentRunString(payload?.executionError),
532
566
  certification: agentRunString(payload?.certification),
533
567
  sourceCertifiedBlock: agentRunString(payload?.sourceCertifiedBlock)
534
568
  ?? (artifact?.kind === 'answer' ? agentRunString(artifact.ref) : undefined),
@@ -968,7 +1002,10 @@ export async function startLocalServer(opts) {
968
1002
  return result;
969
1003
  };
970
1004
  await reloadSemanticLayer();
971
- await refreshLocalMetadataCatalog(projectRoot);
1005
+ // Normal startup reuses the fingerprinted local catalog when its inputs are
1006
+ // unchanged. Explicit refresh, save, and certification paths still request
1007
+ // the default forced rebuild below.
1008
+ await refreshLocalMetadataCatalog(projectRoot, undefined, undefined, false);
972
1009
  const startupDbtManifestPath = resolveDbtManifestPath(projectRoot, projectConfig);
973
1010
  if (startupDbtManifestPath && !isAgentProjectIndexReady(projectRoot)) {
974
1011
  try {
@@ -1338,8 +1375,14 @@ export async function startLocalServer(opts) {
1338
1375
  throw error;
1339
1376
  }
1340
1377
  const semanticDriver = semanticConnection?.driver;
1378
+ const defaultExecutionTarget = executionTargetDescriptor({});
1379
+ const semanticConnectionName = request.executionTarget?.target === 'connection'
1380
+ ? request.executionTarget.connectionName
1381
+ : defaultExecutionTarget.target === 'connection'
1382
+ ? defaultExecutionTarget.connectionName
1383
+ : undefined;
1341
1384
  const semanticTableMapping = semanticLayer && semanticConnection
1342
- ? await resolveSemanticTableMapping(executor, semanticConnection, semanticLayer, projectRoot)
1385
+ ? await resolveSemanticTableMapping(executor, semanticConnection, semanticLayer, projectRoot, semanticConnectionName)
1343
1386
  : undefined;
1344
1387
  const requestedDomain = normalizeAgentRunDomain(agentRunWorkspaceValue(request, 'domain'));
1345
1388
  const requestedPurpose = agentRunWorkspaceValue(request, 'purpose');
@@ -1527,12 +1570,13 @@ export async function startLocalServer(opts) {
1527
1570
  ? { resolvedAnalyticalPlan: routeDecision.resolvedAnalyticalPlan }
1528
1571
  : {}),
1529
1572
  analyticalReferenceInstant: new Date().toISOString(),
1530
- executeCertifiedBlock: (node, invocation) => executeCertifiedBlockForAgent(node, invocation, semanticConnection),
1531
- executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection),
1532
- executeDqlArtifact: (artifact) => executeArtifactReferenceForAgent(artifact, request.question, semanticConnection),
1573
+ executeCertifiedBlock: (node, invocation) => executeCertifiedBlockForAgent(node, invocation, semanticConnection, semanticConnectionName),
1574
+ executeGeneratedSql: (sql, artifact) => executeGeneratedArtifactForAgent(request.question, sql, artifact, semanticConnection, semanticConnectionName),
1575
+ executeDqlArtifact: (artifact) => executeArtifactReferenceForAgent(artifact, request.question, semanticConnection, semanticConnectionName),
1533
1576
  getSchemaContext: (question, preparedContextPack) => getSchemaContextForAgent(question, preparedContextPack, semanticConnection, request.executionTarget?.target === 'connection'
1534
1577
  ? request.executionTarget.connectionName
1535
1578
  : undefined),
1579
+ probeNamedRelations: (relations) => probeNamedRelationsForAgent(relations, semanticConnection),
1536
1580
  }, (turn) => {
1537
1581
  if (turn.kind === 'thinking')
1538
1582
  onProgress?.(turn.text);
@@ -1918,7 +1962,7 @@ export async function startLocalServer(opts) {
1918
1962
  ],
1919
1963
  };
1920
1964
  }
1921
- const resolvedRoute = resolvedRunRouteFromAnswer(governedAnswer) ?? route;
1965
+ const answeredRoute = resolvedRunRouteFromAnswer(governedAnswer) ?? route;
1922
1966
  const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
1923
1967
  const isSemantic = governedAnswer.route?.tier === 'semantic_metric';
1924
1968
  const requestedNotebookDataset = findMentionedNotebookDataset(request.question, datasetWorkspace.list());
@@ -1949,6 +1993,12 @@ export async function startLocalServer(opts) {
1949
1993
  // provider outages are blocked so the UI can offer an explicit retry.
1950
1994
  const needsClarification = governedAnswer.kind === 'no_answer'
1951
1995
  && !isGroundingGap && !isProviderError && !isModelDeclined && !isPolicyBlocked;
1996
+ // A run that ASKED THE USER A QUESTION is a clarify turn, whatever route it
1997
+ // would have taken had it answered. Persisting the resolved analytical route
1998
+ // instead (`generated_answer`) meant the engine's clarification-continuation
1999
+ // lookup — which matches on `route === 'clarify'` — never found it, so the
2000
+ // reply ran context-free and was clarified again: the reported clarify loop.
2001
+ const resolvedRoute = needsClarification ? 'clarify' : answeredRoute;
1952
2002
  const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
1953
2003
  const runnableSql = governedAnswer.kind === 'no_answer' || isExecutionFailure || (isExploratory && !governedAnswer.result)
1954
2004
  ? undefined
@@ -1993,31 +2043,45 @@ export async function startLocalServer(opts) {
1993
2043
  synthesizedAnswer = undefined;
1994
2044
  }
1995
2045
  }
2046
+ // A grounding/modeling gap is a REFUSAL. It was computed above but never
2047
+ // reached this ternary, so it fell through to `needs_review` — the same
2048
+ // status as a perfectly good uncertified answer. Every downstream trust
2049
+ // check then treated the refusal as a usable turn, which is how one failed
2050
+ // answer poisoned every later question in the thread.
1996
2051
  const status = isTerminalFailure
1997
2052
  ? 'blocked'
1998
2053
  : needsClarification
1999
2054
  ? 'needs_clarification'
2000
- : isCertified || isSemantic
2001
- ? 'completed'
2002
- : 'needs_review';
2055
+ : isGroundingGap || isModelDeclined
2056
+ ? 'blocked'
2057
+ : isCertified || isSemantic
2058
+ ? 'completed'
2059
+ : 'needs_review';
2003
2060
  const trustState = isTerminalFailure
2004
2061
  ? 'blocked'
2005
2062
  : needsClarification
2006
2063
  ? 'not_applicable'
2007
- : isCertified
2008
- ? 'certified'
2009
- : isSemantic
2010
- ? 'governed'
2011
- : 'review_required';
2064
+ : isGroundingGap || isModelDeclined
2065
+ ? 'blocked'
2066
+ : isCertified
2067
+ ? 'certified'
2068
+ : isSemantic
2069
+ ? 'governed'
2070
+ : 'review_required';
2012
2071
  const stopReason = isTerminalFailure
2013
2072
  ? 'blocked'
2014
2073
  : needsClarification
2015
2074
  ? 'needs_clarification'
2016
- : isCertified
2017
- ? 'certified_answer_found'
2018
- : isSemantic
2019
- ? 'governed_semantic_answer'
2020
- : 'human_review_required';
2075
+ // A gap is terminal, but it is NOT a provider outage: it still owes the
2076
+ // user its evidence trace, and its next-actions below stay the
2077
+ // grounding-gap set rather than "retry the provider".
2078
+ : isGroundingGap || isModelDeclined
2079
+ ? 'human_review_required'
2080
+ : isCertified
2081
+ ? 'certified_answer_found'
2082
+ : isSemantic
2083
+ ? 'governed_semantic_answer'
2084
+ : 'human_review_required';
2021
2085
  const terminalFailureActions = governedAnswer.analyticalFailure
2022
2086
  ? governedAnswer.analyticalFailure.code === 'PERMISSION_DENIED'
2023
2087
  ? [{ id: 'repair-access', label: 'Change connection or request access', route: 'blocked' }]
@@ -2163,33 +2227,43 @@ export async function startLocalServer(opts) {
2163
2227
  const kind = routeDecision?.conversationalKind ?? 'smalltalk';
2164
2228
  const isGeneralKnowledge = routeDecision?.category === 'general_knowledge';
2165
2229
  const answerKind = isGeneralKnowledge ? 'general_knowledge' : 'conversational';
2166
- const catalogContext = buildAgentRunCatalogContext();
2230
+ const catalogContext = kind === 'answer_explanation' ? '' : buildAgentRunCatalogContext();
2167
2231
  const suggestions = buildConversationSuggestions(projectRoot, kind);
2168
2232
  const nextActions = suggestions.map((prompt, index) => ({
2169
2233
  id: `suggest-question-${index + 1}`,
2170
2234
  label: prompt,
2171
2235
  }));
2172
- let text;
2173
- try {
2174
- const provider = await createBlockStudioAssistProvider(projectRoot);
2175
- if (provider) {
2176
- const system = buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, request.audience ?? 'analyst');
2177
- const conversationMemory = renderConversationMemoryForPrompt(request.conversationContext);
2178
- const messages = [
2179
- { role: 'system', content: system },
2180
- ...(conversationMemory ? [{ role: 'system', content: conversationMemory }] : []),
2181
- ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
2182
- { role: 'user', content: request.question },
2183
- ];
2184
- text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
2185
- // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
2186
- if (text)
2187
- emitAnswerDelta?.(text);
2188
- }
2236
+ // A question about the latest answer is an evidence lookup, not another
2237
+ // warehouse run. Resolve it from the persisted artifact contract before
2238
+ // considering a provider-generated conversational reply.
2239
+ let text = kind === 'answer_explanation'
2240
+ ? buildPriorAnswerExplanation(request.question, request.conversationContext)
2241
+ : undefined;
2242
+ if (text) {
2243
+ emitAnswerDelta?.(text);
2189
2244
  }
2190
- catch {
2191
- // Provider unavailable / errored — fall through to the deterministic reply.
2192
- text = undefined;
2245
+ else {
2246
+ try {
2247
+ const provider = await createBlockStudioAssistProvider(projectRoot);
2248
+ if (provider) {
2249
+ const system = buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, request.audience ?? 'analyst');
2250
+ const conversationMemory = renderConversationMemoryForPrompt(request.conversationContext);
2251
+ const messages = [
2252
+ { role: 'system', content: system },
2253
+ ...(conversationMemory ? [{ role: 'system', content: conversationMemory }] : []),
2254
+ ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
2255
+ { role: 'user', content: request.question },
2256
+ ];
2257
+ text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
2258
+ // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
2259
+ if (text)
2260
+ emitAnswerDelta?.(text);
2261
+ }
2262
+ }
2263
+ catch {
2264
+ // Provider unavailable / errored — fall through to the deterministic reply.
2265
+ text = undefined;
2266
+ }
2193
2267
  }
2194
2268
  if (!text) {
2195
2269
  text = buildConversationContextRecap(request.conversationContext)
@@ -2197,7 +2271,7 @@ export async function startLocalServer(opts) {
2197
2271
  emitAnswerDelta?.(text);
2198
2272
  }
2199
2273
  return {
2200
- summary: 'Replied conversationally.',
2274
+ summary: kind === 'answer_explanation' ? 'Explained the previous answer.' : 'Replied conversationally.',
2201
2275
  answer: text,
2202
2276
  answerKind,
2203
2277
  status: 'completed',
@@ -2706,6 +2780,9 @@ export async function startLocalServer(opts) {
2706
2780
  const serverSnapshot = agentRunRecord(request.conversationContext?.conversationEnvelope
2707
2781
  ?? request.conversationContext?.serverSnapshot);
2708
2782
  const topicRelation = agentRunString(serverSnapshot?.topicRelation);
2783
+ const workspaceSurface = agentRunWorkspaceValue(request, 'surface');
2784
+ const blockAuthoringContext = request.requestedMode === 'block'
2785
+ || workspaceSurface === 'block-studio';
2709
2786
  // The readiness marker is source-versioned. When it matches, pass the
2710
2787
  // already-built metadata identity into retrieval so buildLocalContextPack
2711
2788
  // opens the immutable snapshot directly instead of rebuilding all metadata
@@ -2713,9 +2790,23 @@ export async function startLocalServer(opts) {
2713
2790
  const preparedMetadataFingerprint = isAgentProjectIndexReady(projectRoot)
2714
2791
  ? currentMetadataFingerprint(projectRoot)
2715
2792
  : undefined;
2793
+ const targetDescriptor = executionTargetDescriptor(request);
2794
+ const targetConnectionName = targetDescriptor.target === 'connection'
2795
+ ? targetDescriptor.connectionName
2796
+ : undefined;
2797
+ let runtimeSchemaSnapshot;
2798
+ if (targetConnectionName) {
2799
+ const targetConnection = resolveNamedConnection(targetConnectionName);
2800
+ const activeScope = targetConnection
2801
+ ? optionalActiveConnectionMetadataScope(projectRoot, projectConfig, targetConnection, targetConnectionName)
2802
+ : null;
2803
+ runtimeSchemaSnapshot = runtimeSchemaSnapshotForAgentConnection(projectRoot, targetConnectionName, activeScope?.scopeFingerprint);
2804
+ }
2716
2805
  const task = buildLocalContextPack(projectRoot, {
2717
2806
  question: request.question,
2718
2807
  focusObjectKey: request.selectedEvidenceId,
2808
+ mode: blockAuthoringContext ? 'build' : 'question',
2809
+ includeDraftBlocks: blockAuthoringContext,
2719
2810
  followUp,
2720
2811
  priorContextPackId: agentRunString(request.conversationContext?.contextPackId),
2721
2812
  conversationTopicRelation: topicRelation === 'continuation'
@@ -2724,8 +2815,14 @@ export async function startLocalServer(opts) {
2724
2815
  || topicRelation === 'shift'
2725
2816
  ? topicRelation
2726
2817
  : undefined,
2818
+ // Reuse remains available for fast follow-ups; the context-pack freshness
2819
+ // contract now binds it to this runtime snapshot's connection/generation.
2820
+ reusePolicy: 'seed',
2727
2821
  preparedMetadataFingerprint,
2728
- surface: 'notebook',
2822
+ ...(runtimeSchemaSnapshot ? { runtimeSchemaSnapshot } : {}),
2823
+ surface: blockAuthoringContext
2824
+ ? 'block'
2825
+ : workspaceSurface ?? 'notebook',
2729
2826
  selectedContext: {
2730
2827
  selectedObject: request.selectedObject,
2731
2828
  workspaceContext: request.workspaceContext,
@@ -2995,44 +3092,56 @@ export async function startLocalServer(opts) {
2995
3092
  cells: snapshotCells,
2996
3093
  });
2997
3094
  };
2998
- const executeDqlArtifactSourceForAgent = async (source, metadata = {}, invocationInput, executionConnection) => {
2999
- const invocation = prepareBlockInvocation({
3095
+ const executeDqlArtifactSourceForAgent = async (source, metadata = {}, invocationInput, executionConnection, executionConnectionName) => {
3096
+ // Everything from here to the executor call is DQL's OWN block compiler:
3097
+ // parse, parameter resolution, plan build, semantic compose. A failure in
3098
+ // any of it means DQL could not rebuild its artifact — the warehouse was
3099
+ // never contacted. Tagging the origin here stops the answer loop from
3100
+ // reporting these as "the generated SQL failed to execute".
3101
+ const invocation = withAnalyticalCompilationOrigin('bind', () => prepareBlockInvocation({
3000
3102
  source,
3001
3103
  parameters: invocationInput?.parameters,
3002
3104
  parameterSources: invocationInput?.parameterSources,
3003
3105
  question: invocationInput?.question,
3004
3106
  surface: 'ask_ai',
3005
- });
3006
- if (invocation.errors.length > 0)
3007
- throw new Error(invocation.errors.join(' '));
3107
+ }));
3108
+ if (invocation.errors.length > 0) {
3109
+ throw analyticalError(invocation.errors.join(' '), { origin: 'dql_compilation', stage: 'compile' });
3110
+ }
3008
3111
  if (invocation.unresolvedParameters.length > 0) {
3009
- throw new Error(`I need values for: ${invocation.unresolvedParameters.join(', ')}.`);
3112
+ throw analyticalError(`I need values for: ${invocation.unresolvedParameters.join(', ')}.`, {
3113
+ origin: 'dql_compilation',
3114
+ stage: 'bind',
3115
+ code: 'unresolved_parameter',
3116
+ offending: { parameter: invocation.unresolvedParameters[0] },
3117
+ });
3010
3118
  }
3011
3119
  const activeConnection = requireActiveConnection(executionConnection);
3012
3120
  const precompiledSemanticQuery = Boolean(extractBlockStudioSql(source));
3013
3121
  const tableMapping = !precompiledSemanticQuery
3014
- ? await resolveSemanticTableMapping(executor, activeConnection, semanticLayer, projectRoot)
3122
+ ? await resolveSemanticTableMapping(executor, activeConnection, semanticLayer, projectRoot, executionConnectionName)
3015
3123
  : undefined;
3016
- const semanticCompose = semanticLayer
3017
- ? await composeSemanticBlockSqlForRuntime(source, semanticLayer, {
3124
+ const composeLayer = semanticLayer;
3125
+ const semanticCompose = composeLayer
3126
+ ? await withAnalyticalErrorOrigin({ origin: 'dql_compilation', stage: 'compile' }, () => composeSemanticBlockSqlForRuntime(source, composeLayer, {
3018
3127
  driver: activeConnection.driver,
3019
3128
  tableMapping,
3020
3129
  projectRoot,
3021
3130
  projectConfig,
3022
3131
  detectedProvider: semanticDetectedProvider,
3023
3132
  parameters: invocation.values,
3024
- })
3133
+ }))
3025
3134
  : null;
3026
- const plan = buildExecutionPlan({ id: `agent-${metadata.name ?? 'dql-artifact'}`, type: 'dql', source, title: metadata.name ?? 'DQL artifact' }, {
3135
+ const plan = withAnalyticalCompilationOrigin('compile', () => buildExecutionPlan({ id: `agent-${metadata.name ?? 'dql-artifact'}`, type: 'dql', source, title: metadata.name ?? 'DQL artifact' }, {
3027
3136
  semanticLayer,
3028
3137
  driver: activeConnection.driver,
3029
3138
  tableMapping,
3030
3139
  parameters: invocation.values,
3031
3140
  semanticSql: semanticCompose?.sql ?? undefined,
3032
- });
3141
+ }));
3033
3142
  if (!plan && !semanticCompose?.sql) {
3034
3143
  const semanticError = semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message;
3035
- throw new Error(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`);
3144
+ throw analyticalError(semanticError ?? `DQL artifact "${metadata.name ?? 'draft'}" produced no executable SQL.`, { origin: 'dql_compilation', stage: 'compile' });
3036
3145
  }
3037
3146
  const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan.sql, activeConnection, projectRoot, projectConfig);
3038
3147
  const executionSql = invocationInput?.rowLimit
@@ -3080,7 +3189,7 @@ export async function startLocalServer(opts) {
3080
3189
  } : {}),
3081
3190
  };
3082
3191
  };
3083
- const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection) => {
3192
+ const executeCertifiedBlockByNameForAgent = async (blockName, invocationInput, requireCertified = false, executionConnection, executionConnectionName) => {
3084
3193
  const manifest = buildManifest({ projectRoot });
3085
3194
  const block = manifest.blocks[blockName];
3086
3195
  if (!block) {
@@ -3095,7 +3204,7 @@ export async function startLocalServer(opts) {
3095
3204
  path: block.filePath,
3096
3205
  domain: block.domain,
3097
3206
  chartType: block.chartType,
3098
- }, invocationInput, executionConnection);
3207
+ }, invocationInput, executionConnection, executionConnectionName);
3099
3208
  return {
3100
3209
  ...result,
3101
3210
  dqlArtifact: {
@@ -3114,17 +3223,17 @@ export async function startLocalServer(opts) {
3114
3223
  },
3115
3224
  };
3116
3225
  };
3117
- const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection) => {
3226
+ const executeCertifiedBlockForAgent = async (node, invocationInput, executionConnection, executionConnectionName) => {
3118
3227
  if (node.kind !== 'block') {
3119
3228
  throw new Error(`Certified ${node.kind} "${node.name}" is a navigation artifact and cannot be executed as a block.`);
3120
3229
  }
3121
- return executeCertifiedBlockByNameForAgent(node.name || node.nodeId.replace(/^block:/, ''), invocationInput, false, executionConnection);
3230
+ return executeCertifiedBlockByNameForAgent(node.name || node.nodeId.replace(/^block:/, ''), invocationInput, false, executionConnection, executionConnectionName);
3122
3231
  };
3123
3232
  // Secondary agent surfaces use this compatibility callback. Delegate to the
3124
3233
  // same artifact-first path as Ask so research/app/notebook execution cannot
3125
3234
  // silently reintroduce a raw-SQL result followed by a different DQL artifact.
3126
3235
  const executeGeneratedSqlForAgent = async (sql, artifact) => executeGeneratedArtifactForAgent(artifact?.name ?? 'AI-generated query', sql, artifact);
3127
- const executeArtifactReferenceForAgent = async (artifact, question, executionConnection) => {
3236
+ const executeArtifactReferenceForAgent = async (artifact, question, executionConnection, executionConnectionName) => {
3128
3237
  const invocation = prepareBlockInvocation({
3129
3238
  source: artifact.source,
3130
3239
  parameters: artifact.parameterValues,
@@ -3139,7 +3248,7 @@ export async function startLocalServer(opts) {
3139
3248
  question,
3140
3249
  parameters: invocation.values,
3141
3250
  rowLimit: artifact.limit ?? 200,
3142
- }, executionConnection);
3251
+ }, executionConnection, executionConnectionName);
3143
3252
  return {
3144
3253
  ...result,
3145
3254
  dqlArtifact: {
@@ -3151,9 +3260,123 @@ export async function startLocalServer(opts) {
3151
3260
  },
3152
3261
  };
3153
3262
  };
3154
- const executeGeneratedArtifactForAgent = async (question, sql, seed, executionConnection) => {
3155
- const artifact = generatedSqlArtifactContract(question, sql, seed);
3156
- return executeArtifactReferenceForAgent({ ...artifact, limit: seed?.limit ?? artifact.limit ?? 200 }, question, executionConnection);
3263
+ /**
3264
+ * Build the reusable DQL artifact for an answer that HAS ALREADY EXECUTED,
3265
+ * and check that it recompiles.
3266
+ *
3267
+ * AGT-010 requires the attached artifact to be the one used for execution, so
3268
+ * that "Apply" cannot diverge from what Ask displayed. The old code satisfied
3269
+ * that invariant backwards: it made EXECUTION follow the artifact, routing the
3270
+ * model's SQL through `parameterizeSqlForDqlImport` → `candidateToDqlSource` →
3271
+ * the DQL parser → `processSQL` before running anything. Every one of those
3272
+ * stages could fail on SQL the warehouse would have accepted, and each failure
3273
+ * destroyed the whole answer.
3274
+ *
3275
+ * Now the executed string is the source of truth and the artifact is derived
3276
+ * from it. Building the artifact is a SAVE concern, so a failure here degrades
3277
+ * the artifact to review-required and never throws: the user still gets their
3278
+ * answer, and only "Save as block" is affected.
3279
+ */
3280
+ const buildVerifiedGeneratedArtifact = (question, sql, executedSql, seed, warnings, resultColumns = [], dialect = 'duckdb') => {
3281
+ // Which result columns the user may promote to a filter input. Decided here
3282
+ // because it needs a real dialect parse of the SQL that actually ran.
3283
+ const filterableColumns = resultColumns.length > 0
3284
+ ? filterableResultColumns(executedSql, resultColumns, dialect)
3285
+ : [];
3286
+ const withCandidates = (artifact) => (filterableColumns.length > 0 ? { ...artifact, filterableColumns } : artifact);
3287
+ let artifact;
3288
+ try {
3289
+ artifact = generatedSqlArtifactContract(question, sql, seed);
3290
+ }
3291
+ catch (error) {
3292
+ warnings.push(`This answer could not be captured as a reusable DQL block: ${apiErrorMessage(error)}`);
3293
+ return undefined;
3294
+ }
3295
+ try {
3296
+ const invocation = prepareBlockInvocation({ source: artifact.source, question, surface: 'ask_ai' });
3297
+ const divergence = invocation.errors.length > 0
3298
+ ? invocation.errors.join(' ')
3299
+ : invocation.unresolvedParameters.length > 0
3300
+ ? `it still needs values for ${invocation.unresolvedParameters.join(', ')}`
3301
+ : undefined;
3302
+ if (divergence) {
3303
+ warnings.push(`The reusable DQL block for this answer needs review before reuse: ${divergence}`);
3304
+ // Keep the declared parameter contract even when the block cannot run
3305
+ // as-is. This branch is precisely "it still needs values for X", so the
3306
+ // parameter-input surface needs the list in order to ASK for them —
3307
+ // dropping it would leave the user a review-required block with no way
3308
+ // to see, or supply, what it wants.
3309
+ return withCandidates({
3310
+ ...artifact,
3311
+ trustState: 'review_required',
3312
+ compiledSql: executedSql,
3313
+ ...(invocation.parameters.length > 0 ? { parameters: invocation.parameters } : {}),
3314
+ ...(Object.keys(invocation.values).length > 0 ? { parameterValues: invocation.values } : {}),
3315
+ });
3316
+ }
3317
+ return withCandidates({
3318
+ ...artifact,
3319
+ compiledSql: executedSql,
3320
+ ...(invocation.parameters.length > 0 ? { parameters: invocation.parameters } : {}),
3321
+ ...(Object.keys(invocation.values).length > 0 ? { parameterValues: invocation.values } : {}),
3322
+ });
3323
+ }
3324
+ catch (error) {
3325
+ warnings.push(`The reusable DQL block for this answer needs review before reuse: ${apiErrorMessage(error)}`);
3326
+ return withCandidates({ ...artifact, trustState: 'review_required', compiledSql: executedSql });
3327
+ }
3328
+ };
3329
+ /**
3330
+ * Execute the model's SQL EXACTLY as validated — the same way a notebook cell
3331
+ * runs it. This is the fix for "the query Ask generated fails in Ask but runs
3332
+ * in the notebook": both paths now reach `executor.executeQuery` with the same
3333
+ * string, so the only remaining reasons to differ are the connection and the
3334
+ * governance gates, both of which report themselves honestly.
3335
+ */
3336
+ const executeGeneratedSqlDirect = async (question, sql, seed, executionConnection) => {
3337
+ const activeConnection = requireActiveConnection(executionConnection);
3338
+ const rowBound = clampAnalyticalRowBound(seed?.limit ?? 200);
3339
+ const trimmed = sql.trim().replace(/;\s*$/, '').trim();
3340
+ if (!trimmed)
3341
+ throw analyticalError('The generated SQL was empty.', { origin: 'host', stage: 'execute' });
3342
+ const readOnlyError = readOnlySqlValidationError(trimmed, 'Generated SQL', activeConnection.driver);
3343
+ if (readOnlyError) {
3344
+ throw analyticalError(readOnlyError, { origin: 'governance_gate', stage: 'validation', code: 'unsafe_sql' });
3345
+ }
3346
+ const prepared = prepareLocalExecution(trimmed, activeConnection, projectRoot, projectConfig);
3347
+ const bounded = buildRowBoundedSql(prepared.sql, rowBound, activeConnection.driver);
3348
+ const app = loadRuntimeApp(projectRoot, activePersonaAppId());
3349
+ const sourceDomain = seed?.source?.match(/\bdomain\s*=\s*"([^"]+)"/i)?.[1];
3350
+ assertAppAccess({ app, domain: sourceDomain ?? app?.domain, level: 'execute' });
3351
+ const rawResult = await executor.executeQuery(bounded.sql, [], runtimeVariables({}), prepared.connection);
3352
+ const normalized = normalizeQueryResult(rawResult);
3353
+ // The authoritative bound. `buildRowBoundedSql` may legitimately decline to
3354
+ // touch the statement, and only one of 15 drivers honours a maxRows hint, so
3355
+ // the host is the only layer correctness may depend on.
3356
+ const truncated = normalized.rows.length > rowBound;
3357
+ const rows = truncated ? normalized.rows.slice(0, rowBound) : normalized.rows;
3358
+ const warnings = [];
3359
+ const artifact = buildVerifiedGeneratedArtifact(question, trimmed, bounded.sql, seed, warnings, normalized.columns, activeConnection.driver);
3360
+ return {
3361
+ columns: normalized.columns,
3362
+ rows,
3363
+ rowCount: rows.length,
3364
+ executionTime: normalized.executionTime,
3365
+ sql: bounded.sql,
3366
+ ...(truncated ? { truncated: true } : {}),
3367
+ ...(artifact ? { dqlArtifact: { ...artifact, limit: rowBound } } : {}),
3368
+ ...(warnings.length > 0 ? { validationWarnings: warnings } : {}),
3369
+ executionReceipt: createDqlArtifactExecutionReceipt(artifact?.source ?? trimmed, bounded.sql, {}, { columns: normalized.columns, rows, rowCount: rows.length }),
3370
+ };
3371
+ };
3372
+ const executeGeneratedArtifactForAgent = async (question, sql, seed, executionConnection, executionConnectionName) => {
3373
+ // A seed that is already a certified/saved artifact keeps the DQL-first
3374
+ // path: there the `.dql` source IS the contract, and its parameters and
3375
+ // semantic refs must be compiled, not bypassed.
3376
+ if (seed && seed.kind !== 'sql_block') {
3377
+ return executeArtifactReferenceForAgent({ ...seed, limit: seed.limit ?? 200 }, question, executionConnection, executionConnectionName);
3378
+ }
3379
+ return executeGeneratedSqlDirect(question, sql, seed, executionConnection);
3157
3380
  };
3158
3381
  /**
3159
3382
  * EXP-001: execution host for the deliberately narrow non-governed lane.
@@ -3262,6 +3485,60 @@ export async function startLocalServer(opts) {
3262
3485
  return { proofs, sql: boundedSql, repairs, error: error instanceof Error ? error.message : String(error) };
3263
3486
  }
3264
3487
  };
3488
+ /**
3489
+ * Ask the warehouse whether specific named relations exist, AFTER the cached
3490
+ * catalog has already missed them.
3491
+ *
3492
+ * `getSchemaContextForAgent` deliberately returns nothing on a lexical miss —
3493
+ * "a lexical miss is not permission to rescan every visible schema". That is
3494
+ * right for BULK retrieval, but it also meant a relation the model named
3495
+ * explicitly, and that the notebook queries without complaint, was refused as
3496
+ * "outside the inspected metadata context" without the warehouse ever being
3497
+ * asked. A point lookup of up to four named `(schema, table)` pairs is not a
3498
+ * rescan.
3499
+ *
3500
+ * The payoff is honesty as much as recall: after this probe, a miss is
3501
+ * evidence. The refusal can say the relation is not visible to this
3502
+ * connection, rather than only that DQL had not looked at it.
3503
+ */
3504
+ const probeNamedRelationsForAgent = async (relations, probeConnection) => {
3505
+ const probeTarget = probeConnection ?? connection;
3506
+ if (!probeTarget)
3507
+ return undefined;
3508
+ // Same operator switch that governs live value grounding: a deployment that
3509
+ // wants zero live metadata traffic keeps it.
3510
+ if (resolveAgentRuntimeValueGrounding(projectConfig).mode !== 'safe_automatic')
3511
+ return undefined;
3512
+ const sql = buildNamedRelationProbeSql(relations);
3513
+ if (!sql)
3514
+ return undefined;
3515
+ const prepared = prepareLocalExecution(sql, probeTarget, projectRoot, projectConfig);
3516
+ const result = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
3517
+ const rows = normalizeQueryResult(result).rows;
3518
+ if (rows.length === 0)
3519
+ return undefined;
3520
+ const tables = buildAgentSchemaContext('', rows, { includeUnscored: true, limit: 8 });
3521
+ if (tables.length === 0)
3522
+ return undefined;
3523
+ return {
3524
+ relations: tables.map((table) => ({
3525
+ relation: table.relation,
3526
+ name: table.name,
3527
+ // Provenance is explicit: these columns came from a live lookup, not
3528
+ // from the immutable snapshot.
3529
+ source: 'scoped live probe',
3530
+ columns: table.columns.map((column) => ({ name: column.name, type: column.type })),
3531
+ columnCompleteness: 'complete',
3532
+ })),
3533
+ schemaContext: tables.map((table) => ({
3534
+ relation: table.relation,
3535
+ schema: table.schema,
3536
+ name: table.name,
3537
+ columns: table.columns,
3538
+ })),
3539
+ notes: tables.map((table) => `${table.relation} resolved by a scoped live probe`),
3540
+ };
3541
+ };
3265
3542
  const getSchemaContextForAgent = async (question, preparedContextPack, executionConnection, executionConnectionName) => {
3266
3543
  const schemaConnection = executionConnection ?? connection;
3267
3544
  const scanRuntimeSchema = async () => {
@@ -3277,7 +3554,7 @@ export async function startLocalServer(opts) {
3277
3554
  const activeScope = schemaConnection
3278
3555
  ? optionalActiveConnectionMetadataScope(projectRoot, projectConfig, schemaConnection, executionConnectionName ?? projectConfig.defaultConnectionName ?? 'default')
3279
3556
  : null;
3280
- const activeRuntimeSnapshot = latestRuntimeSchemaSnapshotForProject(projectRoot);
3557
+ const activeRuntimeSnapshot = latestRuntimeSchemaSnapshotForProject(projectRoot, executionConnectionName);
3281
3558
  const hasActivatedMetadataGeneration = Boolean(activeScope
3282
3559
  && activeRuntimeSnapshot?.scopeFingerprint === activeScope.scopeFingerprint
3283
3560
  && activeRuntimeSnapshot.status !== 'stale');
@@ -3289,9 +3566,6 @@ export async function startLocalServer(opts) {
3289
3566
  // row value must not turn a warm Ask into an information_schema scan over
3290
3567
  // thousands of enterprise tables; only field-scoped value probes are live.
3291
3568
  const enriched = await enrichAgentSchemaContextWithValueMatches(question, catalogContext, executor, schemaConnection, valueGrounding.searchSafeColumns);
3292
- if (!hasActivatedMetadataGeneration) {
3293
- recordAgentRuntimeSchemaSnapshot(projectRoot, catalogContext, 'catalog runtime schema');
3294
- }
3295
3569
  return enriched;
3296
3570
  }
3297
3571
  // An activated, scope-qualified warehouse generation is the complete warm
@@ -3307,7 +3581,7 @@ export async function startLocalServer(opts) {
3307
3581
  const enriched = valueGrounding.mode === 'safe_automatic'
3308
3582
  ? await enrichAgentSchemaContextWithValueMatches(question, schemaContext.ranked, executor, schemaConnection, valueGrounding.searchSafeColumns)
3309
3583
  : schemaContext.ranked;
3310
- recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext.snapshot, 'question-scoped information_schema search');
3584
+ recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext.snapshot, 'question-scoped information_schema search', executionConnectionName);
3311
3585
  return enriched;
3312
3586
  }
3313
3587
  catch {
@@ -6519,6 +6793,12 @@ export async function startLocalServer(opts) {
6519
6793
  res.end(serializeJSON({ ok: true }));
6520
6794
  return;
6521
6795
  }
6796
+ if (req.method === 'DELETE' && !action) {
6797
+ store.deleteThread(threadId);
6798
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
6799
+ res.end(serializeJSON({ ok: true }));
6800
+ return;
6801
+ }
6522
6802
  // Explicit promotion — the ONLY path from conversation into durable
6523
6803
  // memory ("remember this"). Deliberate user act, tagged for audit.
6524
6804
  if (req.method === 'POST' && action === 'promote') {
@@ -9884,7 +10164,7 @@ export async function startLocalServer(opts) {
9884
10164
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
9885
10165
  res.end(serializeJSON({
9886
10166
  scope,
9887
- status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint),
10167
+ status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint, connectionId),
9888
10168
  }));
9889
10169
  }
9890
10170
  catch (error) {
@@ -9904,7 +10184,7 @@ export async function startLocalServer(opts) {
9904
10184
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
9905
10185
  res.end(serializeJSON({
9906
10186
  scope,
9907
- status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint),
10187
+ status: warehouseMetadataStatus(projectRoot, scope.scopeFingerprint, connectionId),
9908
10188
  }));
9909
10189
  }
9910
10190
  catch (error) {
@@ -9933,7 +10213,7 @@ export async function startLocalServer(opts) {
9933
10213
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
9934
10214
  res.end(serializeJSON({
9935
10215
  scope: synced.scope,
9936
- status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint),
10216
+ status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint, connectionId),
9937
10217
  }));
9938
10218
  }
9939
10219
  catch (error) {
@@ -9959,7 +10239,7 @@ export async function startLocalServer(opts) {
9959
10239
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
9960
10240
  res.end(serializeJSON({
9961
10241
  scope: synced.scope,
9962
- status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint),
10242
+ status: warehouseMetadataStatus(projectRoot, synced.scope.scopeFingerprint, connectionId),
9963
10243
  }));
9964
10244
  }
9965
10245
  catch (error) {
@@ -9996,7 +10276,7 @@ export async function startLocalServer(opts) {
9996
10276
  connectorStatus,
9997
10277
  metadataScope,
9998
10278
  metadataStatus: metadataScope
9999
- ? warehouseMetadataStatus(projectRoot, metadataScope.scopeFingerprint)
10279
+ ? warehouseMetadataStatus(projectRoot, metadataScope.scopeFingerprint, defaultKey)
10000
10280
  : warehouseMetadataStatus(projectRoot),
10001
10281
  }));
10002
10282
  return;
@@ -12107,6 +12387,60 @@ export async function startLocalServer(opts) {
12107
12387
  res.end(readFileSync(filePath));
12108
12388
  return;
12109
12389
  }
12390
+ /**
12391
+ * Promote a RESULT COLUMN into a runtime filter input on a generated answer.
12392
+ *
12393
+ * The SQL edit happens here, not in the browser: deciding where a predicate
12394
+ * may go needs a real dialect parse, and shipping a SQL parser to the client
12395
+ * to guess at it is exactly how a filter ends up silently attached to the
12396
+ * wrong query. The client sends a column; it gets back an artifact.
12397
+ */
12398
+ if (req.method === 'POST' && path === '/api/dql/artifacts/add-filter') {
12399
+ try {
12400
+ const body = await readJSON(req);
12401
+ const artifact = normalizeDqlArtifactReference(body.artifact);
12402
+ const column = typeof body.column === 'string' ? body.column.trim() : '';
12403
+ const value = body.value;
12404
+ const resultColumns = Array.isArray(body.resultColumns)
12405
+ ? body.resultColumns.filter((entry) => typeof entry === 'string')
12406
+ : [];
12407
+ if (!artifact)
12408
+ throw new Error('A DQL artifact is required to add a filter input.');
12409
+ if (!column)
12410
+ throw new Error('Choose a result column to filter on.');
12411
+ if (value === undefined || value === null || String(value).trim() === '') {
12412
+ throw new Error('Choose a default filter value before adding the input.');
12413
+ }
12414
+ const sql = extractBlockStudioSql(artifact.source);
12415
+ if (!sql)
12416
+ throw new Error('This answer has no editable SQL to filter.');
12417
+ const activeConnection = requireActiveConnection();
12418
+ const existingNames = (artifact.parameters ?? []).map((parameter) => parameter.name);
12419
+ const added = addSqlResultFilter(sql, resultColumns, column, existingNames, activeConnection.driver);
12420
+ const source = replaceBlockStudioSql(artifact.source, added.sql, added.parameterName, value);
12421
+ // Re-parse so the returned contract is what the block ACTUALLY declares,
12422
+ // rather than what this endpoint believes it wrote.
12423
+ const invocation = prepareBlockInvocation({ source, surface: 'ask_ai' });
12424
+ if (invocation.errors.length > 0)
12425
+ throw new Error(invocation.errors.join(' '));
12426
+ const { executionReceipt: _receipt, compiledSql: _compiled, ...unexecuted } = artifact;
12427
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
12428
+ res.end(serializeJSON({
12429
+ artifact: {
12430
+ ...unexecuted,
12431
+ source,
12432
+ parameters: invocation.parameters,
12433
+ parameterValues: { ...(artifact.parameterValues ?? {}), [added.parameterName]: value },
12434
+ },
12435
+ parameterName: added.parameterName,
12436
+ }));
12437
+ }
12438
+ catch (error) {
12439
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
12440
+ res.end(serializeJSON({ error: apiErrorMessage(error) }));
12441
+ }
12442
+ return;
12443
+ }
12110
12444
  if (req.method === 'POST' && path === '/api/dql/artifacts/execute') {
12111
12445
  try {
12112
12446
  const body = await readJSON(req);
@@ -12524,7 +12858,11 @@ table: ${table}${tagList}
12524
12858
  return;
12525
12859
  }
12526
12860
  const content = readFileSync(filePath);
12527
- res.writeHead(200, { 'Content-Type': contentTypeFor(filePath) });
12861
+ res.writeHead(200, {
12862
+ 'Content-Type': contentTypeFor(filePath),
12863
+ 'Cache-Control': staticResponseCacheControl(filePath),
12864
+ 'Content-Length': content.byteLength,
12865
+ });
12528
12866
  res.end(content);
12529
12867
  })().catch((error) => {
12530
12868
  // API-007/E2E-014: no rejected request promise may terminate the local
@@ -13480,10 +13818,10 @@ export function compileBlockStudioManifest(projectRoot, projectConfig = loadProj
13480
13818
  _lineageCache.delete(projectRoot);
13481
13819
  return manifest;
13482
13820
  }
13483
- async function refreshLocalMetadataCatalog(projectRoot, manifest, semanticLayer) {
13821
+ async function refreshLocalMetadataCatalog(projectRoot, manifest, semanticLayer, force = true) {
13484
13822
  try {
13485
13823
  await ensureMetadataCatalogFresh(projectRoot, {
13486
- force: true,
13824
+ force,
13487
13825
  ...(manifest ? { manifest } : {}),
13488
13826
  ...(semanticLayer ? { semanticLayer } : {}),
13489
13827
  });
@@ -15061,18 +15399,80 @@ const AGENT_PREVIEW_FORBIDDEN_SQL = [
15061
15399
  'update',
15062
15400
  'vacuum',
15063
15401
  ];
15402
+ /**
15403
+ * Run DQL's own block-compiler machinery with an honest origin. Anything these
15404
+ * stages throw is a DQL-artifact failure, never a warehouse failure.
15405
+ */
15406
+ function withAnalyticalCompilationOrigin(stage, fn) {
15407
+ return withAnalyticalErrorOriginSync({ origin: 'dql_compilation', stage }, fn);
15408
+ }
15409
+ export function clampAnalyticalRowBound(rowLimit) {
15410
+ return Number.isFinite(rowLimit) ? Math.max(1, Math.min(10_000, Math.floor(rowLimit))) : 200;
15411
+ }
15412
+ /**
15413
+ * Dialects where a bare trailing `LIMIT n` is valid on a top-level SELECT.
15414
+ * MSSQL/Fabric need `TOP`/`OFFSET…FETCH` in positions that depend on the rest
15415
+ * of the statement, so we never rewrite there — the host-side truncation below
15416
+ * is the guarantee, and appending is only an optimization.
15417
+ */
15418
+ const TRAILING_LIMIT_DIALECTS = new Set([
15419
+ 'duckdb', 'postgres', 'postgresql', 'redshift', 'snowflake', 'bigquery',
15420
+ 'databricks', 'trino', 'presto', 'clickhouse', 'mysql', 'mariadb', 'sqlite', 'athena',
15421
+ ]);
15422
+ /**
15423
+ * Bound a result set WITHOUT rewriting the statement's shape.
15424
+ *
15425
+ * The previous approach wrapped every Ask query as
15426
+ * `SELECT * FROM (<sql>) AS dql_agent_preview LIMIT n`. That is not a no-op:
15427
+ * - `SELECT * FROM (WITH x AS (…) SELECT …) AS t` is a syntax error on
15428
+ * MSSQL/Fabric, and the model emits CTEs constantly;
15429
+ * - duplicate output column names are legal at top level but not as a
15430
+ * derived table on several engines;
15431
+ * - the inner `ORDER BY` stops being guaranteed, silently breaking top-N.
15432
+ * All three produced "fails in Ask, runs in the notebook" for identical SQL.
15433
+ *
15434
+ * Appending is skipped whenever it cannot be proven safe. Callers must still
15435
+ * truncate rows host-side — that, not this, is what actually enforces the bound.
15436
+ */
15437
+ export function buildRowBoundedSql(sql, rowLimit, dialect = 'duckdb') {
15438
+ const trimmed = sql.trim().replace(/;\s*$/, '').trim();
15439
+ if (!rowLimit)
15440
+ return { sql: trimmed, outcome: 'skipped', reason: 'no row bound requested' };
15441
+ if (!TRAILING_LIMIT_DIALECTS.has(dialect.toLowerCase())) {
15442
+ return { sql: trimmed, outcome: 'skipped', reason: `trailing LIMIT is not portable on ${dialect}` };
15443
+ }
15444
+ const analysis = analyzeSqlReferences(trimmed, dialect);
15445
+ if (!analysis.parsed) {
15446
+ return { sql: trimmed, outcome: 'skipped', reason: 'statement did not parse; not rewriting it' };
15447
+ }
15448
+ const statements = analysis.statementTypes ?? [];
15449
+ if (statements.length !== 1 || statements[0]?.toLowerCase() !== 'select') {
15450
+ return { sql: trimmed, outcome: 'skipped', reason: 'not a single SELECT statement' };
15451
+ }
15452
+ // Any existing bound — LIMIT, FETCH FIRST/NEXT, TOP — wins. Detection is
15453
+ // deliberately greedy: a false positive costs nothing (host truncation still
15454
+ // applies), a false negative would override the author's own bound.
15455
+ const scan = stripSqlStringsAndComments(trimmed);
15456
+ if (/\blimit\b/i.test(scan) || /\bfetch\s+(first|next)\b/i.test(scan) || /\btop\s*\(?\s*\d/i.test(scan)) {
15457
+ return { sql: trimmed, outcome: 'existing', reason: 'statement already carries its own bound' };
15458
+ }
15459
+ return { sql: `${trimmed}\nLIMIT ${clampAnalyticalRowBound(rowLimit)}`, outcome: 'appended' };
15460
+ }
15461
+ /**
15462
+ * @deprecated Kept for one release so any out-of-tree caller keeps working.
15463
+ * Use {@link executeAnalyticalSql}, which bounds rows without wrapping.
15464
+ */
15064
15465
  export function buildAgentPreviewSql(sql, rowLimit = 200) {
15065
15466
  const trimmed = sql.trim();
15066
15467
  if (!trimmed)
15067
- throw new Error('Generated SQL preview is empty.');
15068
- const boundedRowLimit = Number.isFinite(rowLimit)
15069
- ? Math.max(1, Math.min(10_000, Math.floor(rowLimit)))
15070
- : 200;
15468
+ throw analyticalError('Generated SQL preview is empty.', { origin: 'host', stage: 'execute' });
15071
15469
  const withoutTrailingSemicolon = trimmed.replace(/;\s*$/, '').trim();
15072
15470
  const readOnlyError = readOnlySqlValidationError(withoutTrailingSemicolon, 'Generated SQL preview');
15073
- if (readOnlyError)
15074
- throw new Error(readOnlyError);
15075
- return `SELECT * FROM (\n${withoutTrailingSemicolon}\n) AS dql_agent_preview LIMIT ${boundedRowLimit}`;
15471
+ // A deliberate read-only refusal, not a warehouse rejection.
15472
+ if (readOnlyError) {
15473
+ throw analyticalError(readOnlyError, { origin: 'governance_gate', stage: 'validation', code: 'unsafe_sql' });
15474
+ }
15475
+ return buildRowBoundedSql(withoutTrailingSemicolon, rowLimit).sql;
15076
15476
  }
15077
15477
  /**
15078
15478
  * EXP-001 preflight for model-authored exploratory SQL.
@@ -15460,7 +15860,7 @@ function summarizeExploratoryJoinProbe(rows, leftRelation, leftColumn, rightRela
15460
15860
  };
15461
15861
  return `${leftRelation}.${leftColumn} ↔ ${rightRelation}.${rightColumn}: sample left=${number('left_sample_rows')}, right=${number('right_sample_rows')}, joined=${number('joined_rows')}, unmatched left/right=${number('unmatched_left_sample_rows')}/${number('unmatched_right_sample_rows')}, max matches left/right=${number('max_matches_per_left_key')}/${number('max_matches_per_right_key')}`;
15462
15862
  }
15463
- function readOnlySqlValidationError(sql, subject) {
15863
+ function readOnlySqlValidationError(sql, subject, dialect = 'duckdb') {
15464
15864
  const internalRelationError = internalDqlRelationIdValidationError(sql, subject);
15465
15865
  if (internalRelationError)
15466
15866
  return internalRelationError;
@@ -15471,6 +15871,22 @@ function readOnlySqlValidationError(sql, subject) {
15471
15871
  if (scanSql.includes(';')) {
15472
15872
  return `${subject} only supports one statement.`;
15473
15873
  }
15874
+ // Prefer the parser. The keyword blacklist matches IDENTIFIERS, so a CTE or
15875
+ // column alias legitimately named `set`, `load`, `merge`, or `copy` was
15876
+ // rejected as if it were DDL — a false failure on SQL the notebook runs fine.
15877
+ // The blacklist stays as the conservative fallback for statements the dialect
15878
+ // parser cannot read.
15879
+ const analysis = analyzeSqlReferences(scanSql, dialect);
15880
+ if (analysis.parsed) {
15881
+ const statements = analysis.statementTypes ?? [];
15882
+ const offending = statements.find((statement) => statement.toLowerCase() !== 'select');
15883
+ if (offending) {
15884
+ return `${subject} rejected unsupported statement keyword: ${offending.toUpperCase()}.`;
15885
+ }
15886
+ if (statements.length > 1)
15887
+ return `${subject} only supports one statement.`;
15888
+ return null;
15889
+ }
15474
15890
  const forbiddenPattern = new RegExp(`\\b(${AGENT_PREVIEW_FORBIDDEN_SQL.join('|')})\\b`, 'i');
15475
15891
  const forbidden = scanSql.match(forbiddenPattern)?.[1];
15476
15892
  if (forbidden) {
@@ -15691,6 +16107,16 @@ function contentTypeFor(filePath) {
15691
16107
  return 'text/plain; charset=utf-8';
15692
16108
  }
15693
16109
  }
16110
+ export function staticResponseCacheControl(filePath) {
16111
+ // Vite fingerprints every generated asset filename, so a new DQL build gets
16112
+ // a new URL and the previous file is safe to retain indefinitely. Keep the
16113
+ // HTML shell revalidated so it can point at the current asset hashes.
16114
+ if (extname(filePath).toLowerCase() === '.html')
16115
+ return 'no-cache';
16116
+ return filePath.split(sep).includes('assets')
16117
+ ? 'public, max-age=31536000, immutable'
16118
+ : 'no-cache';
16119
+ }
15694
16120
  function listProjectFiles(projectRoot) {
15695
16121
  const allowed = new Set(['.dql', '.sql', '.md', '.json', '.csv', '.yaml', '.yml', '.dqlnb']);
15696
16122
  const files = [];
@@ -16132,11 +16558,11 @@ function parseSemanticBlockConfig(source) {
16132
16558
  limit: limitMatch ? Number.parseInt(limitMatch[1], 10) : undefined,
16133
16559
  };
16134
16560
  }
16135
- export async function resolveSemanticTableMapping(executor, connection, semanticLayer, projectRoot) {
16561
+ export async function resolveSemanticTableMapping(executor, connection, semanticLayer, projectRoot, connectionId) {
16136
16562
  if (!semanticLayer)
16137
16563
  return undefined;
16138
16564
  if (projectRoot) {
16139
- const snapshot = latestRuntimeSchemaSnapshotForProject(projectRoot);
16565
+ const snapshot = latestRuntimeSchemaSnapshotForProject(projectRoot, connectionId);
16140
16566
  if (snapshot?.scopeFingerprint && snapshot.tables.length) {
16141
16567
  return buildSemanticTableMapping(semanticLayer, snapshot.tables.map((table) => ({
16142
16568
  table_catalog: table.catalogOrDatabase,
@@ -16158,16 +16584,30 @@ export async function resolveSemanticTableMapping(executor, connection, semantic
16158
16584
  }
16159
16585
  }
16160
16586
  export function buildSemanticTableMapping(semanticLayer, rows) {
16161
- const dbTableNames = new Set();
16162
- const schemaQualified = new Map();
16587
+ const normalizeIdentifierPart = (value) => value
16588
+ .trim()
16589
+ .replace(/^["'`\[]|["'`\]]$/g, '')
16590
+ .toLowerCase();
16591
+ const normalizeRelation = (value) => value
16592
+ .split('.')
16593
+ .map(normalizeIdentifierPart)
16594
+ .filter(Boolean)
16595
+ .join('.');
16596
+ const candidates = [];
16163
16597
  for (const row of rows) {
16598
+ const catalog = String(row['table_catalog'] ?? '');
16164
16599
  const schema = String(row['table_schema'] ?? '');
16165
16600
  const name = String(row['table_name'] ?? '');
16166
- const relation = typeof row['table_relation'] === 'string' ? row['table_relation'] : undefined;
16601
+ const relation = typeof row['table_relation'] === 'string' && row['table_relation'].trim()
16602
+ ? row['table_relation']
16603
+ : [catalog, schema, name].filter(Boolean).join('.');
16167
16604
  if (!name)
16168
16605
  continue;
16169
- dbTableNames.add(name);
16170
- schemaQualified.set(name, relation ?? (schema ? `${schema}.${name}` : name));
16606
+ candidates.push({
16607
+ name: normalizeIdentifierPart(name),
16608
+ relation,
16609
+ normalizedRelation: normalizeRelation(relation),
16610
+ });
16171
16611
  }
16172
16612
  const tableMapping = {};
16173
16613
  const allSemanticTables = new Set();
@@ -16182,9 +16622,19 @@ export function buildSemanticTableMapping(semanticLayer, rows) {
16182
16622
  for (const semTable of allSemanticTables) {
16183
16623
  if (!semTable)
16184
16624
  continue;
16185
- if (dbTableNames.has(semTable) && schemaQualified.has(semTable)) {
16186
- tableMapping[semTable] = schemaQualified.get(semTable);
16187
- }
16625
+ const normalizedSemantic = normalizeRelation(semTable);
16626
+ const semanticLeaf = normalizedSemantic.split('.').at(-1);
16627
+ if (!semanticLeaf)
16628
+ continue;
16629
+ const qualifiedMatches = candidates.filter((candidate) => candidate.normalizedRelation === normalizedSemantic
16630
+ || candidate.normalizedRelation.endsWith(`.${normalizedSemantic}`));
16631
+ const leafMatches = candidates.filter((candidate) => candidate.name === semanticLeaf);
16632
+ const matches = qualifiedMatches.length > 0 ? qualifiedMatches : leafMatches;
16633
+ // Never guess when the same semantic leaf exists in several schemas. A
16634
+ // qualified semantic relation can disambiguate; otherwise the semantic
16635
+ // runtime must surface the modeling gap instead of executing the wrong one.
16636
+ if (matches.length === 1)
16637
+ tableMapping[semTable] = matches[0].relation;
16188
16638
  }
16189
16639
  return Object.keys(tableMapping).length > 0 ? tableMapping : undefined;
16190
16640
  }
@@ -18738,7 +19188,7 @@ function agentResultToSynthesisPreview(result) {
18738
19188
  }
18739
19189
  /** Up to three example questions built from real catalog blocks (falls back to generic asks). */
18740
19190
  function buildConversationSuggestions(projectRoot, kind) {
18741
- if (kind === 'gratitude')
19191
+ if (kind === 'gratitude' || kind === 'answer_explanation')
18742
19192
  return [];
18743
19193
  let names = [];
18744
19194
  try {
@@ -18834,9 +19284,9 @@ export function buildConversationContextRecap(context) {
18834
19284
  return undefined;
18835
19285
  return `We were talking about ${contextBits.join('. ')}.`;
18836
19286
  }
18837
- function buildConversationTurnsRecap(context) {
19287
+ function latestSubstantiveConversationTurn(context) {
18838
19288
  const turns = Array.isArray(context.turns)
18839
- ? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-3)
19289
+ ? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-6)
18840
19290
  : [];
18841
19291
  if (turns.length === 0)
18842
19292
  return undefined;
@@ -18848,9 +19298,120 @@ function buildConversationTurnsRecap(context) {
18848
19298
  // subject of the next recap. Prefer the newest analytical/authoring turn and
18849
19299
  // fall back to the active turn only when the thread contains conversation alone.
18850
19300
  const substantiveTurns = turns.filter((turn) => agentRunString(turn.route) !== 'conversation');
18851
- const selected = active && agentRunString(active.route) !== 'conversation'
19301
+ return active && agentRunString(active.route) !== 'conversation'
18852
19302
  ? active
18853
19303
  : substantiveTurns[substantiveTurns.length - 1] ?? active ?? turns[turns.length - 1];
19304
+ }
19305
+ /**
19306
+ * Explain the latest successful answer from its persisted artifact contract.
19307
+ * This is intentionally deterministic and never executes SQL: missing evidence
19308
+ * is reported as missing instead of being guessed or converted into a new metric.
19309
+ */
19310
+ export function buildPriorAnswerExplanation(question, context) {
19311
+ if (!context)
19312
+ return undefined;
19313
+ const selected = latestSubstantiveConversationTurn(context);
19314
+ const artifact = agentRunRecord(selected?.dqlArtifact) ?? agentRunRecord(context.dqlArtifact);
19315
+ const result = agentRunRecord(selected?.result);
19316
+ const metrics = agentRunStringArray(artifact?.metrics
19317
+ ?? selected?.requestedMeasures
19318
+ ?? context.priorMeasures);
19319
+ const dimensions = agentRunStringArray(artifact?.dimensions
19320
+ ?? selected?.requestedDimensions
19321
+ ?? context.requestedDimensions);
19322
+ const timeDimension = agentRunRecord(artifact?.timeDimension);
19323
+ const timeName = agentRunString(timeDimension?.name);
19324
+ const granularity = agentRunString(timeDimension?.granularity);
19325
+ const filters = formatPriorAnswerFilters(artifact?.filters, selected?.requestedFilters ?? context.requestedFilters);
19326
+ const topN = typeof selected?.topN === 'number'
19327
+ ? selected.topN
19328
+ : typeof context.priorLimit === 'number'
19329
+ ? context.priorLimit
19330
+ : undefined;
19331
+ const source = agentRunString(artifact?.name)
19332
+ ?? agentRunString(selected?.sourceCertifiedBlock)
19333
+ ?? agentRunString(context.sourceCertifiedBlock);
19334
+ const columns = agentRunStringArray(result?.columns ?? context.resultColumns).slice(0, 8);
19335
+ const metricText = metrics.length > 0 ? metrics.map(humanizeArtifactField).join(', ') : undefined;
19336
+ const dimensionText = dimensions.length > 0 ? dimensions.map(humanizeArtifactField).join(', ') : undefined;
19337
+ const normalizedQuestion = question.toLowerCase();
19338
+ if (/\b(?:daily|monthly|weekly|quarterly|yearly|annual|grain|period|timeframe|date range)\b/.test(normalizedQuestion)) {
19339
+ if (granularity) {
19340
+ return [
19341
+ `The previous result uses${metricText ? ` ${metricText}` : ' the recorded metric'} and is grouped`
19342
+ + `${timeName ? ` by ${humanizeArtifactField(timeName)}` : ''} at a ${formatTimeGranularity(granularity)} grain.`,
19343
+ filters.length > 0 ? `Applied filters: ${filters.join('; ')}.` : '',
19344
+ ].filter(Boolean).join(' ');
19345
+ }
19346
+ return [
19347
+ `The previous result does not declare a daily or monthly time grain${metricText ? ` for ${metricText}` : ''}.`,
19348
+ dimensionText ? `Its recorded grouping is ${dimensionText}.` : 'It is an aggregate across the applied result scope.',
19349
+ filters.length > 0 ? `Applied filters: ${filters.join('; ')}.` : '',
19350
+ 'If you want a new monthly or daily breakdown, ask me to show or group it that way.',
19351
+ ].filter(Boolean).join(' ');
19352
+ }
19353
+ if (/\b(?:which|what)\s+(?:metric|measure)\b|\bmetric\s+(?:did|does|was|is)\b/.test(normalizedQuestion)) {
19354
+ return metricText
19355
+ ? `The previous answer used ${metricText}${source ? ` from ${humanizeArtifactField(source)}` : ''}.`
19356
+ : 'The previous answer did not retain a qualified metric identifier, so I cannot name one without rerunning or reviewing its DQL.';
19357
+ }
19358
+ if (/\bfilter|include|exclude\b/.test(normalizedQuestion)) {
19359
+ return filters.length > 0
19360
+ ? `The previous answer applied these filters: ${filters.join('; ')}.`
19361
+ : 'The previous artifact does not record any explicit filters.';
19362
+ }
19363
+ const facts = [
19364
+ metricText ? `Metric: ${metricText}.` : '',
19365
+ dimensionText ? `Grouped by: ${dimensionText}.` : '',
19366
+ granularity ? `Time grain: ${formatTimeGranularity(granularity)}${timeName ? ` on ${humanizeArtifactField(timeName)}` : ''}.` : '',
19367
+ filters.length > 0 ? `Filters: ${filters.join('; ')}.` : '',
19368
+ topN ? `Limit: top ${topN}.` : '',
19369
+ source ? `Source: ${humanizeArtifactField(source)}.` : '',
19370
+ columns.length > 0 ? `Returned fields: ${columns.map(humanizeArtifactField).join(', ')}.` : '',
19371
+ ].filter(Boolean);
19372
+ return facts.length > 0
19373
+ ? facts.join(' ')
19374
+ : 'The previous answer did not retain enough artifact metadata to explain its metric, filters, or grain safely.';
19375
+ }
19376
+ function formatPriorAnswerFilters(primary, fallback) {
19377
+ const fromArtifact = Array.isArray(primary)
19378
+ ? primary.map(agentRunRecord).filter((filter) => Boolean(filter)).flatMap((filter) => {
19379
+ const dimension = agentRunString(filter.dimension);
19380
+ const operator = agentRunString(filter.operator) ?? 'equals';
19381
+ const values = agentRunStringArray(filter.values);
19382
+ return dimension && values.length > 0
19383
+ ? [`${humanizeArtifactField(dimension)} ${operator.replace(/_/g, ' ')} ${values.join(', ')}`]
19384
+ : [];
19385
+ })
19386
+ : [];
19387
+ return fromArtifact.length > 0
19388
+ ? fromArtifact
19389
+ : agentRunStringArray(fallback).map((value) => value.replace(/_/g, ' '));
19390
+ }
19391
+ function humanizeArtifactField(value) {
19392
+ return value
19393
+ .split('.')
19394
+ .at(-1)
19395
+ .replace(/::/g, ' ')
19396
+ .replace(/[_-]+/g, ' ')
19397
+ .replace(/\s+/g, ' ')
19398
+ .trim();
19399
+ }
19400
+ function formatTimeGranularity(value) {
19401
+ const normalized = value.trim().toLowerCase();
19402
+ return {
19403
+ day: 'daily',
19404
+ week: 'weekly',
19405
+ month: 'monthly',
19406
+ quarter: 'quarterly',
19407
+ year: 'yearly',
19408
+ }[normalized] ?? humanizeArtifactField(value);
19409
+ }
19410
+ function buildConversationTurnsRecap(context) {
19411
+ const turns = Array.isArray(context.turns)
19412
+ ? context.turns.map(agentRunRecord).filter((turn) => Boolean(turn)).slice(-3)
19413
+ : [];
19414
+ const selected = latestSubstantiveConversationTurn(context);
18854
19415
  if (!selected)
18855
19416
  return undefined;
18856
19417
  const result = agentRunRecord(selected.result);
@@ -21433,11 +21994,36 @@ export function runtimeSnapshotStale(projectRoot, maxAgeMs = RUNTIME_SNAPSHOT_MA
21433
21994
  return false;
21434
21995
  }
21435
21996
  }
21436
- function recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext, source) {
21997
+ /**
21998
+ * Resolve only metadata captured for the connection executing this Ask run.
21999
+ * Returning an explicit empty snapshot is a correctness boundary: callers pass
22000
+ * it into the context builder so it cannot silently fall back to another
22001
+ * connection's project-global runtime FTS rows.
22002
+ */
22003
+ export function runtimeSchemaSnapshotForAgentConnection(projectRoot, connectionId, expectedScopeFingerprint) {
22004
+ const snapshot = latestRuntimeSchemaSnapshotForProject(projectRoot, connectionId);
22005
+ const current = Boolean(snapshot
22006
+ && snapshot.status !== 'stale'
22007
+ && (!expectedScopeFingerprint
22008
+ || !snapshot.scopeFingerprint
22009
+ || snapshot.scopeFingerprint === expectedScopeFingerprint));
22010
+ return current
22011
+ ? snapshot
22012
+ : {
22013
+ version: 1,
22014
+ connectionId,
22015
+ status: 'partial',
22016
+ source: 'selected connection has no current runtime metadata',
22017
+ tables: [],
22018
+ };
22019
+ }
22020
+ function recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext, source, connectionId) {
21437
22021
  if (schemaContext.length === 0)
21438
22022
  return;
21439
22023
  try {
21440
22024
  recordRuntimeSchemaSnapshot(projectRoot, {
22025
+ ...(connectionId ? { connectionId } : {}),
22026
+ status: 'partial',
21441
22027
  source,
21442
22028
  tables: schemaContext.map((table) => ({
21443
22029
  relation: table.relation,
@@ -22332,6 +22918,51 @@ const RUNTIME_SCHEMA_SEARCH_STOPWORDS = new Set([
22332
22918
  * execution target. Tokens are reduced to safe identifier characters before
22333
22919
  * interpolation, so user text cannot become SQL syntax.
22334
22920
  */
22921
+ /**
22922
+ * Point-lookup of NAMED relations in `information_schema.columns`.
22923
+ *
22924
+ * This is deliberately not `buildRuntimeSchemaSearchSql`. That one matches
22925
+ * `LOWER(table_name) LIKE '%term%'` across every visible schema, which is the
22926
+ * unbounded rescan the "a lexical miss is not permission to rescan" policy
22927
+ * exists to prevent. This one asks about specific `(schema, table)` pairs the
22928
+ * model actually referenced, with equality predicates — a metadata point
22929
+ * lookup, not a scan.
22930
+ *
22931
+ * Returns null when nothing safe to ask about survives normalization.
22932
+ */
22933
+ export function buildNamedRelationProbeSql(relations, maxRelations = 4) {
22934
+ const predicates = [];
22935
+ const seen = new Set();
22936
+ for (const raw of relations) {
22937
+ const parts = raw.replace(/["`\[\]]/g, '').trim().split('.').filter(Boolean);
22938
+ const table = parts.at(-1);
22939
+ // Identifiers only — never interpolate anything that could carry SQL.
22940
+ if (!table || !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(table))
22941
+ continue;
22942
+ const schema = parts.length >= 2 ? parts.at(-2) : undefined;
22943
+ if (schema && !/^[A-Za-z_][A-Za-z0-9_$]*$/.test(schema))
22944
+ continue;
22945
+ const key = `${schema ?? ''}.${table}`.toLowerCase();
22946
+ if (seen.has(key))
22947
+ continue;
22948
+ seen.add(key);
22949
+ predicates.push(schema
22950
+ ? `(LOWER(table_schema) = '${schema.toLowerCase()}' AND LOWER(table_name) = '${table.toLowerCase()}')`
22951
+ : `(LOWER(table_name) = '${table.toLowerCase()}')`);
22952
+ if (predicates.length >= maxRelations)
22953
+ break;
22954
+ }
22955
+ if (predicates.length === 0)
22956
+ return null;
22957
+ return `SELECT table_schema, table_name, column_name, data_type
22958
+ FROM information_schema.columns
22959
+ WHERE UPPER(table_schema) NOT IN ('INFORMATION_SCHEMA', 'PG_CATALOG')
22960
+ AND (
22961
+ ${predicates.map((predicate) => ` ${predicate}`).join(' OR\n')}
22962
+ )
22963
+ ORDER BY table_schema, table_name, ordinal_position
22964
+ LIMIT 400`;
22965
+ }
22335
22966
  export function buildRuntimeSchemaSearchSql(question) {
22336
22967
  const terms = Array.from(agentSchemaTokens(question))
22337
22968
  .map((term) => term.toLowerCase().replace(/[^a-z0-9_]/g, ''))