@duckcodeailabs/dql-cli 1.7.1 → 1.8.0

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 (39) hide show
  1. package/README.md +20 -3
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +69 -4
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/assets/dql-notebook/assets/index-siDRLswd.js +5880 -0
  6. package/dist/assets/dql-notebook/index.html +1 -1
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/doctor.js +43 -10
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/init.d.ts.map +1 -1
  11. package/dist/commands/init.js +7 -4
  12. package/dist/commands/init.js.map +1 -1
  13. package/dist/llm/answer-loop-tools.js +17 -4
  14. package/dist/llm/answer-loop-tools.js.map +1 -1
  15. package/dist/llm/providers/dql-agent-provider.d.ts +1 -0
  16. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  17. package/dist/llm/providers/dql-agent-provider.js +143 -26
  18. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  19. package/dist/llm/types.d.ts +13 -0
  20. package/dist/llm/types.d.ts.map +1 -1
  21. package/dist/local-runtime.d.ts +28 -0
  22. package/dist/local-runtime.d.ts.map +1 -1
  23. package/dist/local-runtime.js +825 -167
  24. package/dist/local-runtime.js.map +1 -1
  25. package/dist/npm-runtime.d.ts +24 -0
  26. package/dist/npm-runtime.d.ts.map +1 -0
  27. package/dist/npm-runtime.js +72 -0
  28. package/dist/npm-runtime.js.map +1 -0
  29. package/dist/package.json +10 -10
  30. package/dist/providers/subscription-cli.d.ts +1 -0
  31. package/dist/providers/subscription-cli.d.ts.map +1 -1
  32. package/dist/providers/subscription-cli.js +40 -8
  33. package/dist/providers/subscription-cli.js.map +1 -1
  34. package/dist/settings/provider-settings.d.ts +2 -0
  35. package/dist/settings/provider-settings.d.ts.map +1 -1
  36. package/dist/settings/provider-settings.js +16 -7
  37. package/dist/settings/provider-settings.js.map +1 -1
  38. package/package.json +11 -11
  39. package/dist/assets/dql-notebook/assets/index-4DbeLWHA.js +0 -5876
@@ -4,19 +4,19 @@ import { createServer } from "node:http";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, watch, writeFileSync, } from "node:fs";
5
5
  import { createRequire } from "node:module";
6
6
  import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { dirname, extname, join, normalize, relative, resolve, } from "node:path";
8
9
  import { Readable } from "node:stream";
9
10
  import { buildMixedSourceWarehouseFallbackSql, findMentionedNotebookDataset, planMixedSourceNotebookSql, planMixedSourceSql, } from "./mixed-source-sql.js";
10
- import Anthropic from "@anthropic-ai/sdk";
11
- import OpenAI from "openai";
11
+ import { resolveNpmInvocation } from './npm-runtime.js';
12
12
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, hasStandaloneSemanticRef, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
13
13
  import { loadSemanticLayerFromDir, normalizeDqlArtifactReference, serializeMetricDefinitionToYaml, resolveSemanticLayerAsync, resolveRepoSource, getDialect, Parser, NodeKind, blockParameterDefinitions, buildLineageGraph, buildManifest, collectInputFiles, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryBusiness360, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, domainFolderSlug, previewModelingChange, applyModelingChange, previewDbtSourcePatch, applyDbtSourcePatch, loadDomainPackageRegistry, loadDbtNodeAuthoringDetail, relationshipValidationProofFingerprint, discoverDbtDomains, renderDomainDeclaration, ProjectSnapshotService, analyzeSqlReferences, } from '@duckcodeailabs/dql-core';
14
14
  import { load as loadYaml } from 'js-yaml';
15
15
  import { listBlockTemplates } from './block-templates.js';
16
16
  import { getRunner as getLLMRunner } from './llm/index.js';
17
- import { createDqlAgentProviderRunner } from './llm/providers/dql-agent-provider.js';
17
+ import { createDqlAgentProviderRunner, resolveAgentFollowUpContext } from './llm/providers/dql-agent-provider.js';
18
18
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
19
- import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, probeLocalOllamaEmbeddings, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
19
+ import { ClaudeProvider, ConversationStore, advanceThreadState, buildConversationSnapshot, recallRelevantTurns, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildAnalysisQuestionPlan, buildLocalContextPack, toAgentRetrievalEvidence, defaultConversationPath, defaultMemoryPath, ensureDefaultMemoryFiles, ensureAgentProjectReady, isAgentProjectIndexReady, currentMetadataFingerprint, ensureMetadataCatalogFresh, readIndexedDomainKnowledge, readIndexedKnowledge360, propose, proposePlan, recordCorrectionTrace, emitCorrectionEvalCase, mineJoinPatterns, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, computeResultStats, buildDeterministicDashboardStory, synthesizeAnswer, streamOrGenerate, narrateResult, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, latestRuntimeSchemaSnapshotForProject, loadSkills, migrateLegacySkills, configuredSkillsPath, skillsDir, draftDomainSkillBootstrap, buildDomainSkillBootstrapPrompt, mergeDomainSkillBootstrapEnrichment, writeSkill, deleteSkill, deriveGeneratedDraftSlug, reindexProject, invalidateAgentProjectState, resolveDomainContextEnvelope, defaultKgPath, planAppFromPrompt, KGStore, planResearch, loadSemanticMetrics, cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, routeReasoningEffort, routeForCascadeAnswerTier, clampReasoningEffort, bumpReasoningEffort, resolveThinkingMode, coerceThinkingMode, upsertGeneratedDqlArtifactDraft, } from '@duckcodeailabs/dql-agent';
20
20
  import { gatherProposeEnrichment } from './propose-enrich.js';
21
21
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
22
22
  import { getActiveProvider, getEffectiveProviderConfig, isProviderSettingsId, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
@@ -153,6 +153,20 @@ export function parseAgentRunRequestBody(body) {
153
153
  },
154
154
  };
155
155
  }
156
+ const AGENT_LOOKUP_DEADLINE_MS = 45_000;
157
+ const AGENT_RESEARCH_DEADLINE_MS = 120_000;
158
+ /**
159
+ * PERF-002: one wall-clock budget follows the request through routing, provider
160
+ * calls, repair, and execution. Ordinary Ask never inherits Research's budget
161
+ * merely because it spans two tables; explicit/deep investigation does.
162
+ */
163
+ export function agentRunDeadlineMs(request) {
164
+ if (request.requestedMode === 'research' || request.analysisDepth === 'deep') {
165
+ return AGENT_RESEARCH_DEADLINE_MS;
166
+ }
167
+ const plan = buildAnalysisQuestionPlan(request.question);
168
+ return plan.needsResearchWorkspace ? AGENT_RESEARCH_DEADLINE_MS : AGENT_LOOKUP_DEADLINE_MS;
169
+ }
156
170
  export function shouldSynthesizeAgentRunAnswer(governedAnswer) {
157
171
  if (governedAnswer.kind === 'no_answer')
158
172
  return false;
@@ -283,6 +297,7 @@ function conversationTurnInputFromRun(run) {
283
297
  sourceCertifiedBlock: agentRunString(payload?.sourceCertifiedBlock)
284
298
  ?? (artifact?.kind === 'answer' ? agentRunString(artifact.ref) : undefined),
285
299
  contextPackId: agentRunString(payload?.contextPackId) ?? agentRunString(contextPack?.id),
300
+ knowledgeLens: agentRunRecord(contextPack?.knowledgeLens),
286
301
  sql: agentRunString(payload?.proposedSql) ?? agentRunString(payload?.sql),
287
302
  dqlArtifact: agentRunRecord(payload?.dqlArtifact),
288
303
  cascade: agentRunRecord(payload?.cascade),
@@ -351,6 +366,21 @@ function compactConversationRecord(record) {
351
366
  function apiRequestId(scope) {
352
367
  return `${scope}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
353
368
  }
369
+ /** UI-007 / E2E-005: report the installed CLI version that owns this runtime. */
370
+ export function readDqlRuntimeVersion(runtimeUrl = import.meta.url) {
371
+ const runtimeDir = dirname(fileURLToPath(runtimeUrl));
372
+ for (const packagePath of [join(runtimeDir, 'package.json'), join(runtimeDir, '../package.json')]) {
373
+ try {
374
+ const parsed = JSON.parse(readFileSync(packagePath, 'utf-8'));
375
+ if (typeof parsed.version === 'string' && parsed.version.trim())
376
+ return parsed.version.trim();
377
+ }
378
+ catch {
379
+ // Source and published layouts place package.json at different levels.
380
+ }
381
+ }
382
+ return 'unknown';
383
+ }
354
384
  function apiErrorEnvelope(input) {
355
385
  return {
356
386
  requestId: input.requestId,
@@ -377,6 +407,7 @@ export async function startLocalServer(opts) {
377
407
  const bindHost = opts.host ?? process.env.DQL_HOST ?? '127.0.0.1';
378
408
  const loopback = bindHost === '127.0.0.1' || bindHost === 'localhost' || bindHost === '::1';
379
409
  const authToken = opts.authToken ?? process.env.DQL_SERVER_TOKEN;
410
+ const runtimeVersion = readDqlRuntimeVersion();
380
411
  const allowedOrigins = new Set((opts.allowedOrigins ?? (process.env.DQL_ALLOWED_ORIGINS ?? '').split(','))
381
412
  .map((value) => value.trim().replace(/\/$/, ''))
382
413
  .filter(Boolean));
@@ -426,6 +457,102 @@ export async function startLocalServer(opts) {
426
457
  error: snapshot.error,
427
458
  };
428
459
  };
460
+ const latestDbtPreparationJob = () => Array.from(onboardingJobs.values())
461
+ .reverse()
462
+ .find((job) => job.kind === 'dbt_prepare' || job.kind === 'dbt_refresh');
463
+ const startDbtPreparationJob = (input) => {
464
+ const now = new Date().toISOString();
465
+ const id = apiRequestId(input.kind === 'dbt_refresh' ? 'dbt-refresh' : 'dbt-prepare');
466
+ const job = {
467
+ id,
468
+ kind: input.kind,
469
+ status: 'running',
470
+ stage: 'indexing',
471
+ progress: 65,
472
+ message: 'Indexing dbt models, columns, semantic metrics, certified blocks, and governed relationships.',
473
+ createdAt: now,
474
+ updatedAt: now,
475
+ snapshotId: input.snapshotId,
476
+ phases: [
477
+ { id: 'artifact_validation', label: 'Validated dbt project and artifacts', status: 'completed', durationMs: input.validationDurationMs },
478
+ { id: 'snapshot_compile', label: 'Compiled immutable project snapshot', status: 'completed', durationMs: input.compileDurationMs },
479
+ { id: 'search_index', label: 'Build governed search indexes', status: 'running' },
480
+ ],
481
+ };
482
+ if (onboardingJobs.size >= 24) {
483
+ for (const [existingId, existing] of onboardingJobs) {
484
+ if (existing.status === 'running' || existing.status === 'queued')
485
+ continue;
486
+ onboardingJobs.delete(existingId);
487
+ if (onboardingJobs.size < 16)
488
+ break;
489
+ }
490
+ }
491
+ onboardingJobs.set(id, job);
492
+ // Start after the Apply response can be returned. Governed Ask calls the
493
+ // same versioned preparation service, so an early first question awaits
494
+ // this in-flight promise instead of starting a duplicate cold rebuild.
495
+ setTimeout(() => {
496
+ void (async () => {
497
+ const indexStartedAt = Date.now();
498
+ try {
499
+ const prepared = await ensureAgentProjectReady(projectRoot, {
500
+ kgPath: defaultKgPath(projectRoot),
501
+ manifest: input.manifest,
502
+ });
503
+ const current = onboardingJobs.get(id);
504
+ if (!current || current.status === 'cancelled')
505
+ return;
506
+ const indexDurationMs = Date.now() - indexStartedAt;
507
+ const completedAt = new Date().toISOString();
508
+ onboardingJobs.set(id, {
509
+ ...current,
510
+ status: 'completed',
511
+ stage: 'ready',
512
+ progress: 100,
513
+ message: `Ready. Indexed ${prepared.nodes.toLocaleString()} governed objects for fast search.`,
514
+ updatedAt: completedAt,
515
+ phases: current.phases.map((phase) => phase.id === 'search_index'
516
+ ? { ...phase, status: 'completed', durationMs: indexDurationMs }
517
+ : phase),
518
+ result: {
519
+ snapshotId: input.snapshotId,
520
+ cacheHit: prepared.cacheHit,
521
+ sourceVersion: prepared.sourceVersion,
522
+ objectCount: prepared.nodes,
523
+ edgeCount: prepared.edges,
524
+ metadataFingerprint: prepared.metadataFingerprint,
525
+ kgFingerprint: prepared.kgFingerprint,
526
+ phaseDurationsMs: {
527
+ artifactValidation: input.validationDurationMs,
528
+ snapshotCompile: input.compileDurationMs,
529
+ searchIndex: indexDurationMs,
530
+ total: input.validationDurationMs + input.compileDurationMs + indexDurationMs,
531
+ },
532
+ completedAt,
533
+ },
534
+ });
535
+ }
536
+ catch (error) {
537
+ const current = onboardingJobs.get(id);
538
+ if (!current || current.status === 'cancelled')
539
+ return;
540
+ onboardingJobs.set(id, {
541
+ ...current,
542
+ status: 'failed',
543
+ progress: 65,
544
+ message: 'The dbt project is connected, but its governed search indexes need attention.',
545
+ updatedAt: new Date().toISOString(),
546
+ phases: current.phases.map((phase) => phase.id === 'search_index'
547
+ ? { ...phase, status: 'failed', durationMs: Date.now() - indexStartedAt }
548
+ : phase),
549
+ error: error instanceof Error ? error.message : String(error),
550
+ });
551
+ }
552
+ })();
553
+ }, 0);
554
+ return job;
555
+ };
429
556
  const onboardingDbtPaths = (body = {}) => {
430
557
  const repoUrl = typeof body.repoUrl === 'string' && body.repoUrl.trim() ? body.repoUrl.trim() : undefined;
431
558
  const branch = typeof body.branch === 'string' && body.branch.trim() ? body.branch.trim() : undefined;
@@ -522,28 +649,6 @@ export async function startLocalServer(opts) {
522
649
  }
523
650
  return candidate;
524
651
  };
525
- // Zero-config semantic search: if the user is running Ollama with an embedding model
526
- // but hasn't set an embed env var, auto-detect it so retrieval + metric/block matching
527
- // get real semantic recall out of the box (match by meaning, not just keywords) — the
528
- // biggest lever for "find the right metric/block instead of jumping to raw SQL".
529
- // Explicit config always wins; when nothing is found we stay on the deterministic
530
- // keyword matcher. Scoped to the app server (not eval/CI) and fully best-effort.
531
- if (!process.env.DQL_OLLAMA_EMBED_URL && !process.env.DQL_OPENAI_API_KEY && !process.env.OPENAI_API_KEY) {
532
- try {
533
- const detected = await probeLocalOllamaEmbeddings();
534
- if (detected) {
535
- process.env.DQL_OLLAMA_EMBED_URL = detected.endpoint;
536
- process.env.DQL_OLLAMA_EMBED_MODEL = detected.model;
537
- console.log(`[dql] Semantic search on: local Ollama embeddings (${detected.model}) — matching by meaning, not just keywords.`);
538
- }
539
- else {
540
- console.log('[dql] Semantic search: keyword-only. For higher matching accuracy, install Ollama and run `ollama pull nomic-embed-text` — DQL detects and uses it automatically, fully local & free.');
541
- }
542
- }
543
- catch {
544
- // Probe failure never blocks startup — fall back to the keyword matcher.
545
- }
546
- }
547
652
  // Auto-ensure the active connection's driver so a configured connection is never
548
653
  // left "broken" after a fresh clone, a CLI upgrade, or a Node version change (the
549
654
  // driver lives in gitignored, per-project .dql/connectors). Best-effort + non-fatal.
@@ -679,6 +784,15 @@ export async function startLocalServer(opts) {
679
784
  const nested = agentRunRecord(workspace.context);
680
785
  return agentRunString(workspace[key]) ?? (nested ? agentRunString(nested[key]) : undefined);
681
786
  };
787
+ const agentRunWorkspaceValues = (request, key) => {
788
+ const workspace = request.workspaceContext ?? {};
789
+ const nested = agentRunRecord(workspace.context);
790
+ const raw = workspace[key] ?? nested?.[key];
791
+ if (!Array.isArray(raw))
792
+ return undefined;
793
+ const values = raw.filter((item) => typeof item === 'string' && Boolean(item.trim())).map((item) => item.trim());
794
+ return values.length > 0 ? [...new Set(values)] : undefined;
795
+ };
682
796
  const agentRunNotebookPath = (request, runId) => (agentRunWorkspaceValue(request, 'notebookPath')
683
797
  ?? (request.selectedObject?.kind === 'notebook' || request.selectedObject?.kind === 'cell' ? request.selectedObject.path : undefined)
684
798
  ?? `notebooks/agent-research/${runId}.dqlnb`);
@@ -860,7 +974,7 @@ export async function startLocalServer(opts) {
860
974
  return provider.generate([{ role: 'system', content: system }, { role: 'user', content: user }], { maxTokens: 600, temperature: 0.2, signal });
861
975
  },
862
976
  });
863
- async function runGovernedAgentAnswerForRun(request, repair, route = 'generated_answer', onProgress) {
977
+ async function runGovernedAgentAnswerForRun(request, repair, route = 'generated_answer', onProgress, routeDecision) {
864
978
  const governed = resolveGovernedAnswerRunner(projectRoot);
865
979
  const resolvedProvider = governed?.provider ?? null;
866
980
  const runner = governed?.runner ?? null;
@@ -886,6 +1000,9 @@ export async function startLocalServer(opts) {
886
1000
  workspaceContext: request.workspaceContext,
887
1001
  instruction: [
888
1002
  'Route through the governed DQL answer loop.',
1003
+ ...(routeDecision?.meaningResolution?.selectedConceptIds.length
1004
+ ? [`Meaning resolution selected these retrieved qualified IDs: ${routeDecision.meaningResolution.selectedConceptIds.join(', ')}. Treat them as strong evidence, but still validate grain, dimensions, filters, authorization, and runtime compatibility before execution.`]
1005
+ : []),
889
1006
  'Prefer certified DQL blocks when they exactly cover the question.',
890
1007
  'Generated DQL artifacts remain review-required; SQL is only the bounded preview/compiled evidence.',
891
1008
  'If the question needs investigation, return the clearest answer and next review action without certifying generated work.',
@@ -898,6 +1015,9 @@ export async function startLocalServer(opts) {
898
1015
  const controller = request.runId
899
1016
  ? activeAgentRunControllers.get(request.runId) ?? new AbortController()
900
1017
  : new AbortController();
1018
+ const runSignal = request.signal
1019
+ ? AbortSignal.any([request.signal, controller.signal])
1020
+ : controller.signal;
901
1021
  // Best-effort active warehouse dialect so Lane-2 semantic compiles emit
902
1022
  // dialect-correct SQL (e.g. DATE_TRUNC / identifier quoting). Absent when no
903
1023
  // connection is configured — the compiler then uses its default dialect.
@@ -918,6 +1038,7 @@ export async function startLocalServer(opts) {
918
1038
  activeDomain: requestedDomain,
919
1039
  purpose: requestedPurpose,
920
1040
  modelAreaId: requestedModelAreaId,
1041
+ skillRefs: agentRunWorkspaceValues(request, 'skillRefs'),
921
1042
  source: 'explicit_ui',
922
1043
  snapshotId: runProjectSnapshot.snapshotId,
923
1044
  })
@@ -939,6 +1060,7 @@ export async function startLocalServer(opts) {
939
1060
  reasoningEffort,
940
1061
  ...(analysisDepth ? { analysisDepth } : {}),
941
1062
  projectRoot,
1063
+ preparedContextPack: preparedAgentContextPacks.get(request),
942
1064
  domainContext,
943
1065
  projectSnapshot: { snapshotId: runProjectSnapshot.snapshotId, manifest: runProjectSnapshot.manifest },
944
1066
  assertProjectSnapshot: (snapshotId) => {
@@ -947,6 +1069,12 @@ export async function startLocalServer(opts) {
947
1069
  projectSnapshots.assertCurrent(snapshotId);
948
1070
  },
949
1071
  ...(semanticDriver ? { semanticDriver } : {}),
1072
+ ...(routeDecision?.meaningResolution?.selectedConceptIds.length
1073
+ ? { preferredEvidenceIds: routeDecision.meaningResolution.selectedConceptIds }
1074
+ : {}),
1075
+ ...(routeDecision?.meaningResolution?.recommendedExecutionId
1076
+ ? { preferredExecutionId: routeDecision.meaningResolution.recommendedExecutionId }
1077
+ : {}),
950
1078
  executeCertifiedBlock: executeCertifiedBlockForAgent,
951
1079
  executeGeneratedSql: executeGeneratedSqlForAgent,
952
1080
  getSchemaContext: getSchemaContextForAgent,
@@ -959,7 +1087,7 @@ export async function startLocalServer(opts) {
959
1087
  if (turn.kind === 'error') {
960
1088
  providerError = turn.message;
961
1089
  }
962
- }, controller.signal);
1090
+ }, runSignal);
963
1091
  if (!governedAnswer) {
964
1092
  throw new Error(providerError ?? 'The AI provider did not return a governed answer.');
965
1093
  }
@@ -1001,9 +1129,11 @@ export async function startLocalServer(opts) {
1001
1129
  return;
1002
1130
  const existing = agentRunRecord(result.chartConfig) ?? {};
1003
1131
  // A declared chart on the execution result came from authored DQL and is a
1004
- // governed display contract. Agent `suggestedViz` remains a soft preference.
1005
- if (typeof existing.chart === 'string')
1006
- return;
1132
+ // governed display contract. Preserve its type/bindings, but still enrich a
1133
+ // missing display format from the result semantics; otherwise an authored
1134
+ // KPI for `lifetime_spend` renders as a generic `671.4K` instead of `$671.4K`.
1135
+ // Agent `suggestedViz` remains a soft preference when no chart was authored.
1136
+ const hasAuthoredChart = typeof existing.chart === 'string';
1007
1137
  const recommendation = recommendVisualization(projectRoot, {
1008
1138
  blockRef: governedAnswer.sourceCertifiedBlock ?? governedAnswer.block?.name,
1009
1139
  prompt: question,
@@ -1014,17 +1144,26 @@ export async function startLocalServer(opts) {
1014
1144
  if (!recommendation.ok)
1015
1145
  return;
1016
1146
  const fieldHints = recommendation.display.fieldHints ?? {};
1017
- const chart = recommendation.display.defaultVisualization.replace(/_/g, '-');
1147
+ const chart = hasAuthoredChart
1148
+ ? String(existing.chart).replace(/_/g, '-')
1149
+ : recommendation.display.defaultVisualization.replace(/_/g, '-');
1018
1150
  const agentChoice = typeof governedAnswer.suggestedViz === 'string'
1019
1151
  ? governedAnswer.suggestedViz.toLowerCase().replace(/_/g, '-')
1020
1152
  : undefined;
1021
1153
  result.chartConfig = {
1022
1154
  ...existing,
1023
1155
  chart,
1024
- decisionSource: agentChoice === chart ? 'agent' : 'data',
1025
- rationale: recommendation.display.rationale,
1026
- ...(typeof fieldHints.x === 'string' ? { x: fieldHints.x } : {}),
1027
- ...(typeof fieldHints.y === 'string' ? { y: fieldHints.y } : {}),
1156
+ decisionSource: hasAuthoredChart ? 'authored' : agentChoice === chart ? 'agent' : 'data',
1157
+ rationale: hasAuthoredChart
1158
+ ? agentRunString(existing.rationale) ?? 'Authored DQL visualization enriched with result-aware display semantics.'
1159
+ : recommendation.display.rationale,
1160
+ ...(typeof existing.x !== 'string' && typeof fieldHints.x === 'string' ? { x: fieldHints.x } : {}),
1161
+ ...(typeof existing.y !== 'string' && typeof fieldHints.y === 'string' ? { y: fieldHints.y } : {}),
1162
+ ...(typeof existing.color !== 'string' && typeof fieldHints.color === 'string' ? { color: fieldHints.color } : {}),
1163
+ ...(typeof existing.format !== 'string'
1164
+ && (fieldHints.format === 'currency' || fieldHints.format === 'percent' || fieldHints.format === 'number')
1165
+ ? { format: fieldHints.format }
1166
+ : {}),
1028
1167
  };
1029
1168
  governedAnswer.suggestedViz = chart;
1030
1169
  const evidence = governedAnswer.evidence ?? {
@@ -1114,7 +1253,7 @@ export async function startLocalServer(opts) {
1114
1253
  const answerRunExecutor = async ({ request, route, routeDecision, attempt, repairHint, emit }) => {
1115
1254
  let governedAnswer;
1116
1255
  try {
1117
- governedAnswer = await runGovernedAgentAnswerForRun(request, { attempt, repairHint }, route, (message) => emit({ type: 'executor.started', message, route }));
1256
+ governedAnswer = await runGovernedAgentAnswerForRun(request, { attempt, repairHint }, route, (message) => emit({ type: 'executor.started', message, route }), routeDecision);
1118
1257
  // Surface the approved Hint-Graph corrections that shaped this answer so the
1119
1258
  // UI can show an "applied learnings" chip (memoryContext is already on the answer).
1120
1259
  if (!governedAnswer.appliedHints) {
@@ -1278,49 +1417,49 @@ export async function startLocalServer(opts) {
1278
1417
  // more provider calls retrying the same incompatible candidate.
1279
1418
  const isPolicyBlocked = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'policy_blocked';
1280
1419
  // The model tried to compose a governed query and declined despite having usable
1281
- // context (e.g. it wasn't confident about a multi-table join). That is NOT a
1282
- // question for the USER to clarify it's a case to retry harder: escalate to a
1283
- // deeper research pass (higher reasoning effort + deep analysis) through the
1284
- // engine's bounded loop, exactly as a grounding gap is retried today.
1420
+ // context (e.g. it wasn't confident about a multi-table join). The answer loop
1421
+ // has already spent its one evidence-aware repair. Keep this terminal and
1422
+ // inspectable; an ordinary Ask must never silently become a second Research run.
1285
1423
  const isModelDeclined = governedAnswer.kind === 'no_answer' && governedAnswer.refusalCode === 'model_declined';
1286
- const groundingRepairHint = isGroundingGap ? groundingGapRepairHint(governedAnswer) : undefined;
1287
- const declinedRepairHint = isModelDeclined
1288
- ? 'The first attempt declined to compose a governed query despite available context. Investigate the join path across the requested entities/metrics and compose a review-required query rather than declining.'
1289
- : undefined;
1290
- // Only a genuinely AMBIGUOUS question is surfaced as "needs clarification". A
1291
- // grounding gap or a model decline is retried/escalated by the engine, and a
1292
- // provider outage is surfaced as blocked so the UI offers a retry.
1424
+ // Only a genuinely AMBIGUOUS question is surfaced as "needs clarification".
1425
+ // Grounding/compiler gaps are terminal review states with their evidence trace;
1426
+ // provider outages are blocked so the UI can offer an explicit retry.
1293
1427
  const needsClarification = governedAnswer.kind === 'no_answer'
1294
1428
  && !isGroundingGap && !isProviderError && !isModelDeclined && !isPolicyBlocked;
1295
1429
  const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
1296
1430
  const runnableSql = governedAnswer.kind === 'no_answer' || (isExploratory && !governedAnswer.result)
1297
1431
  ? undefined
1298
1432
  : sql;
1299
- // Synthesis is a legacy polish pass. Certified/no-answer paths and lanes
1300
- // that already produced DQL-first final prose keep the fast path.
1433
+ // Render executed rows deterministically for ordinary lookups. A second LLM
1434
+ // call is reserved for an explicit research route; certified, semantic, and
1435
+ // generated lookup answers must not pay another provider round-trip merely
1436
+ // to restate values the host already has.
1301
1437
  let synthesizedAnswer;
1302
1438
  if (shouldSynthesizeAgentRunAnswer(governedAnswer)) {
1303
1439
  try {
1304
- const provider = await createBlockStudioAssistProvider(projectRoot);
1305
- if (provider) {
1306
- const preview = agentResultToSynthesisPreview(governedAnswer.result);
1307
- const draft = governedAnswer.answer ?? governedAnswer.text;
1308
- const result = await synthesizeAnswer({
1309
- question: request.question,
1310
- category: routeDecision?.category,
1311
- // The primary Ask reply is always business-facing. Analysts keep
1312
- // the full DQL, SQL, lineage, gates, and grain in the inspector.
1313
- audience: 'stakeholder',
1314
- resultPreview: preview,
1315
- sql: sql,
1316
- draftText: draft,
1317
- gaps: businessNarrativeGaps(governedAnswer.validationWarnings),
1318
- }, {
1440
+ const provider = route === 'research'
1441
+ ? await createBlockStudioAssistProvider(projectRoot)
1442
+ : null;
1443
+ const preview = agentResultToSynthesisPreview(governedAnswer.result);
1444
+ const draft = governedAnswer.answer ?? governedAnswer.text;
1445
+ const result = await synthesizeAnswer({
1446
+ question: request.question,
1447
+ category: routeDecision?.category,
1448
+ // The primary Ask reply is always business-facing. Analysts keep
1449
+ // the full DQL, SQL, lineage, gates, and grain in the inspector.
1450
+ audience: 'stakeholder',
1451
+ resultPreview: preview,
1452
+ sql: sql,
1453
+ draftText: draft,
1454
+ gaps: businessNarrativeGaps(governedAnswer.validationWarnings),
1455
+ rankingDirection: governedAnswer.contextPack?.questionPlan.requestedShape.rankingDirection,
1456
+ }, provider
1457
+ ? {
1319
1458
  complete: ({ system, user, signal, onDelta }) => streamOrGenerate(provider, [{ role: 'system', content: system }, { role: 'user', content: user }], { maxTokens: 350, temperature: 0.3, signal }, onDelta ?? (() => { })),
1320
- });
1321
- if (result.text)
1322
- synthesizedAnswer = result.text;
1323
- }
1459
+ }
1460
+ : {});
1461
+ if (result.text)
1462
+ synthesizedAnswer = result.text;
1324
1463
  }
1325
1464
  catch {
1326
1465
  // Keep the governed draft on any synthesis failure.
@@ -1411,26 +1550,22 @@ export async function startLocalServer(opts) {
1411
1550
  : 'The answer is generated or semantic-layer backed and remains review-required.', governedAnswer.route),
1412
1551
  ...(isGroundingGap ? [
1413
1552
  {
1414
- ...agentRunEvaluation('grounding-gap', 'Metadata grounding', false, 'warning', 'The answer loop found a metadata grounding gap that can be retried with wider context.', {
1553
+ ...agentRunEvaluation('grounding-gap', 'Metadata grounding', false, 'warning', 'The bounded lookup could not prove the required metadata grounding. No automatic retry or Research escalation was started.', {
1415
1554
  refusalCode: governedAnswer.refusalCode,
1416
1555
  refusalDetails: governedAnswer.refusalDetails,
1417
1556
  validationWarnings: governedAnswer.validationWarnings,
1418
1557
  route: governedAnswer.route,
1419
1558
  }),
1420
- suggestedRepair: groundingRepairHint,
1421
- repairAction: { kind: 'retry', hint: groundingRepairHint },
1422
1559
  },
1423
1560
  ] : []),
1424
1561
  ...(isModelDeclined ? [
1425
1562
  {
1426
- ...agentRunEvaluation('declined-despite-context', 'Answer grounding', false, 'blocking', 'The model declined to compose a governed query despite available context escalating to a deeper investigation before accepting a refusal.', {
1563
+ ...agentRunEvaluation('declined-despite-context', 'Answer grounding', false, 'blocking', 'The bounded lookup could not compose a governed query after its in-lane repair. Start Research explicitly to investigate beyond this lookup budget.', {
1427
1564
  refusalCode: governedAnswer.refusalCode,
1428
1565
  refusalDetails: governedAnswer.refusalDetails,
1429
1566
  validationWarnings: governedAnswer.validationWarnings,
1430
1567
  route: governedAnswer.route,
1431
1568
  }),
1432
- suggestedRepair: declinedRepairHint,
1433
- repairAction: { kind: 'escalate', route: 'research', hint: declinedRepairHint },
1434
1569
  },
1435
1570
  ] : []),
1436
1571
  ...(isPolicyBlocked ? [
@@ -1968,7 +2103,138 @@ export async function startLocalServer(opts) {
1968
2103
  };
1969
2104
  },
1970
2105
  };
1971
- // Compact, catalog-grounded context the LLM planner decomposes `auto` turns against.
2106
+ // One immutable, question-ranked pack is shared by routing, planning, schema
2107
+ // lookup, and governed execution for the lifetime of a request. This removes
2108
+ // both positional catalog truncation and the previous duplicate retrieval pass.
2109
+ const preparedAgentContextPacks = new WeakMap();
2110
+ const pendingAgentContextPacks = new WeakMap();
2111
+ const buildAgentRunContextPack = async (request) => {
2112
+ const prepared = preparedAgentContextPacks.get(request);
2113
+ if (prepared)
2114
+ return prepared;
2115
+ const pending = pendingAgentContextPacks.get(request);
2116
+ if (pending)
2117
+ return pending;
2118
+ const snapshot = projectSnapshot();
2119
+ const requestedDomain = agentRunWorkspaceValue(request, 'domain');
2120
+ // CTX-003: resolve result entities/values before evidence retrieval. The
2121
+ // router, planner, and answer loop must rank the same typed follow-up; doing
2122
+ // this only inside the provider adapter allowed stale catalog matches to win
2123
+ // before "they" / "this amount" became customer-scoped context.
2124
+ const followUp = resolveAgentFollowUpContext(request.conversationContext, request.question);
2125
+ const serverSnapshot = agentRunRecord(request.conversationContext?.serverSnapshot);
2126
+ const topicRelation = agentRunString(serverSnapshot?.topicRelation);
2127
+ // The readiness marker is source-versioned. When it matches, pass the
2128
+ // already-built metadata identity into retrieval so buildLocalContextPack
2129
+ // opens the immutable snapshot directly instead of rebuilding all metadata
2130
+ // merely to rediscover the same fingerprint on every follow-up.
2131
+ const preparedMetadataFingerprint = isAgentProjectIndexReady(projectRoot)
2132
+ ? currentMetadataFingerprint(projectRoot)
2133
+ : undefined;
2134
+ const task = buildLocalContextPack(projectRoot, {
2135
+ question: request.question,
2136
+ followUp,
2137
+ priorContextPackId: agentRunString(request.conversationContext?.contextPackId),
2138
+ conversationTopicRelation: topicRelation === 'continuation'
2139
+ || topicRelation === 'refinement'
2140
+ || topicRelation === 'return'
2141
+ || topicRelation === 'shift'
2142
+ ? topicRelation
2143
+ : undefined,
2144
+ preparedMetadataFingerprint,
2145
+ surface: 'notebook',
2146
+ selectedContext: {
2147
+ selectedObject: request.selectedObject,
2148
+ workspaceContext: request.workspaceContext,
2149
+ },
2150
+ strictness: request.analysisDepth === 'deep' ? 'exploratory' : 'balanced',
2151
+ limit: request.analysisDepth === 'deep' ? 120 : 80,
2152
+ domainContext: requestedDomain
2153
+ ? resolveDomainContextEnvelope({
2154
+ manifest: snapshot.manifest,
2155
+ activeDomain: requestedDomain,
2156
+ purpose: agentRunWorkspaceValue(request, 'purpose'),
2157
+ modelAreaId: agentRunWorkspaceValue(request, 'modelAreaId'),
2158
+ skillRefs: agentRunWorkspaceValues(request, 'skillRefs'),
2159
+ source: 'explicit_ui',
2160
+ snapshotId: snapshot.snapshotId,
2161
+ })
2162
+ : undefined,
2163
+ }).then((pack) => {
2164
+ preparedAgentContextPacks.set(request, pack);
2165
+ pendingAgentContextPacks.delete(request);
2166
+ return pack;
2167
+ }).catch((error) => {
2168
+ pendingAgentContextPacks.delete(request);
2169
+ throw error;
2170
+ });
2171
+ pendingAgentContextPacks.set(request, task);
2172
+ return task;
2173
+ };
2174
+ const buildAgentRunEvidence = async (request) => {
2175
+ const startedAt = Date.now();
2176
+ const pack = await buildAgentRunContextPack(request);
2177
+ const meaningEvidence = pack.retrievalDiagnostics.meaningEvidence;
2178
+ if (!meaningEvidence) {
2179
+ return {
2180
+ snapshotId: pack.id,
2181
+ sourceFingerprint: pack.freshness.fingerprint ?? undefined,
2182
+ candidates: [],
2183
+ diagnostics: { durationMs: Date.now() - startedAt },
2184
+ };
2185
+ }
2186
+ const evidence = toAgentRetrievalEvidence(meaningEvidence, pack.questionPlan, {
2187
+ snapshotId: pack.id,
2188
+ sourceFingerprint: pack.freshness.fingerprint ?? undefined,
2189
+ durationMs: Date.now() - startedAt,
2190
+ truncated: pack.retrievalDiagnostics.topRejected.length > 0,
2191
+ });
2192
+ const certifiedFits = new Map(pack.retrievalDiagnostics.certifiedCandidateFits.map((fit) => [fit.objectKey, fit]));
2193
+ const semanticEvidence = new Set(pack.routeDecision.selectedEvidence
2194
+ .filter((item) => item.role === 'semantic_metric')
2195
+ .map((item) => item.objectKey));
2196
+ return {
2197
+ ...evidence,
2198
+ candidates: evidence.candidates.map((candidate) => {
2199
+ if (candidate.kind === 'certified_block') {
2200
+ const fit = certifiedFits.get(candidate.id);
2201
+ return {
2202
+ ...candidate,
2203
+ compatibility: fit?.action === 'certified_answer'
2204
+ ? 'compatible'
2205
+ : fit?.action === 'rejected_for_fit'
2206
+ ? 'incompatible'
2207
+ : 'partial',
2208
+ };
2209
+ }
2210
+ if ((candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
2211
+ && semanticEvidence.has(candidate.id)
2212
+ && pack.routeDecision.route !== 'clarify'
2213
+ && pack.routeDecision.route !== 'conflict') {
2214
+ const requestedDimensions = pack.questionPlan.requestedShape.dimensions.map((dimension) => dimension.toLowerCase());
2215
+ const availableDimensions = (candidate.dimensions ?? []).map((dimension) => dimension.toLowerCase());
2216
+ const dimensionsFit = requestedDimensions.length === 0 || requestedDimensions.every((requested) => availableDimensions.some((available) => available === requested || available.endsWith(`.${requested}`)));
2217
+ const requestedTimeGrain = pack.questionPlan.timeTerms[0]?.toLowerCase();
2218
+ const availableTimeGrains = (candidate.timeGrains ?? []).map((grain) => grain.toLowerCase());
2219
+ const timeGrainFits = !requestedTimeGrain
2220
+ || availableTimeGrains.includes(requestedTimeGrain);
2221
+ return { ...candidate, compatibility: dimensionsFit && timeGrainFits ? 'compatible' : 'partial' };
2222
+ }
2223
+ if (candidate.trustTier === 'governed_sql')
2224
+ return { ...candidate, compatibility: 'partial' };
2225
+ return candidate;
2226
+ }),
2227
+ };
2228
+ };
2229
+ const buildRankedAgentRunCatalogContext = async (request) => {
2230
+ const evidence = await buildAgentRunEvidence(request);
2231
+ return evidence.candidates.map((candidate) => {
2232
+ const detail = candidate.definition ? `: ${candidate.definition}` : '';
2233
+ return `- ${candidate.id} [${candidate.trustTier}; ${candidate.compatibility}]${detail}`;
2234
+ }).join('\n');
2235
+ };
2236
+ // Compact fallback used only for plain conversational replies. Analytical
2237
+ // turns use the structured, question-ranked evidence path above.
1972
2238
  const buildAgentRunCatalogContext = () => {
1973
2239
  try {
1974
2240
  const blocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
@@ -2004,7 +2270,7 @@ export async function startLocalServer(opts) {
2004
2270
  { role: 'user', content: user },
2005
2271
  ], { maxTokens: 700, temperature: 0.1, signal });
2006
2272
  },
2007
- getCatalogContext: buildAgentRunCatalogContext,
2273
+ getCatalogContext: buildRankedAgentRunCatalogContext,
2008
2274
  });
2009
2275
  // Hybrid router: keep the deterministic decision when it is confident (certified
2010
2276
  // fast paths + greetings stay 0-LLM); spend one cheap classification call only for
@@ -2014,13 +2280,22 @@ export async function startLocalServer(opts) {
2014
2280
  complete: async ({ system, user, signal }) => {
2015
2281
  const provider = await createBlockStudioAssistProvider(projectRoot);
2016
2282
  if (!provider)
2017
- throw new Error('No AI provider configured for routing.');
2283
+ throw new Error('No AI provider configured for meaning resolution.');
2018
2284
  return provider.generate([
2019
2285
  { role: 'system', content: system },
2020
2286
  { role: 'user', content: user },
2021
- ], { maxTokens: 250, temperature: 0, signal });
2287
+ ], {
2288
+ maxTokens: 600,
2289
+ temperature: 0,
2290
+ // AGT-009/PERF-002: ambiguity gets one bounded resolver call. If the
2291
+ // provider stalls, the router falls back to its evidence-only decision
2292
+ // instead of leaving the UI in "Generating and validating SQL" for a
2293
+ // minute or more.
2294
+ signal: boundedAgentMeaningSignal(signal),
2295
+ });
2022
2296
  },
2023
- getCatalogContext: () => buildAgentRunCatalogContext(),
2297
+ getEvidence: buildAgentRunEvidence,
2298
+ getCatalogContext: buildRankedAgentRunCatalogContext,
2024
2299
  });
2025
2300
  const agentRunStore = new FileAgentRunStore({ path: defaultAgentRunStorePath(projectRoot) });
2026
2301
  // A run may outlive its streaming browser connection, so cancellation is
@@ -2303,30 +2578,24 @@ export async function startLocalServer(opts) {
2303
2578
  };
2304
2579
  };
2305
2580
  const catalogContext = await buildAgentSchemaContextFromCatalog(projectRoot, question, preparedContextPack).catch(() => []);
2581
+ const valueGrounding = resolveAgentRuntimeValueGrounding(projectConfig);
2306
2582
  if (catalogContext.length > 0) {
2307
- if (!connection)
2583
+ if (!connection || valueGrounding.mode !== 'safe_automatic')
2308
2584
  return catalogContext;
2309
- // Rescan live when the question shape calls for it OR the stored snapshot is
2310
- // stale/absent (P6) otherwise a warehouse schema change between sessions is
2311
- // silently reasoned over from a cached snapshot that never expires.
2312
- const runtimeScan = (shouldAugmentAgentRuntimeSchema(question, preparedContextPack?.questionPlan) || runtimeSnapshotStale(projectRoot))
2313
- ? await scanRuntimeSchema().catch(() => undefined)
2314
- : undefined;
2315
- const runtimeContext = runtimeScan?.ranked ?? [];
2316
- const merged = mergeAgentSchemaContexts(catalogContext, runtimeContext);
2317
- const enriched = await enrichAgentSchemaContextWithValueMatches(question, merged, executor, connection);
2318
- recordAgentRuntimeSchemaSnapshot(projectRoot, !runtimeScan?.snapshot.length
2319
- ? enriched
2320
- : mergeAgentSchemaSampleValues(runtimeScan.snapshot, enriched), runtimeContext.length > 0
2321
- ? 'full information_schema runtime scan for composite Ask AI question'
2322
- : 'catalog enriched runtime schema');
2585
+ // The immutable dbt/DQL snapshot already owns schema discovery. A named
2586
+ // row value must not turn a warm Ask into an information_schema scan over
2587
+ // thousands of enterprise tables; only field-scoped value probes are live.
2588
+ const enriched = await enrichAgentSchemaContextWithValueMatches(question, catalogContext, executor, connection, valueGrounding.searchSafeColumns);
2589
+ recordAgentRuntimeSchemaSnapshot(projectRoot, catalogContext, 'catalog runtime schema');
2323
2590
  return enriched;
2324
2591
  }
2325
2592
  if (!connection)
2326
2593
  return [];
2327
2594
  try {
2328
2595
  const schemaContext = await scanRuntimeSchema();
2329
- const enriched = await enrichAgentSchemaContextWithValueMatches(question, schemaContext.ranked, executor, connection);
2596
+ const enriched = valueGrounding.mode === 'safe_automatic'
2597
+ ? await enrichAgentSchemaContextWithValueMatches(question, schemaContext.ranked, executor, connection, valueGrounding.searchSafeColumns)
2598
+ : schemaContext.ranked;
2330
2599
  recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext.snapshot, 'full information_schema runtime scan');
2331
2600
  return enriched;
2332
2601
  }
@@ -3409,13 +3678,42 @@ export async function startLocalServer(opts) {
3409
3678
  }
3410
3679
  if (req.method === 'GET' && path === '/api/health') {
3411
3680
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3412
- res.end(serializeJSON({ status: 'ok' }));
3681
+ res.end(serializeJSON({ status: 'ok', version: runtimeVersion }));
3682
+ return;
3683
+ }
3684
+ if (req.method === 'GET' && path === '/api/onboarding/launch') {
3685
+ const requestId = apiRequestId('onboarding-launch');
3686
+ const acknowledgedVersion = readUserPrefs(userPrefsPath).setup?.acknowledgedVersion ?? null;
3687
+ const shouldOpen = acknowledgedVersion !== runtimeVersion;
3688
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3689
+ res.end(serializeJSON({
3690
+ requestId,
3691
+ version: runtimeVersion,
3692
+ acknowledgedVersion,
3693
+ shouldOpen,
3694
+ reason: shouldOpen ? (acknowledgedVersion ? 'version_upgrade' : 'first_install') : null,
3695
+ }));
3696
+ return;
3697
+ }
3698
+ if (req.method === 'POST' && path === '/api/onboarding/acknowledge') {
3699
+ const requestId = apiRequestId('onboarding-acknowledge');
3700
+ const prefs = readUserPrefs(userPrefsPath);
3701
+ prefs.setup = {
3702
+ acknowledgedVersion: runtimeVersion,
3703
+ acknowledgedAt: new Date().toISOString(),
3704
+ };
3705
+ writeUserPrefs(userPrefsPath, prefs);
3706
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3707
+ res.end(serializeJSON({ requestId, version: runtimeVersion, acknowledged: true }));
3413
3708
  return;
3414
3709
  }
3415
3710
  if (req.method === 'GET' && path === '/api/onboarding/status') {
3416
3711
  const requestId = apiRequestId('onboarding-status');
3417
3712
  const dbtProjectDir = findDbtProjectPath(projectRoot, projectConfig);
3418
3713
  const manifestPath = resolve(dbtProjectDir, projectConfig.dbt?.manifestPath ?? 'target/manifest.json');
3714
+ const configuredDbtSource = projectConfig.dbt?.repoUrl ?? projectConfig.dbt?.projectDir;
3715
+ const dbtConfigured = existsSync(join(dbtProjectDir, 'dbt_project.yml'))
3716
+ || Boolean(configuredDbtSource && !/\{\{[^}]+\}\}/.test(configuredDbtSource));
3419
3717
  const configuredProfilesDir = projectConfig.dbt?.profilesDir ? resolve(projectRoot, projectConfig.dbt.profilesDir) : undefined;
3420
3718
  const profilePath = findDbtProfilePaths(projectRoot, dbtProjectDir, configuredProfilesDir)[0];
3421
3719
  const registry = loadDomainPackageRegistry(projectRoot);
@@ -3430,12 +3728,24 @@ export async function startLocalServer(opts) {
3430
3728
  snapshotError = error instanceof Error ? error.message : String(error);
3431
3729
  }
3432
3730
  }
3731
+ const preparation = latestDbtPreparationJob();
3732
+ const preparationForSnapshot = preparation?.snapshotId === snapshotId ? preparation : undefined;
3733
+ const searchIndexesPresent = isAgentProjectIndexReady(projectRoot);
3734
+ const snapshotState = preparationForSnapshot?.status === 'running' || preparationForSnapshot?.status === 'queued'
3735
+ ? 'building'
3736
+ : preparationForSnapshot?.status === 'failed'
3737
+ ? 'failed'
3738
+ : snapshotId && searchIndexesPresent
3739
+ ? 'ready'
3740
+ : snapshotId
3741
+ ? 'stale'
3742
+ : 'missing';
3433
3743
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3434
3744
  res.end(serializeJSON({
3435
3745
  requestId,
3436
3746
  snapshotId,
3437
3747
  dbt: {
3438
- configured: Boolean(projectConfig.dbt),
3748
+ configured: dbtConfigured,
3439
3749
  projectDir: projectConfig.dbt?.projectDir,
3440
3750
  manifestPath: projectConfig.dbt?.manifestPath,
3441
3751
  profilesDir: configuredProfilesDir
@@ -3446,14 +3756,27 @@ export async function startLocalServer(opts) {
3446
3756
  subPath: projectConfig.dbt?.subPath,
3447
3757
  projectFound: existsSync(join(dbtProjectDir, 'dbt_project.yml')),
3448
3758
  manifestFound,
3759
+ artifactState: manifestFound ? 'ready' : 'missing',
3449
3760
  },
3450
3761
  modeling: {
3451
3762
  enabled: projectConfig.manifestVersion === 3 && projectConfig.modeling?.mode === 'dbt-first',
3452
3763
  manifestVersion: projectConfig.manifestVersion ?? 2,
3453
3764
  mode: projectConfig.modeling?.mode,
3765
+ snapshotState,
3454
3766
  },
3455
3767
  domains: { count: registry.values().length, diagnostics: registry.diagnostics },
3456
3768
  snapshot: { id: snapshotId, error: snapshotError },
3769
+ preparation,
3770
+ readiness: {
3771
+ project: {
3772
+ state: snapshotState === 'ready' ? 'ready' : snapshotState === 'building' ? 'preparing' : snapshotState === 'failed' ? 'failed' : dbtConfigured ? 'configured' : 'missing',
3773
+ message: preparationForSnapshot?.message ?? (snapshotState === 'ready'
3774
+ ? 'dbt metadata and governed search indexes are ready.'
3775
+ : snapshotState === 'stale'
3776
+ ? 'dbt metadata is configured; search indexes will be refreshed before governed Ask.'
3777
+ : undefined),
3778
+ },
3779
+ },
3457
3780
  capabilities: {
3458
3781
  warehouse: Boolean(connection),
3459
3782
  ai: Boolean(process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.GEMINI_API_KEY || process.env.OLLAMA_BASE_URL),
@@ -3478,6 +3801,7 @@ export async function startLocalServer(opts) {
3478
3801
  if (req.method === 'POST' && path === '/api/onboarding/dbt/apply') {
3479
3802
  const requestId = apiRequestId('onboarding-dbt-apply');
3480
3803
  try {
3804
+ const applyStartedAt = Date.now();
3481
3805
  const body = await readJSON(req);
3482
3806
  let preview;
3483
3807
  try {
@@ -3513,6 +3837,7 @@ export async function startLocalServer(opts) {
3513
3837
  res.end(serializeJSON(apiErrorEnvelope({ requestId, code: 'SOURCE_CHANGED', message: 'dbt artifacts changed after preview. Review the refreshed preview before applying.', nextActions: ['Refresh the preview and review the new diff.'] })));
3514
3838
  return;
3515
3839
  }
3840
+ const validationDurationMs = Date.now() - applyStartedAt;
3516
3841
  const nextConfig = {
3517
3842
  ...projectConfig,
3518
3843
  manifestVersion: 3,
@@ -3538,8 +3863,12 @@ export async function startLocalServer(opts) {
3538
3863
  projectSnapshots.invalidate();
3539
3864
  invalidateAgentProjectState(projectRoot);
3540
3865
  let snapshotId;
3866
+ let snapshotManifest;
3867
+ const compileStartedAt = Date.now();
3541
3868
  try {
3542
- snapshotId = projectSnapshot().snapshotId;
3869
+ const snapshot = projectSnapshot();
3870
+ snapshotId = snapshot.snapshotId;
3871
+ snapshotManifest = snapshot.manifest;
3543
3872
  }
3544
3873
  catch (error) {
3545
3874
  const rollbackPath = `${configPath}.rollback-${process.pid}`;
@@ -3551,8 +3880,22 @@ export async function startLocalServer(opts) {
3551
3880
  invalidateAgentProjectState(projectRoot);
3552
3881
  throw Object.assign(new Error(`dbt-first configuration did not compile and was rolled back: ${error instanceof Error ? error.message : String(error)}`), { code: 'SNAPSHOT_BUILD_FAILED' });
3553
3882
  }
3883
+ const preparation = startDbtPreparationJob({
3884
+ kind: 'dbt_prepare',
3885
+ snapshotId,
3886
+ manifest: snapshotManifest,
3887
+ validationDurationMs,
3888
+ compileDurationMs: Date.now() - compileStartedAt,
3889
+ });
3554
3890
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3555
- res.end(serializeJSON({ requestId, snapshotId, applied: true, config: { manifestVersion: 3, modeling: nextConfig.modeling, dbt: nextConfig.dbt }, fingerprint: preview.fingerprint }));
3891
+ res.end(serializeJSON({
3892
+ requestId,
3893
+ applied: true,
3894
+ config: { manifestVersion: 3, modeling: nextConfig.modeling, dbt: nextConfig.dbt },
3895
+ fingerprint: preview.fingerprint,
3896
+ jobId: preparation.id,
3897
+ ...preparation,
3898
+ }));
3556
3899
  }
3557
3900
  catch (error) {
3558
3901
  const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : 'DBT_ARTIFACT_INVALID';
@@ -3563,7 +3906,7 @@ export async function startLocalServer(opts) {
3563
3906
  }
3564
3907
  if (req.method === 'POST' && path === '/api/onboarding/refresh') {
3565
3908
  const requestId = apiRequestId('onboarding-refresh');
3566
- const id = `dbt-refresh-${Date.now().toString(36)}`;
3909
+ const refreshStartedAt = Date.now();
3567
3910
  try {
3568
3911
  const body = await readJSON(req);
3569
3912
  const currentArtifact = previewDbtOnboarding({});
@@ -3572,20 +3915,24 @@ export async function startLocalServer(opts) {
3572
3915
  res.end(serializeJSON(apiErrorEnvelope({ requestId, code: 'SOURCE_CHANGED', message: 'dbt artifacts changed before refresh. Review the current artifact preview.', nextActions: ['Return to the dbt preview step and review the refreshed fingerprint.'] })));
3573
3916
  return;
3574
3917
  }
3918
+ const validationDurationMs = Date.now() - refreshStartedAt;
3919
+ const compileStartedAt = Date.now();
3575
3920
  projectSnapshots.invalidate();
3576
3921
  const snapshot = projectSnapshot();
3577
3922
  invalidateAgentProjectState(projectRoot);
3578
- await reindexProject(projectRoot, { manifest: snapshot.manifest, kgPath: defaultKgPath(projectRoot) });
3579
- const job = { id, kind: 'dbt_refresh', status: 'completed', createdAt: new Date().toISOString(), result: { snapshotId: snapshot.snapshotId, diagnostics: snapshot.manifest.diagnostics ?? [] } };
3580
- onboardingJobs.set(id, job);
3923
+ const job = startDbtPreparationJob({
3924
+ kind: 'dbt_refresh',
3925
+ snapshotId: snapshot.snapshotId,
3926
+ manifest: snapshot.manifest,
3927
+ validationDurationMs,
3928
+ compileDurationMs: Date.now() - compileStartedAt,
3929
+ });
3581
3930
  res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8' });
3582
- res.end(serializeJSON({ requestId, snapshotId: snapshot.snapshotId, jobId: id, ...job }));
3931
+ res.end(serializeJSON({ requestId, jobId: job.id, ...job }));
3583
3932
  }
3584
3933
  catch (error) {
3585
- const job = { id, kind: 'dbt_refresh', status: 'failed', createdAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) };
3586
- onboardingJobs.set(id, job);
3587
3934
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
3588
- res.end(serializeJSON({ ...apiErrorEnvelope({ requestId, code: 'SNAPSHOT_BUILD_FAILED', message: job.error, nextActions: ['Keep using the previous snapshot while fixing compile diagnostics.'] }), jobId: id, ...job }));
3935
+ res.end(serializeJSON(apiErrorEnvelope({ requestId, code: 'SNAPSHOT_BUILD_FAILED', message: error instanceof Error ? error.message : String(error), nextActions: ['Keep using the previous snapshot while fixing compile diagnostics.'] })));
3589
3936
  }
3590
3937
  return;
3591
3938
  }
@@ -3604,10 +3951,17 @@ export async function startLocalServer(opts) {
3604
3951
  const requestId = apiRequestId('onboarding-job-cancel');
3605
3952
  const id = decodeURIComponent(path.slice('/api/onboarding/jobs/'.length));
3606
3953
  const job = onboardingJobs.get(id);
3607
- if (job)
3608
- onboardingJobs.set(id, { ...job, status: 'cancelled' });
3954
+ const cancelled = job ? {
3955
+ ...job,
3956
+ status: 'cancelled',
3957
+ message: 'Stopped waiting for dbt preparation. The last valid snapshot remains available.',
3958
+ updatedAt: new Date().toISOString(),
3959
+ phases: job.phases.map((phase) => phase.status === 'running' ? { ...phase, status: 'cancelled' } : phase),
3960
+ } : undefined;
3961
+ if (cancelled)
3962
+ onboardingJobs.set(id, cancelled);
3609
3963
  res.writeHead(job ? 200 : 404, { 'Content-Type': 'application/json; charset=utf-8' });
3610
- res.end(serializeJSON(job ? { requestId, ...job, status: 'cancelled' } : apiErrorEnvelope({ requestId, code: 'ONBOARDING_JOB_NOT_FOUND', message: `onboarding job not found: ${id}`, recoverable: false })));
3964
+ res.end(serializeJSON(cancelled ? { requestId, ...cancelled } : apiErrorEnvelope({ requestId, code: 'ONBOARDING_JOB_NOT_FOUND', message: `onboarding job not found: ${id}`, recoverable: false })));
3611
3965
  return;
3612
3966
  }
3613
3967
  if (req.method === 'POST' && path === '/api/onboarding/domains/discover') {
@@ -3741,7 +4095,12 @@ export async function startLocalServer(opts) {
3741
4095
  const requestId = apiRequestId('domain-workspace');
3742
4096
  const suffix = decodeURIComponent(path.slice('/api/domain-workspaces/'.length));
3743
4097
  const relatedSuffix = '/related-products';
3744
- const domainId = suffix.endsWith(relatedSuffix) ? suffix.slice(0, -relatedSuffix.length) : suffix;
4098
+ const knowledgeSuffix = '/knowledge';
4099
+ const domainId = suffix.endsWith(relatedSuffix)
4100
+ ? suffix.slice(0, -relatedSuffix.length)
4101
+ : suffix.endsWith(knowledgeSuffix)
4102
+ ? suffix.slice(0, -knowledgeSuffix.length)
4103
+ : suffix;
3745
4104
  const snapshot = projectSnapshot();
3746
4105
  const manifest = snapshot.manifest;
3747
4106
  if (!manifest.modeling?.packages[domainId]) {
@@ -3749,6 +4108,19 @@ export async function startLocalServer(opts) {
3749
4108
  res.end(serializeJSON(apiErrorEnvelope({ requestId, snapshotId: snapshot.snapshotId, code: 'DOMAIN_NOT_FOUND', message: `domain workspace not found: ${domainId}`, recoverable: false })));
3750
4109
  return;
3751
4110
  }
4111
+ if (suffix.endsWith(knowledgeSuffix)) {
4112
+ await ensureMetadataCatalogFresh(projectRoot, { manifest, semanticLayer });
4113
+ const knowledge = readIndexedDomainKnowledge(projectRoot, domainId)
4114
+ ?? canonicalDomainKnowledge(manifest, domainId, snapshot.snapshotId);
4115
+ if (!knowledge) {
4116
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
4117
+ res.end(serializeJSON(apiErrorEnvelope({ requestId, snapshotId: snapshot.snapshotId, code: 'DOMAIN_KNOWLEDGE_NOT_FOUND', message: `domain knowledge capsule not found: ${domainId}`, recoverable: false })));
4118
+ return;
4119
+ }
4120
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
4121
+ res.end(serializeJSON({ requestId, ...knowledge }));
4122
+ return;
4123
+ }
3752
4124
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3753
4125
  res.end(serializeJSON(suffix.endsWith(relatedSuffix)
3754
4126
  ? { requestId, domain: domainId, ...relatedProductsForDomain(manifest, domainId), snapshotId: snapshot.snapshotId }
@@ -3765,11 +4137,64 @@ export async function startLocalServer(opts) {
3765
4137
  // provenance references and the sparse DQL overlay, never copied dbt YAML.
3766
4138
  if (req.method === 'GET' && path === '/api/modeling/dbt-first') {
3767
4139
  const requestId = apiRequestId('modeling-dbt-first');
3768
- const snapshot = projectSnapshot();
4140
+ const modelingEnabled = projectConfig.manifestVersion === 3 && projectConfig.modeling?.mode === 'dbt-first';
4141
+ if (!modelingEnabled) {
4142
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
4143
+ res.end(serializeJSON(apiErrorEnvelope({
4144
+ requestId,
4145
+ code: 'DBT_FIRST_NOT_ENABLED',
4146
+ message: 'dbt-first modeling is not enabled. Connect and apply a dbt project from Settings → Project & dbt.',
4147
+ details: {
4148
+ manifestVersion: projectConfig.manifestVersion ?? 2,
4149
+ modelingMode: projectConfig.modeling?.mode ?? null,
4150
+ },
4151
+ nextActions: ['Open Settings → Project & dbt and apply a valid dbt project.'],
4152
+ })));
4153
+ return;
4154
+ }
4155
+ const dbtProjectDir = findDbtProjectPath(projectRoot, projectConfig);
4156
+ const configuredManifestPath = projectConfig.dbt?.manifestPath ?? 'target/manifest.json';
4157
+ const expectedManifestPath = resolve(dbtProjectDir, configuredManifestPath);
4158
+ if (!existsSync(expectedManifestPath)) {
4159
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
4160
+ res.end(serializeJSON(apiErrorEnvelope({
4161
+ requestId,
4162
+ code: 'DBT_MANIFEST_NOT_FOUND',
4163
+ message: `The configured dbt manifest was not found at ${configuredManifestPath}.`,
4164
+ details: {
4165
+ projectDir: projectConfig.dbt?.projectDir ?? portableProjectPath(projectRoot, dbtProjectDir),
4166
+ manifestPath: configuredManifestPath,
4167
+ },
4168
+ nextActions: ['Run dbt parse, dbt compile, or dbt build in the configured dbt project, then refresh Domain Studio.'],
4169
+ })));
4170
+ return;
4171
+ }
4172
+ let snapshot;
4173
+ try {
4174
+ snapshot = projectSnapshot();
4175
+ }
4176
+ catch (error) {
4177
+ res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
4178
+ res.end(serializeJSON(apiErrorEnvelope({
4179
+ requestId,
4180
+ code: 'DBT_MANIFEST_COMPILE_FAILED',
4181
+ message: apiErrorMessage(error),
4182
+ details: { manifestPath: configuredManifestPath },
4183
+ nextActions: ['Fix or rebuild the dbt manifest, then refresh Domain Studio.'],
4184
+ })));
4185
+ return;
4186
+ }
3769
4187
  const manifest = snapshot.manifest;
3770
4188
  if (manifest.manifestVersion !== 3 || !manifest.modeling || !manifest.dbtProvenance) {
3771
- res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
3772
- res.end(serializeJSON(apiErrorEnvelope({ requestId, snapshotId: snapshot.snapshotId, code: 'DBT_FIRST_NOT_ENABLED', message: 'dbt-first modeling is not enabled. Set manifestVersion: 3 and modeling.mode: "dbt-first" in dql.config.json.', nextActions: ['Use Setup to connect dbt and enable dbt-first modeling.'] })));
4189
+ res.writeHead(422, { 'Content-Type': 'application/json; charset=utf-8' });
4190
+ res.end(serializeJSON(apiErrorEnvelope({
4191
+ requestId,
4192
+ snapshotId: snapshot.snapshotId,
4193
+ code: 'DBT_MANIFEST_COMPILE_FAILED',
4194
+ message: snapshot.error ?? 'DQL could not compile Domain Studio from the configured dbt manifest.',
4195
+ details: { manifestPath: configuredManifestPath },
4196
+ nextActions: ['Review the dbt artifact and DQL compile diagnostics, rebuild the dbt manifest, then refresh Domain Studio.'],
4197
+ })));
3773
4198
  return;
3774
4199
  }
3775
4200
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -3812,12 +4237,35 @@ export async function startLocalServer(opts) {
3812
4237
  const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit')) || 50));
3813
4238
  const cursor = Math.max(0, Number(url.searchParams.get('cursor')) || 0);
3814
4239
  const query = (url.searchParams.get('q') ?? '').trim().toLowerCase();
4240
+ const queryTokens = query.split(/[^a-z0-9]+/).filter(Boolean);
3815
4241
  const domain = (url.searchParams.get('domain') ?? '').trim();
3816
4242
  const boundByDbtId = new Map(Object.values(manifest.modeling?.entities ?? {}).map((entity) => [entity.dbtUniqueId, entity]));
3817
4243
  const nodes = Object.values(manifest.dbtProvenance?.nodes ?? {})
3818
- .filter((node) => !query || `${node.name} ${node.relation ?? ''} ${node.sourcePath ?? ''}`.toLowerCase().includes(query))
4244
+ .filter((node) => {
4245
+ if (!queryTokens.length)
4246
+ return true;
4247
+ const haystack = `${node.name} ${node.uniqueId} ${node.relation ?? ''} ${node.sourcePath ?? ''}`.toLowerCase();
4248
+ return queryTokens.every((token) => haystack.includes(token));
4249
+ })
3819
4250
  .filter((node) => !domain || boundByDbtId.get(node.uniqueId)?.domain === domain)
3820
- .sort((a, b) => a.uniqueId.localeCompare(b.uniqueId));
4251
+ .sort((a, b) => {
4252
+ if (!query)
4253
+ return a.uniqueId.localeCompare(b.uniqueId);
4254
+ const score = (node) => {
4255
+ const name = node.name.toLowerCase();
4256
+ const uniqueId = node.uniqueId.toLowerCase();
4257
+ let value = name === query || uniqueId === query ? 1_000 : 0;
4258
+ if (name.startsWith(query))
4259
+ value += 500;
4260
+ if (uniqueId.startsWith(query))
4261
+ value += 300;
4262
+ if (name.includes(query))
4263
+ value += 200;
4264
+ value += queryTokens.reduce((total, token) => total + (name.startsWith(token) ? 80 : name.includes(token) ? 40 : 10), 0);
4265
+ return value;
4266
+ };
4267
+ return score(b) - score(a) || a.uniqueId.localeCompare(b.uniqueId);
4268
+ });
3821
4269
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3822
4270
  res.end(serializeJSON({
3823
4271
  requestId,
@@ -4036,8 +4484,13 @@ export async function startLocalServer(opts) {
4036
4484
  }
4037
4485
  const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
4038
4486
  const runId = parsed.request.runId;
4487
+ const runController = new AbortController();
4488
+ parsed.request.signal = AbortSignal.any([
4489
+ runController.signal,
4490
+ AbortSignal.timeout(agentRunDeadlineMs(parsed.request)),
4491
+ ]);
4039
4492
  if (runId)
4040
- activeAgentRunControllers.set(runId, new AbortController());
4493
+ activeAgentRunControllers.set(runId, runController);
4041
4494
  try {
4042
4495
  if (wantsStream) {
4043
4496
  res.writeHead(200, {
@@ -9548,13 +10001,17 @@ export async function startLocalServer(opts) {
9548
10001
  try {
9549
10002
  const graph = buildProjectLineageGraph(projectRoot, semanticLayer);
9550
10003
  const result = queryBusiness360(graph, rawNodeId);
9551
- if (!result) {
10004
+ const snapshot = projectSnapshot();
10005
+ await ensureMetadataCatalogFresh(projectRoot, { manifest: snapshot.manifest, semanticLayer });
10006
+ const knowledge = readIndexedKnowledge360(projectRoot, rawNodeId)
10007
+ ?? canonicalKnowledge360(snapshot.manifest, rawNodeId, snapshot.snapshotId);
10008
+ if (!result && !knowledge) {
9552
10009
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
9553
10010
  res.end(serializeJSON({ error: `Lineage node "${rawNodeId}" not found` }));
9554
10011
  return;
9555
10012
  }
9556
10013
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
9557
- res.end(serializeJSON(result));
10014
+ res.end(serializeJSON(result ? { ...result, knowledge } : { version: 3, knowledge }));
9558
10015
  }
9559
10016
  catch (error) {
9560
10017
  res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -10345,7 +10802,7 @@ export function resolveDefaultLLMProvider(projectRoot) {
10345
10802
  const preferred = ['openai', 'gemini', 'anthropic', 'custom-openai', 'ollama'];
10346
10803
  for (const id of preferred) {
10347
10804
  const provider = settings.find((item) => item.id === id);
10348
- if (provider?.enabled && provider.hasApiKey)
10805
+ if (provider?.enabled && provider.configured)
10349
10806
  return id;
10350
10807
  }
10351
10808
  return null;
@@ -11779,7 +12236,8 @@ function installConnectorPackage(projectRoot, driver) {
11779
12236
  description: 'Project-local DQL connector packages',
11780
12237
  }, null, 2) + '\n', 'utf-8');
11781
12238
  }
11782
- execFileSync('npm', ['install', '--prefix', installRoot, '--no-audit', '--no-fund', definition.packageSpec], {
12239
+ const npm = resolveNpmInvocation();
12240
+ execFileSync(npm.command, [...npm.argsPrefix, 'install', '--prefix', installRoot, '--no-audit', '--no-fund', definition.packageSpec], {
11783
12241
  cwd: projectRoot,
11784
12242
  encoding: 'utf-8',
11785
12243
  stdio: 'pipe',
@@ -12986,9 +13444,18 @@ function readUserPrefs(userPrefsPath) {
12986
13444
  return { favorites: [], recentlyUsed: [] };
12987
13445
  }
12988
13446
  const raw = JSON.parse(readFileSync(userPrefsPath, 'utf-8'));
13447
+ const acknowledgedVersion = typeof raw.setup?.acknowledgedVersion === 'string'
13448
+ ? raw.setup.acknowledgedVersion.trim()
13449
+ : '';
12989
13450
  return {
12990
13451
  favorites: Array.isArray(raw.favorites) ? raw.favorites.map(String) : [],
12991
13452
  recentlyUsed: Array.isArray(raw.recentlyUsed) ? raw.recentlyUsed.map(String) : [],
13453
+ ...(acknowledgedVersion ? {
13454
+ setup: {
13455
+ acknowledgedVersion,
13456
+ ...(typeof raw.setup?.acknowledgedAt === 'string' ? { acknowledgedAt: raw.setup.acknowledgedAt } : {}),
13457
+ },
13458
+ } : {}),
12992
13459
  };
12993
13460
  }
12994
13461
  catch {
@@ -15464,7 +15931,7 @@ async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
15464
15931
  const activeProvider = getActiveProvider(projectRoot);
15465
15932
  // Subscription CLI providers (Claude Code / Codex) carry no API key — they're
15466
15933
  // usable when enabled; their real "installed + logged in" check runs in available().
15467
- const isUsable = (provider) => provider.enabled && (provider.hasApiKey || provider.authMode === 'subscription_cli');
15934
+ const isUsable = (provider) => provider.enabled && provider.configured;
15468
15935
  const selected = requestedProvider
15469
15936
  ? settings.find((provider) => provider.id === requestedProvider && isUsable(provider))
15470
15937
  : settings.find((provider) => provider.id === activeProvider && provider.enabled)
@@ -16386,17 +16853,127 @@ function buildNotebookTemplate(title, template) {
16386
16853
  return JSON.stringify({ dqlnbVersion: 2, version: 1, title, cells }, null, 2);
16387
16854
  }
16388
16855
  /** Build a lineage graph from the project's blocks and semantic layer. */
16389
- // Simple lineage graph cache: rebuilds at most every 5 seconds
16390
- let _lineageCache = null;
16391
- const LINEAGE_CACHE_TTL_MS = 5000;
16856
+ // Cache per project + source fingerprint. A process can serve different roots,
16857
+ // and a time-only singleton previously returned the wrong graph after a change.
16858
+ const _lineageCache = new Map();
16392
16859
  function buildProjectLineageGraph(projectRoot, semanticLayer) {
16393
- if (_lineageCache && Date.now() - _lineageCache.builtAt < LINEAGE_CACHE_TTL_MS) {
16394
- return _lineageCache.graph;
16395
- }
16860
+ const signature = lineageSourceSignature(projectRoot);
16861
+ const cached = _lineageCache.get(projectRoot);
16862
+ if (cached?.signature === signature)
16863
+ return cached.graph;
16396
16864
  const graph = buildProjectLineageGraphUncached(projectRoot, semanticLayer);
16397
- _lineageCache = { graph, builtAt: Date.now() };
16865
+ _lineageCache.set(projectRoot, { signature, graph });
16398
16866
  return graph;
16399
16867
  }
16868
+ function lineageSourceSignature(projectRoot) {
16869
+ const hash = createHash('sha256');
16870
+ const dbtManifestPath = resolveDbtManifestPath(projectRoot, {}) ?? undefined;
16871
+ const inputs = new Set(collectInputFiles({ projectRoot, dbtManifestPath }));
16872
+ const emittedManifest = join(projectRoot, 'dql-manifest.json');
16873
+ if (existsSync(emittedManifest))
16874
+ inputs.add(emittedManifest);
16875
+ for (const input of [...inputs].sort()) {
16876
+ try {
16877
+ const stats = statSync(input);
16878
+ hash.update(`${input}\0${stats.size}\0${stats.mtimeMs}\n`);
16879
+ }
16880
+ catch {
16881
+ hash.update(`${input}\0missing\n`);
16882
+ }
16883
+ }
16884
+ return hash.digest('hex');
16885
+ }
16886
+ /** UI-008: bounded compiler-owned Domain Knowledge Capsule response. */
16887
+ function canonicalDomainKnowledge(manifest, domainId, snapshotId) {
16888
+ const graph = manifest.knowledgeGraph;
16889
+ if (!graph)
16890
+ return null;
16891
+ const capsule = graph.domainCapsules[domainId]
16892
+ ?? Object.values(graph.domainCapsules).find((item) => item.domainId === domainId && !item.modelAreaId)
16893
+ ?? Object.values(graph.domainCapsules).find((item) => item.id === domainId || item.name === domainId);
16894
+ const canonicalDomainId = capsule?.domainId
16895
+ ?? Object.values(graph.objects ?? {}).find((item) => item.kind === 'domain' && (item.id === domainId || item.localId === domainId || item.aliases?.includes(domainId)))?.localId;
16896
+ if (!canonicalDomainId)
16897
+ return null;
16898
+ const objects = Object.values(graph.objects ?? {})
16899
+ .filter((item) => item.domainId === canonicalDomainId || item.id === `domain::${canonicalDomainId}`)
16900
+ .sort((a, b) => a.id.localeCompare(b.id));
16901
+ const objectIds = new Set(objects.map((item) => item.id));
16902
+ const edges = (graph.edges ?? [])
16903
+ .filter((edge) => objectIds.has(edge.from) || objectIds.has(edge.to))
16904
+ .slice(0, 1_500);
16905
+ const routes = graph.crossDomainRoutes.filter((route) => route.providerDomainId === canonicalDomainId || route.consumerDomainId === canonicalDomainId);
16906
+ const routeSummary = routes.reduce((counts, route) => {
16907
+ counts[route.state] = (counts[route.state] ?? 0) + 1;
16908
+ return counts;
16909
+ }, {});
16910
+ return {
16911
+ schemaVersion: graph.schemaVersion,
16912
+ snapshotId,
16913
+ sourceFingerprint: graph.sourceFingerprint,
16914
+ domainId: canonicalDomainId,
16915
+ capsule: capsule ?? graph.domainCapsules[canonicalDomainId],
16916
+ counts: {
16917
+ objects: objects.length,
16918
+ edges: edges.length,
16919
+ routes: routes.length,
16920
+ routeStates: routeSummary,
16921
+ },
16922
+ objects: objects.slice(0, 750),
16923
+ edges,
16924
+ routes,
16925
+ truncated: objects.length > 750 || edges.length >= 1_500,
16926
+ };
16927
+ }
16928
+ /** REL-003: qualified-object neighborhood with route policy and provenance. */
16929
+ function canonicalKnowledge360(manifest, rawId, snapshotId) {
16930
+ const graph = manifest.knowledgeGraph;
16931
+ if (!graph)
16932
+ return null;
16933
+ const graphObjects = graph.objects ?? {};
16934
+ const graphEdges = graph.edges ?? [];
16935
+ const exact = graphObjects[rawId];
16936
+ const matches = exact ? [exact] : Object.values(graphObjects).filter((item) => item.localId === rawId || item.aliases?.includes(rawId) || item.id.endsWith(`::${rawId}`));
16937
+ if (matches.length !== 1) {
16938
+ return matches.length > 1 ? {
16939
+ snapshotId,
16940
+ sourceFingerprint: graph.sourceFingerprint,
16941
+ ambiguous: true,
16942
+ candidates: matches.slice(0, 20).map((item) => ({ id: item.id, kind: item.kind, domainId: item.domainId })),
16943
+ } : null;
16944
+ }
16945
+ const focus = matches[0];
16946
+ const ids = new Set([focus.id]);
16947
+ let frontier = new Set([focus.id]);
16948
+ for (let depth = 0; depth < 2 && frontier.size > 0 && ids.size < 160; depth += 1) {
16949
+ const next = new Set();
16950
+ for (const edge of graphEdges) {
16951
+ if (frontier.has(edge.from) && !ids.has(edge.to))
16952
+ next.add(edge.to);
16953
+ if (frontier.has(edge.to) && !ids.has(edge.from))
16954
+ next.add(edge.from);
16955
+ }
16956
+ for (const id of next) {
16957
+ if (ids.size >= 160)
16958
+ break;
16959
+ ids.add(id);
16960
+ }
16961
+ frontier = next;
16962
+ }
16963
+ const objects = [...ids].flatMap((id) => graphObjects[id] ? [graphObjects[id]] : []);
16964
+ const edges = graphEdges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)).slice(0, 500);
16965
+ const domains = new Set(objects.flatMap((item) => item.domainId ? [item.domainId] : []));
16966
+ const routes = graph.crossDomainRoutes.filter((route) => domains.has(route.providerDomainId) && domains.has(route.consumerDomainId));
16967
+ return {
16968
+ snapshotId,
16969
+ sourceFingerprint: graph.sourceFingerprint,
16970
+ focus,
16971
+ objects,
16972
+ edges,
16973
+ routes,
16974
+ truncated: ids.size >= 160 || edges.length >= 500,
16975
+ };
16976
+ }
16400
16977
  function buildProjectLineageGraphUncached(projectRoot, semanticLayer) {
16401
16978
  const manifestPath = join(projectRoot, 'dql-manifest.json');
16402
16979
  if (existsSync(manifestPath)) {
@@ -17232,7 +17809,12 @@ async function readGitStatus(cwd) {
17232
17809
  behind = Number(match[0] ?? 0);
17233
17810
  ahead = Number(match[1] ?? 0);
17234
17811
  }
17235
- const statusRes = await execGit(gitRoot, ['status', '--porcelain=v1', '--untracked-files=normal']);
17812
+ // UI-001, E2E-001: the Source Control surface is file-oriented. Git's
17813
+ // "normal" untracked mode collapses a new directory into a single `?? dir/`
17814
+ // record, which prevents the UI from showing or diffing the files inside it.
17815
+ // Enumerate every untracked path so tracked and untracked changes share the
17816
+ // same per-file review contract.
17817
+ const statusRes = await execGit(gitRoot, ['status', '--porcelain=v1', '--untracked-files=all']);
17236
17818
  const changes = [];
17237
17819
  if (statusRes.code === 0) {
17238
17820
  for (const line of statusRes.stdout.split('\n')) {
@@ -17884,7 +18466,11 @@ async function testProviderConfig(projectRoot, id, overrides) {
17884
18466
  const base = getEffectiveProviderConfig(projectRoot, id);
17885
18467
  // When the user supplies inline values (testing what they typed before saving),
17886
18468
  // merge them over the saved config and test reachability regardless of enabled.
17887
- const inline = Boolean(overrides && (overrides.apiKey || overrides.baseUrl || overrides.model));
18469
+ // The test route always passes an override object, even when every draft field
18470
+ // is blank. That still means "test this candidate before save" and must allow
18471
+ // a previously configured-but-disabled provider to be reactivated without
18472
+ // forcing the user to paste its redacted secret again.
18473
+ const inline = overrides !== undefined;
17888
18474
  const config = {
17889
18475
  ...base,
17890
18476
  ...(overrides?.apiKey ? { apiKey: overrides.apiKey } : {}),
@@ -17964,15 +18550,19 @@ async function testOpenAIProviderConfig(config, label, details) {
17964
18550
  return { ok: false, message: `${label} is not configured${details}. Add an API key in Settings or OPENAI_API_KEY.` };
17965
18551
  }
17966
18552
  try {
17967
- const client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl });
17968
- const response = await client.responses.create({
17969
- model: config.model ?? 'gpt-5.5',
17970
- input: 'Reply with exactly: OK',
17971
- max_output_tokens: 16,
18553
+ // Use the same provider adapter as governed Ask so enterprise gateways are
18554
+ // tested against the runtime path users will actually execute.
18555
+ const provider = new OpenAIProvider({
18556
+ apiKey: config.apiKey,
18557
+ baseUrl: config.baseUrl,
18558
+ model: config.model,
17972
18559
  });
18560
+ const response = await provider.generate([
18561
+ { role: 'user', content: 'Reply with exactly: OK' },
18562
+ ], { maxTokens: 16, temperature: 0 });
17973
18563
  return {
17974
18564
  ok: true,
17975
- message: `${label} SDK responded${details}: ${(response.output_text ?? 'OK').trim().slice(0, 80) || 'OK'}`,
18565
+ message: `${label} responded through the governed runtime${details}: ${response.trim().slice(0, 80) || 'OK'}`,
17976
18566
  };
17977
18567
  }
17978
18568
  catch (error) {
@@ -17987,20 +18577,17 @@ async function testAnthropicProviderConfig(config, label, details) {
17987
18577
  return { ok: false, message: `${label} is not configured${details}. Add an API key in Settings or ANTHROPIC_API_KEY.` };
17988
18578
  }
17989
18579
  try {
17990
- const client = new Anthropic({
18580
+ const provider = new ClaudeProvider({
17991
18581
  apiKey: config.apiKey,
17992
- ...(config.baseUrl ? { baseURL: normalizeAnthropicBaseUrl(config.baseUrl) } : {}),
17993
- });
17994
- const response = await client.messages.create({
17995
- model: config.model ?? 'claude-opus-4-8',
17996
- max_tokens: 16,
17997
- temperature: 0,
17998
- messages: [{ role: 'user', content: 'Reply with exactly: OK' }],
18582
+ baseUrl: config.baseUrl,
18583
+ model: config.model,
17999
18584
  });
18000
- const text = response.content?.filter((block) => block.type === 'text').map((block) => block.text ?? '').join('') ?? '';
18585
+ const response = await provider.generate([
18586
+ { role: 'user', content: 'Reply with exactly: OK' },
18587
+ ], { maxTokens: 16, temperature: 0 });
18001
18588
  return {
18002
18589
  ok: true,
18003
- message: `${label} SDK responded${details}: ${text.trim().slice(0, 80) || 'OK'}`,
18590
+ message: `${label} responded through the governed runtime${details}: ${response.trim().slice(0, 80) || 'OK'}`,
18004
18591
  };
18005
18592
  }
18006
18593
  catch (error) {
@@ -18049,6 +18636,13 @@ async function buildAgentSchemaContextFromCatalog(projectRoot, question, prepare
18049
18636
  }
18050
18637
  /** How long a stored live-warehouse schema snapshot is trusted before a rescan (P6). */
18051
18638
  const RUNTIME_SNAPSHOT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
18639
+ // A resolver compares at most 12 compact cards and never performs tool calls;
18640
+ // ten seconds is the full allowance, not the start of another planning loop.
18641
+ const AGENT_MEANING_TIMEOUT_MS = 10_000;
18642
+ export function boundedAgentMeaningSignal(signal, timeoutMs = AGENT_MEANING_TIMEOUT_MS) {
18643
+ const timeout = AbortSignal.timeout(Math.max(1, timeoutMs));
18644
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
18645
+ }
18052
18646
  /**
18053
18647
  * Whether the project's stored live-schema snapshot is missing or older than the
18054
18648
  * freshness window (P6). Used to force a fresh information_schema scan even when the
@@ -18084,7 +18678,6 @@ function recordAgentRuntimeSchemaSnapshot(projectRoot, schemaContext, source) {
18084
18678
  name: column.name,
18085
18679
  type: column.type,
18086
18680
  description: column.description,
18087
- sampleValues: column.sampleValues?.slice(0, 8),
18088
18681
  })),
18089
18682
  })),
18090
18683
  });
@@ -19017,25 +19610,29 @@ export function shouldAugmentAgentRuntimeSchema(question, questionPlan) {
19017
19610
  const plannedCompositeMetric = (questionPlan?.metricTerms?.length ?? 0) > 0 && plannedConcepts.size >= 2;
19018
19611
  return explicitJoin || referencesPriorRows || plannedCompositeMetric || (multiEntity && (wantsMetric || wantsDetail));
19019
19612
  }
19020
- async function enrichAgentSchemaContextWithValueMatches(question, schemaContext, executor, connection) {
19613
+ async function enrichAgentSchemaContextWithValueMatches(question, schemaContext, executor, connection, searchSafeColumns) {
19021
19614
  const searchTerms = extractAgentValueSearchTerms(question);
19022
19615
  if (schemaContext.length === 0 || searchTerms.length === 0)
19023
19616
  return schemaContext;
19024
19617
  const matches = new Map();
19025
- for (const candidate of rankAgentValueProbeColumns(schemaContext).slice(0, 12)) {
19618
+ const probes = rankAgentValueProbeColumns(schemaContext, searchSafeColumns).slice(0, 3).map(async (candidate) => {
19026
19619
  try {
19027
- const result = await executor.executeQuery(buildAgentValueProbeSql(candidate.table, candidate.column.name, searchTerms, connection), [], runtimeVariables({}), connection);
19028
- const values = uniqueStrings(result.rows.flatMap(valueProbeRowValues)).slice(0, 5);
19029
- if (values.length === 0)
19030
- continue;
19031
- const tableMatches = matches.get(candidate.table.relation) ?? new Map();
19032
- tableMatches.set(candidate.column.name, values);
19033
- matches.set(candidate.table.relation, tableMatches);
19620
+ const result = await withAgentValueProbeTimeout(executor.executeQuery(buildAgentValueProbeSql(candidate.table, candidate.column.name, searchTerms, connection), [], runtimeVariables({}), connection), 2_000);
19621
+ const values = uniqueStrings(result.rows.flatMap(valueProbeRowValues)).slice(0, 25);
19622
+ return values.length > 0 ? { candidate, values } : undefined;
19034
19623
  }
19035
19624
  catch {
19036
19625
  // Value probes are advisory. Unsupported casts, privileges, and large-table
19037
19626
  // failures should not block the metadata-backed answer path.
19627
+ return undefined;
19038
19628
  }
19629
+ });
19630
+ for (const match of await Promise.all(probes)) {
19631
+ if (!match)
19632
+ continue;
19633
+ const tableMatches = matches.get(match.candidate.table.relation) ?? new Map();
19634
+ tableMatches.set(match.candidate.column.name, match.values);
19635
+ matches.set(match.candidate.table.relation, tableMatches);
19039
19636
  }
19040
19637
  if (matches.size === 0)
19041
19638
  return schemaContext;
@@ -19048,12 +19645,27 @@ async function enrichAgentSchemaContextWithValueMatches(question, schemaContext,
19048
19645
  columns: table.columns.map((column) => {
19049
19646
  const sampleValues = tableMatches.get(column.name);
19050
19647
  return sampleValues?.length
19051
- ? { ...column, sampleValues: uniqueStrings([...(column.sampleValues ?? []), ...sampleValues]).slice(0, 5) }
19648
+ ? { ...column, sampleValues: uniqueStrings([...(column.sampleValues ?? []), ...sampleValues]).slice(0, 25) }
19052
19649
  : column;
19053
19650
  }),
19054
19651
  };
19055
19652
  });
19056
19653
  }
19654
+ async function withAgentValueProbeTimeout(promise, timeoutMs) {
19655
+ let timer;
19656
+ try {
19657
+ return await Promise.race([
19658
+ promise,
19659
+ new Promise((_resolve, reject) => {
19660
+ timer = setTimeout(() => reject(new Error('VALUE_LOOKUP_TIMEOUT')), timeoutMs);
19661
+ }),
19662
+ ]);
19663
+ }
19664
+ finally {
19665
+ if (timer)
19666
+ clearTimeout(timer);
19667
+ }
19668
+ }
19057
19669
  function scoreAgentSchemaTable(table, tokens) {
19058
19670
  let score = 0;
19059
19671
  const relationTokens = agentSchemaTokens(`${table.schema ?? ''} ${table.name} ${table.relation}`);
@@ -19134,11 +19746,37 @@ function scoreAgentValueProbeTable(table) {
19134
19746
  }
19135
19747
  return Math.min(score, 18);
19136
19748
  }
19137
- function rankAgentValueProbeColumns(schemaContext) {
19749
+ /**
19750
+ * Resolve the project-admin boundary for live value lookup. An absent/malformed
19751
+ * policy is deliberately disabled; a broad table or wildcard cannot make an
19752
+ * unknown column search-safe.
19753
+ */
19754
+ export function resolveAgentRuntimeValueGrounding(config) {
19755
+ const configured = config.agent?.runtimeValueGrounding;
19756
+ if (configured?.mode !== 'safe_automatic') {
19757
+ return { mode: 'disabled', searchSafeColumns: new Set() };
19758
+ }
19759
+ const searchSafeColumns = new Set((configured.searchSafeColumns ?? [])
19760
+ .map(normalizeAgentSafeColumnReference)
19761
+ .filter((value) => value.split('.').length >= 2 && !value.includes('*')));
19762
+ return searchSafeColumns.size > 0
19763
+ ? { mode: 'safe_automatic', searchSafeColumns }
19764
+ : { mode: 'disabled', searchSafeColumns };
19765
+ }
19766
+ function normalizeAgentSafeColumnReference(value) {
19767
+ return value.trim().replace(/[`"\[\]]/g, '').toLowerCase();
19768
+ }
19769
+ function isExplicitlySearchSafeAgentColumn(table, column, searchSafeColumns) {
19770
+ const qualified = normalizeAgentSafeColumnReference(`${table.relation}.${column.name}`);
19771
+ const relationParts = table.relation.split('.').filter(Boolean);
19772
+ const shortQualified = normalizeAgentSafeColumnReference(`${relationParts.slice(-1)[0] ?? table.relation}.${column.name}`);
19773
+ return searchSafeColumns.has(qualified) || searchSafeColumns.has(shortQualified);
19774
+ }
19775
+ function rankAgentValueProbeColumns(schemaContext, searchSafeColumns) {
19138
19776
  const ranked = [];
19139
19777
  for (const table of schemaContext) {
19140
19778
  for (const column of table.columns) {
19141
- if (!isAgentValueProbeColumn(column))
19779
+ if (!isAgentValueProbeColumn(column) || !isExplicitlySearchSafeAgentColumn(table, column, searchSafeColumns))
19142
19780
  continue;
19143
19781
  ranked.push({
19144
19782
  table,
@@ -19159,9 +19797,18 @@ function scoreAgentValueProbeColumn(table, column) {
19159
19797
  score += 3;
19160
19798
  return score;
19161
19799
  }
19162
- function isAgentValueProbeColumn(column) {
19800
+ export function isAgentValueProbeColumn(column) {
19163
19801
  const name = column.name.toLowerCase();
19164
- if (/\b(password|secret|token|credential|hash|salt)\b/.test(name))
19802
+ // Tokenize underscore/camel names before applying the hard deny-list. This is
19803
+ // intentionally independent of an allowlist: secrets and free-text payloads
19804
+ // can never be probed through automatic grounding.
19805
+ const normalizedName = column.name
19806
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
19807
+ .replace(/[_-]+/g, ' ')
19808
+ .toLowerCase();
19809
+ if (/\b(password|secret|token|credential|hash|salt|notes?|comments?|description|message|body|payload|content)\b/.test(normalizedName))
19810
+ return false;
19811
+ if (/\bemail\b/.test(normalizedName))
19165
19812
  return false;
19166
19813
  if (!hasAgentSchemaToken(name, [
19167
19814
  'account',
@@ -19199,15 +19846,22 @@ export function buildAgentValueProbeSql(table, column, searchTerms, connection)
19199
19846
  const relation = quoteAgentRelation(table.relation, connection);
19200
19847
  const identifier = quoteAgentIdentifier(column, connection);
19201
19848
  const castValue = `LOWER(CAST(${identifier} AS ${agentTextCastType(connection.driver)}))`;
19202
- const predicates = searchTerms
19203
- .slice(0, 5)
19204
- .map((term) => `${castValue} LIKE ${sqlStringLiteral(`%${escapeSqlLike(term.toLowerCase())}%`)} ESCAPE '\\'`)
19849
+ const predicates = uniqueStrings(searchTerms.flatMap((term) => {
19850
+ const normalized = term.toLowerCase().replace(/\s+/g, ' ').trim();
19851
+ const tokens = normalized.split(' ').filter((token) => token.length >= 4);
19852
+ return [
19853
+ `${castValue} = ${sqlStringLiteral(normalized)}`,
19854
+ ...tokens.slice(0, 2).map((token) => `${castValue} LIKE ${sqlStringLiteral(`${escapeSqlLike(token)}%`)} ESCAPE '\\'`),
19855
+ ];
19856
+ }))
19857
+ .slice(0, 8)
19858
+ .map((predicate) => predicate)
19205
19859
  .join(' OR ');
19206
19860
  return [
19207
19861
  `SELECT DISTINCT CAST(${identifier} AS ${agentTextCastType(connection.driver)}) AS value`,
19208
19862
  `FROM ${relation}`,
19209
19863
  `WHERE ${identifier} IS NOT NULL AND (${predicates})`,
19210
- 'LIMIT 5',
19864
+ 'LIMIT 25',
19211
19865
  ].join('\n');
19212
19866
  }
19213
19867
  function agentTextCastType(driver) {
@@ -19291,6 +19945,9 @@ export function extractAgentValueSearchTerms(question) {
19291
19945
  for (const match of question.matchAll(/\b(?:for|named|called|only|where|customer|user|account|product)\s+([A-Za-z0-9@._-]+(?:\s+[A-Za-z0-9@._-]+){0,3})/gi)) {
19292
19946
  terms.push(match[1]);
19293
19947
  }
19948
+ for (const match of question.matchAll(/\b(?:than|versus|vs\.?)\s+([A-Za-z0-9@._-]+(?:\s+[A-Za-z0-9@._-]+){0,3})/gi)) {
19949
+ terms.push(match[1]);
19950
+ }
19294
19951
  return uniqueStrings(terms
19295
19952
  .map(cleanAgentValueSearchTerm)
19296
19953
  .filter((term) => term.length >= 3 && !AGENT_VALUE_SEARCH_STOP_PHRASES.has(term.toLowerCase()))).slice(0, 6);
@@ -19301,6 +19958,7 @@ function cleanAgentValueSearchTerm(term) {
19301
19958
  .replace(/\s+/g, ' ')
19302
19959
  .trim()
19303
19960
  .replace(/^(?:account|customer|member|named|called|product|sku|subscriber|user)\s+/i, '')
19961
+ .replace(/\s+\b(?:got|get|gets|bought|buy|buys|purchased|purchase|purchases|spent|spend|spends|has|have|with)\b.*$/i, '')
19304
19962
  .replace(/\s+\b(?:last|next|this)\b.*$/i, '')
19305
19963
  .replace(/\s+\b(?:last|this)\s+(?:day|week|month|quarter|year)\b.*$/i, '')
19306
19964
  .replace(/\s+\b(?:daily|weekly|monthly|quarterly|yearly)\b.*$/i, '')