@duckcodeailabs/dql-cli 1.6.27 → 1.6.29

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.
@@ -11,11 +11,13 @@ import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, getDialect, Parser
11
11
  import { load as loadYaml } from 'js-yaml';
12
12
  import { listBlockTemplates } from './block-templates.js';
13
13
  import { getRunner as getLLMRunner } from './llm/index.js';
14
+ import { createDqlAgentProviderRunner } from './llm/providers/dql-agent-provider.js';
14
15
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
15
- import { ClaudeProvider, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, loadSkills, writeSkill, deleteSkill, reindexProject, defaultKgPath, planApp, planResearch, loadSemanticMetrics, } from '@duckcodeailabs/dql-agent';
16
+ import { ClaudeProvider, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, synthesizeAnswer, streamOrGenerate, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, loadSkills, writeSkill, deleteSkill, reindexProject, defaultKgPath, planApp, planResearch, loadSemanticMetrics, } from '@duckcodeailabs/dql-agent';
16
17
  import { gatherProposeEnrichment } from './propose-enrich.js';
17
- import { createAppAiBuildSession, handleAppsApi, recommendVisualization } from './apps-api.js';
18
+ import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
18
19
  import { getActiveProvider, getEffectiveProviderConfig, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
20
+ import { ClaudeCodeCliProvider, CodexCliProvider } from './providers/subscription-cli.js';
19
21
  import { DQLAccessDeniedError, activePersonaAppId, assertAppAccess, loadRuntimeApp, runtimeVariables, } from './governance-runtime.js';
20
22
  import { LocalAppStorage, LocalNotebookResearchStorage, defaultLocalAppsDbPath, defaultNotebookResearchDbPath } from '@duckcodeailabs/dql-project';
21
23
  import { Certifier, ENTERPRISE_RULES, evaluateInvariants, hasInvariantViolation, } from '@duckcodeailabs/dql-governance';
@@ -365,10 +367,11 @@ export async function startLocalServer(opts) {
365
367
  },
366
368
  });
367
369
  async function runGovernedAgentAnswerForRun(request, repair) {
368
- const resolvedProvider = resolveDefaultLLMProvider(projectRoot);
369
- const runner = resolvedProvider ? getLLMRunner(resolvedProvider) : null;
370
+ const governed = resolveGovernedAnswerRunner(projectRoot);
371
+ const resolvedProvider = governed?.provider ?? null;
372
+ const runner = governed?.runner ?? null;
370
373
  if (!resolvedProvider || !runner) {
371
- throw new Error('No AI provider is configured. Configure OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
374
+ throw new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
372
375
  }
373
376
  let governedAnswer;
374
377
  let providerError;
@@ -413,7 +416,7 @@ export async function startLocalServer(opts) {
413
416
  }
414
417
  return governedAnswer;
415
418
  }
416
- const answerRunExecutor = async ({ request, routeDecision, attempt, repairHint }) => {
419
+ const answerRunExecutor = async ({ request, routeDecision, attempt, repairHint, emitAnswerDelta }) => {
417
420
  let governedAnswer;
418
421
  try {
419
422
  governedAnswer = await runGovernedAgentAnswerForRun(request, { attempt, repairHint });
@@ -443,6 +446,37 @@ export async function startLocalServer(opts) {
443
446
  const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
444
447
  const needsClarification = governedAnswer.kind === 'no_answer';
445
448
  const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
449
+ // Synthesis: for a genuinely generated (non-certified) answer, compose an
450
+ // adaptive, well-formatted reply over the executed rows — streamed — instead of
451
+ // the loop's raw draft. Certified answers keep their fast path (no extra call).
452
+ let synthesizedAnswer;
453
+ if (!needsClarification && !isCertified) {
454
+ try {
455
+ const provider = await createBlockStudioAssistProvider(projectRoot);
456
+ if (provider) {
457
+ const preview = agentResultToSynthesisPreview(governedAnswer.result);
458
+ const draft = governedAnswer.answer ?? governedAnswer.text;
459
+ const result = await synthesizeAnswer({
460
+ question: request.question,
461
+ category: routeDecision?.category,
462
+ audience: request.audience ?? 'analyst',
463
+ resultPreview: preview,
464
+ sql: sql,
465
+ draftText: draft,
466
+ gaps: governedAnswer.validationWarnings,
467
+ }, {
468
+ onDelta: emitAnswerDelta,
469
+ complete: ({ system, user, signal, onDelta }) => streamOrGenerate(provider, [{ role: 'system', content: system }, { role: 'user', content: user }], { maxTokens: 350, temperature: 0.3, signal }, onDelta ?? (() => { })),
470
+ });
471
+ if (result.text)
472
+ synthesizedAnswer = result.text;
473
+ }
474
+ }
475
+ catch {
476
+ // Keep the governed draft on any synthesis failure.
477
+ synthesizedAnswer = undefined;
478
+ }
479
+ }
446
480
  const status = needsClarification ? 'needs_clarification' : isCertified ? 'completed' : 'needs_review';
447
481
  const trustState = needsClarification ? 'not_applicable' : isCertified ? 'certified' : 'review_required';
448
482
  const stopReason = needsClarification ? 'needs_clarification' : isCertified ? 'certified_answer_found' : 'human_review_required';
@@ -455,7 +489,7 @@ export async function startLocalServer(opts) {
455
489
  ];
456
490
  return {
457
491
  summary: governedAnswer.route?.label ?? (isCertified ? 'Answered from certified DQL context.' : 'Answered with review-required generated analysis.'),
458
- answer: governedAnswer.answer ?? governedAnswer.text,
492
+ answer: synthesizedAnswer ?? governedAnswer.answer ?? governedAnswer.text,
459
493
  status,
460
494
  trustState,
461
495
  stopReason,
@@ -476,7 +510,54 @@ export async function startLocalServer(opts) {
476
510
  nextActions,
477
511
  };
478
512
  };
513
+ const conversationRunExecutor = async ({ request, routeDecision, emitAnswerDelta }) => {
514
+ const kind = routeDecision?.conversationalKind ?? 'smalltalk';
515
+ const isGeneralKnowledge = routeDecision?.category === 'general_knowledge';
516
+ const answerKind = isGeneralKnowledge ? 'general_knowledge' : 'conversational';
517
+ const catalogContext = buildAgentRunCatalogContext();
518
+ const suggestions = buildConversationSuggestions(projectRoot, kind);
519
+ const nextActions = suggestions.map((prompt, index) => ({
520
+ id: `suggest-question-${index + 1}`,
521
+ label: prompt,
522
+ }));
523
+ let text;
524
+ try {
525
+ const provider = await createBlockStudioAssistProvider(projectRoot);
526
+ if (provider) {
527
+ const system = buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, request.audience ?? 'analyst');
528
+ const messages = [
529
+ { role: 'system', content: system },
530
+ ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
531
+ { role: 'user', content: request.question },
532
+ ];
533
+ text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
534
+ // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
535
+ if (text)
536
+ emitAnswerDelta?.(text);
537
+ }
538
+ }
539
+ catch {
540
+ // Provider unavailable / errored — fall through to the deterministic reply.
541
+ text = undefined;
542
+ }
543
+ if (!text) {
544
+ text = buildConversationalFallback(kind, isGeneralKnowledge, request.question, suggestions);
545
+ emitAnswerDelta?.(text);
546
+ }
547
+ return {
548
+ summary: 'Replied conversationally.',
549
+ answer: text,
550
+ answerKind,
551
+ status: 'completed',
552
+ trustState: 'not_applicable',
553
+ stopReason: 'conversational_reply',
554
+ artifacts: [],
555
+ evaluations: [],
556
+ nextActions,
557
+ };
558
+ };
479
559
  const agentRunExecutors = {
560
+ conversation: conversationRunExecutor,
480
561
  certified_answer: answerRunExecutor,
481
562
  generated_answer: answerRunExecutor,
482
563
  research: async ({ runId, request, routeDecision, emit }) => {
@@ -672,18 +753,29 @@ export async function startLocalServer(opts) {
672
753
  app_build: async ({ request, routeDecision, emit }) => {
673
754
  emit({
674
755
  type: 'executor.started',
675
- message: 'Creating app build session from governed app builder.',
756
+ message: 'Planning the app: matching certified blocks and finding coverage gaps.',
676
757
  route: 'app_build',
677
758
  });
759
+ // Two-phase build: this run only PROPOSES the content list (no files). The
760
+ // user confirms the selection and the UI calls the commit endpoint directly.
678
761
  let session;
679
762
  try {
680
- session = await createAppAiBuildSession(projectRoot, {
763
+ session = await proposeAppAiBuild(projectRoot, {
681
764
  prompt: request.question,
682
765
  domain: agentRunWorkspaceValue(request, 'domain'),
683
766
  owner: agentRunWorkspaceValue(request, 'owner'),
684
767
  notebookPath: agentRunWorkspaceValue(request, 'notebookPath') ?? request.selectedObject?.path,
685
768
  selectedBlockIds: parseAgentRunSelectedBlockIds(request),
686
769
  plannerMode: 'deterministic',
770
+ }, {
771
+ generateGovernedAnswer: async (question) => {
772
+ emit({
773
+ type: 'executor.started',
774
+ message: `Generating review-required SQL for an uncovered question: ${question}`,
775
+ route: 'app_build',
776
+ });
777
+ return runGovernedAgentAnswerForRun({ question });
778
+ },
687
779
  });
688
780
  }
689
781
  catch (error) {
@@ -703,37 +795,46 @@ export async function startLocalServer(opts) {
703
795
  ],
704
796
  };
705
797
  }
706
- const ready = session.status === 'ready';
798
+ const proposal = session.status === 'proposed' ? session.proposal : undefined;
799
+ // A real app needs a certified anchor: only offer confirmation when at least
800
+ // one certified tile is present. Otherwise fall through to the escalate path
801
+ // (research / draft the missing blocks) so the user isn't sent to a dead end.
802
+ const proposed = Boolean(proposal && proposal.tiles.some((tile) => tile.certification === 'certified' && !tile.error));
707
803
  const sessionPlan = agentRunRecord(session.plan);
708
- const appTitle = agentRunString(sessionPlan?.name) ?? 'App draft';
804
+ const appTitle = agentRunString(sessionPlan?.name) ?? 'App proposal';
805
+ const certifiedCount = proposal?.coverage.certifiedTiles ?? 0;
806
+ const generatedCount = proposal?.coverage.generatedTiles ?? 0;
807
+ const gapCount = proposal?.coverage.gaps ?? 0;
709
808
  // A coverage gap is NOT terminal — leave status open so the gate can escalate to
710
809
  // drafting the missing blocks. Only genuine infra errors (the catch above) block.
711
810
  return {
712
- summary: ready
713
- ? 'Created a review-required app draft session from certified DQL assets.'
714
- : 'App build needs more certified DQL coverage before files can be generated.',
715
- status: ready ? 'needs_review' : undefined,
716
- trustState: ready ? 'review_required' : undefined,
717
- stopReason: ready ? 'human_review_required' : undefined,
718
- artifacts: ready ? [agentRunArtifact('app_draft', appTitle, {
719
- session,
811
+ summary: proposed
812
+ ? `Proposed ${certifiedCount} certified tile${certifiedCount === 1 ? '' : 's'}`
813
+ + (generatedCount > 0 ? ` + ${generatedCount} AI-generated (review required)` : '')
814
+ + (gapCount > 0 ? `; ${gapCount} question${gapCount === 1 ? '' : 's'} still uncovered` : '')
815
+ + '. Review the list and confirm to create the app.'
816
+ : 'App build needs more certified DQL coverage before a proposal can be assembled.',
817
+ status: proposed ? 'needs_review' : undefined,
818
+ trustState: proposed ? 'review_required' : undefined,
819
+ stopReason: proposed ? 'human_review_required' : undefined,
820
+ artifacts: proposed ? [agentRunArtifact('app_proposal', appTitle, {
720
821
  sessionId: session.id,
721
822
  appId: session.appId,
722
823
  dashboardId: session.dashboardId,
723
- generatedPaths: session.generatedPaths,
724
824
  plan: session.plan,
725
- validation: session.validation,
825
+ proposal,
826
+ warnings: session.warnings,
726
827
  }, session.appId)] : [],
727
828
  evaluations: [
728
829
  agentRunEvaluation('route-decision', 'Route decision', true, 'info', routeDecision?.reason ?? 'Routed request to app build.'),
729
- agentRunEvaluation('app-coverage', 'Certified coverage', ready, ready ? 'info' : 'blocking', ready
730
- ? 'Generated app files are backed by certified block tiles and saved as a draft app session.'
830
+ agentRunEvaluation('app-coverage', 'Certified coverage', proposed, proposed ? 'info' : 'blocking', proposed
831
+ ? `Proposal is backed by ${certifiedCount} certified tile${certifiedCount === 1 ? '' : 's'}; nothing is created until you confirm.`
731
832
  : session.error ?? 'No certified app tiles matched the request.', session),
732
833
  ],
733
- nextActions: ready
834
+ nextActions: proposed
734
835
  ? [
735
- { id: 'open-app', label: 'Open app draft', artifactKind: 'app_draft' },
736
- { id: 'create-gap-blocks', label: 'Create DQL drafts for gaps', route: 'dql_block_draft', artifactKind: 'dql_block_draft' },
836
+ { id: 'confirm-app-build', label: 'Create this app', artifactKind: 'app_proposal' },
837
+ { id: 'adjust-app-build', label: 'Adjust the plan', route: 'app_build', artifactKind: 'app_proposal' },
737
838
  ]
738
839
  : [
739
840
  { id: 'research-coverage', label: 'Research missing coverage', route: 'research', artifactKind: 'research_run' },
@@ -780,12 +881,29 @@ export async function startLocalServer(opts) {
780
881
  },
781
882
  getCatalogContext: buildAgentRunCatalogContext,
782
883
  });
884
+ // Hybrid router: keep the deterministic decision when it is confident (certified
885
+ // fast paths + greetings stay 0-LLM); spend one cheap classification call only for
886
+ // the ambiguous middle so Auto reliably picks quick-answer vs deep research without
887
+ // the user clicking "Dig deeper". Same provider completion as the planner.
888
+ const agentRunRouter = createHybridRouter({
889
+ complete: async ({ system, user, signal }) => {
890
+ const provider = await createBlockStudioAssistProvider(projectRoot);
891
+ if (!provider)
892
+ throw new Error('No AI provider configured for routing.');
893
+ return provider.generate([
894
+ { role: 'system', content: system },
895
+ { role: 'user', content: user },
896
+ ], { maxTokens: 250, temperature: 0, signal });
897
+ },
898
+ getCatalogContext: () => buildAgentRunCatalogContext(),
899
+ });
783
900
  const agentRunStore = new FileAgentRunStore({ path: defaultAgentRunStorePath(projectRoot) });
784
901
  const agentRunEngine = new AgentRunEngine({
785
902
  store: agentRunStore,
786
903
  executors: agentRunExecutors,
787
904
  gates: defaultAgentRunGates,
788
905
  planner: agentRunPlanner,
906
+ router: agentRunRouter,
789
907
  });
790
908
  const runNotebookForApp = async (appId, notebookPath) => {
791
909
  const absPath = safeJoin(projectRoot, notebookPath);
@@ -967,10 +1085,11 @@ export async function startLocalServer(opts) {
967
1085
  }
968
1086
  };
969
1087
  const generateInvestigationSqlForApp = async (input) => {
970
- const resolvedProvider = resolveDefaultLLMProvider(projectRoot);
971
- const runner = resolvedProvider ? getLLMRunner(resolvedProvider) : null;
1088
+ const governed = resolveGovernedAnswerRunner(projectRoot);
1089
+ const resolvedProvider = governed?.provider ?? null;
1090
+ const runner = governed?.runner ?? null;
972
1091
  if (!resolvedProvider || !runner) {
973
- throw new Error('No AI provider is configured. Configure OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
1092
+ throw new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
974
1093
  }
975
1094
  let governedAnswer;
976
1095
  let providerError;
@@ -1182,8 +1301,9 @@ export async function startLocalServer(opts) {
1182
1301
  let providerError;
1183
1302
  const generationWarnings = [];
1184
1303
  if (!generatedSql && !reviewedSql) {
1185
- const resolvedProvider = resolveDefaultLLMProvider(projectRoot);
1186
- const runner = resolvedProvider ? getLLMRunner(resolvedProvider) : null;
1304
+ const governedResearch = resolveGovernedAnswerRunner(projectRoot);
1305
+ const resolvedProvider = governedResearch?.provider ?? null;
1306
+ const runner = governedResearch?.runner ?? null;
1187
1307
  if (!resolvedProvider || !runner) {
1188
1308
  generationWarnings.push('No AI provider is configured. Metadata context was saved as a research plan; paste SQL or configure an AI provider to generate candidate SQL.');
1189
1309
  }
@@ -1934,6 +2054,11 @@ export async function startLocalServer(opts) {
1934
2054
  res.end(serializeJSON({ error: parsed.error ?? 'Invalid agent run request.' }));
1935
2055
  return;
1936
2056
  }
2057
+ // The UI does not send retrieval signals; probe the local catalog so the
2058
+ // router can short-circuit obvious governed matches without an LLM call.
2059
+ if (!parsed.request.signals && !parsed.request.requestedMode) {
2060
+ parsed.request.signals = computeAgentRunSignals(projectRoot, parsed.request.question);
2061
+ }
1937
2062
  const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
1938
2063
  if (wantsStream) {
1939
2064
  res.writeHead(200, {
@@ -1944,6 +2069,8 @@ export async function startLocalServer(opts) {
1944
2069
  });
1945
2070
  const run = await agentRunEngine.run(parsed.request, (event) => {
1946
2071
  writeAgentRunSse(res, 'agent-run-event', event);
2072
+ }, (delta) => {
2073
+ writeAgentRunSse(res, 'agent-run-answer-delta', { runId: parsed.request.runId, delta });
1947
2074
  });
1948
2075
  writeAgentRunSse(res, 'agent-run-complete', run);
1949
2076
  res.end();
@@ -2453,6 +2580,24 @@ export async function startLocalServer(opts) {
2453
2580
  res.end(serializeJSON({ providers: listProviderSettings(projectRoot) }));
2454
2581
  return;
2455
2582
  }
2583
+ // Live detection for subscription-CLI providers: is the CLI installed and logged
2584
+ // in, and (for Claude) which account/plan. Lets Settings show real status instead
2585
+ // of a static hint. Spawns the CLIs, so it's a separate async call from the list.
2586
+ if (req.method === 'GET' && path === '/api/settings/providers/cli-status') {
2587
+ try {
2588
+ const [claudeCode, codex] = await Promise.all([
2589
+ ClaudeCodeCliProvider.detect(),
2590
+ CodexCliProvider.detect(),
2591
+ ]);
2592
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
2593
+ res.end(serializeJSON({ status: { 'claude-code': claudeCode, codex } }));
2594
+ }
2595
+ catch (error) {
2596
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
2597
+ res.end(serializeJSON({ status: {}, error: error instanceof Error ? error.message : String(error) }));
2598
+ }
2599
+ return;
2600
+ }
2456
2601
  if (req.method === 'POST' && path === '/api/settings/providers') {
2457
2602
  try {
2458
2603
  const body = await readJSON(req);
@@ -2861,6 +3006,12 @@ export async function startLocalServer(opts) {
2861
3006
  blocks = collectPlanBlocks(projectRoot, { certifiedOnly: false });
2862
3007
  return planResearch({ question, metrics, blocks, isFollowUp });
2863
3008
  },
3009
+ // Two-phase app build gap-fill: bounded governed answers for uncovered
3010
+ // questions. Throws when no provider is configured — propose degrades
3011
+ // gracefully by listing the gap instead.
3012
+ generateGovernedAnswer: (question) => runGovernedAgentAnswerForRun({ question }),
3013
+ // Story narration for commit — LLM-backed with deterministic fallback.
3014
+ narrate: narrateForAgentRun,
2864
3015
  });
2865
3016
  if (handled)
2866
3017
  return;
@@ -6482,6 +6633,7 @@ function isLLMProviderId(value) {
6482
6633
  return value === 'anthropic'
6483
6634
  || value === 'claude-agent-sdk'
6484
6635
  || value === 'claude-code'
6636
+ || value === 'codex'
6485
6637
  || value === 'openai'
6486
6638
  || value === 'gemini'
6487
6639
  || value === 'ollama'
@@ -6492,7 +6644,9 @@ export function resolveDefaultLLMProvider(projectRoot) {
6492
6644
  const activeProvider = getActiveProvider(projectRoot);
6493
6645
  if (activeProvider) {
6494
6646
  const active = settings.find((item) => item.id === activeProvider);
6495
- if (active?.enabled)
6647
+ // Only return it for the chat-cell runner path if it's a runner provider id
6648
+ // (subscription-only ids like `codex` aren't runners; they fall through).
6649
+ if (active?.enabled && isLLMProviderId(activeProvider))
6496
6650
  return activeProvider;
6497
6651
  }
6498
6652
  const preferred = ['openai', 'gemini', 'anthropic', 'custom-openai', 'ollama'];
@@ -6503,6 +6657,23 @@ export function resolveDefaultLLMProvider(projectRoot) {
6503
6657
  }
6504
6658
  return null;
6505
6659
  }
6660
+ /**
6661
+ * Resolve the runner that produces a GOVERNED answer (answer-loop with a completion
6662
+ * provider). Subscription CLI providers (Claude Code / Codex) are forced through the
6663
+ * answer-loop runner — never the MCP `claudeCodeRunner`, which doesn't emit a governed
6664
+ * answer envelope. Everything else uses the Settings-resolved default runner.
6665
+ */
6666
+ function resolveGovernedAnswerRunner(projectRoot) {
6667
+ const active = getActiveProvider(projectRoot);
6668
+ if (active === 'claude-code' || active === 'codex') {
6669
+ return { provider: active, runner: createDqlAgentProviderRunner(active) };
6670
+ }
6671
+ const resolved = resolveDefaultLLMProvider(projectRoot);
6672
+ if (!resolved)
6673
+ return null;
6674
+ const runner = getLLMRunner(resolved);
6675
+ return runner ? { provider: resolved, runner } : null;
6676
+ }
6506
6677
  function loadAppDashboard(projectRoot, appId, dashboardId) {
6507
6678
  for (const p of findAppDocuments(projectRoot)) {
6508
6679
  const { document: app } = loadAppDocument(p);
@@ -9887,10 +10058,13 @@ async function buildBlockStudioAiAssistSummary(projectRoot, action, candidate, v
9887
10058
  async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
9888
10059
  const settings = listProviderSettings(projectRoot);
9889
10060
  const activeProvider = getActiveProvider(projectRoot);
10061
+ // Subscription CLI providers (Claude Code / Codex) carry no API key — they're
10062
+ // usable when enabled; their real "installed + logged in" check runs in available().
10063
+ const isUsable = (provider) => provider.enabled && (provider.hasApiKey || provider.authMode === 'subscription_cli');
9890
10064
  const selected = requestedProvider
9891
- ? settings.find((provider) => provider.id === requestedProvider && provider.enabled && provider.hasApiKey)
10065
+ ? settings.find((provider) => provider.id === requestedProvider && isUsable(provider))
9892
10066
  : settings.find((provider) => provider.id === activeProvider && provider.enabled)
9893
- ?? settings.find((provider) => provider.enabled && provider.hasApiKey);
10067
+ ?? settings.find(isUsable);
9894
10068
  if (!selected)
9895
10069
  return null;
9896
10070
  const config = getEffectiveProviderConfig(projectRoot, selected.id);
@@ -9911,11 +10085,171 @@ async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
9911
10085
  case 'custom-openai':
9912
10086
  provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model, allowNoApiKey: true });
9913
10087
  break;
10088
+ case 'claude-code':
10089
+ provider = new ClaudeCodeCliProvider({ model: config.model });
10090
+ break;
10091
+ case 'codex':
10092
+ provider = new CodexCliProvider({ model: config.model });
10093
+ break;
9914
10094
  default:
9915
10095
  return null;
9916
10096
  }
9917
10097
  return await provider.available() ? provider : null;
9918
10098
  }
10099
+ /** Convert a governed answer's result payload into a bounded synthesis preview. */
10100
+ function agentResultToSynthesisPreview(result) {
10101
+ if (!result)
10102
+ return undefined;
10103
+ const columns = Array.isArray(result.columns)
10104
+ ? result.columns.map((col) => typeof col === 'string' ? col : col?.name ?? String(col))
10105
+ : [];
10106
+ const rawRows = Array.isArray(result.rows) ? result.rows.slice(0, 20) : [];
10107
+ const rows = rawRows.map((row) => {
10108
+ if (row && typeof row === 'object' && !Array.isArray(row))
10109
+ return row;
10110
+ // Array-shaped rows → zip with column names.
10111
+ if (Array.isArray(row)) {
10112
+ const obj = {};
10113
+ row.forEach((value, index) => { obj[columns[index] ?? `col${index}`] = value; });
10114
+ return obj;
10115
+ }
10116
+ return { value: row };
10117
+ });
10118
+ return {
10119
+ columns: columns.length > 0 ? columns : Object.keys(rows[0] ?? {}),
10120
+ rows,
10121
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : rows.length,
10122
+ };
10123
+ }
10124
+ const SIGNAL_STOPWORDS = new Set([
10125
+ 'the', 'a', 'an', 'of', 'for', 'to', 'by', 'in', 'on', 'and', 'or', 'is', 'are', 'was', 'were',
10126
+ 'what', 'which', 'how', 'show', 'me', 'my', 'our', 'this', 'that', 'top', 'give', 'get', 'list',
10127
+ ]);
10128
+ function signalTokens(text) {
10129
+ return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)
10130
+ .filter((token) => token.length > 2 && !SIGNAL_STOPWORDS.has(token)));
10131
+ }
10132
+ /** Best token-overlap ratio between the question and any candidate name/description. */
10133
+ function bestOverlap(questionTokens, candidates) {
10134
+ if (questionTokens.size === 0)
10135
+ return 0;
10136
+ let best = 0;
10137
+ for (const candidate of candidates) {
10138
+ const candTokens = signalTokens(candidate);
10139
+ if (candTokens.size === 0)
10140
+ continue;
10141
+ let hits = 0;
10142
+ for (const token of candTokens)
10143
+ if (questionTokens.has(token))
10144
+ hits += 1;
10145
+ if (hits === 0)
10146
+ continue;
10147
+ // Ratio of the candidate's tokens the question covers — rewards a tight match.
10148
+ const ratio = hits / Math.min(candTokens.size, questionTokens.size);
10149
+ if (ratio > best)
10150
+ best = ratio;
10151
+ }
10152
+ return Math.min(1, best);
10153
+ }
10154
+ /**
10155
+ * Cheap, offline retrieval-signal probe. Fills certifiedScore/metricScore/hasRetrieval
10156
+ * from local catalog name/description overlap so the deterministic router can reach
10157
+ * confidence (and skip the LLM) on obvious governed matches. Best-effort — any failure
10158
+ * yields empty signals and the router simply leans on its LLM classification.
10159
+ */
10160
+ function computeAgentRunSignals(projectRoot, question) {
10161
+ try {
10162
+ const tokens = signalTokens(question);
10163
+ if (tokens.size === 0)
10164
+ return { certifiedScore: 0, metricScore: 0, hasRetrieval: false };
10165
+ const certifiedBlocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
10166
+ const blockStrings = certifiedBlocks.flatMap((block) => [block.name, block.description ?? ''].filter(Boolean));
10167
+ const metrics = loadSemanticMetrics(projectRoot);
10168
+ const metricStrings = metrics.map((metric) => {
10169
+ const node = metric;
10170
+ return node.name ?? node.label ?? node.id ?? '';
10171
+ }).filter(Boolean);
10172
+ const certifiedScore = bestOverlap(tokens, blockStrings);
10173
+ const metricScore = bestOverlap(tokens, metricStrings);
10174
+ return { certifiedScore, metricScore, hasRetrieval: certifiedScore > 0 || metricScore > 0 };
10175
+ }
10176
+ catch {
10177
+ return { certifiedScore: 0, metricScore: 0, hasRetrieval: false };
10178
+ }
10179
+ }
10180
+ /** Up to three example questions built from real catalog blocks (falls back to generic asks). */
10181
+ function buildConversationSuggestions(projectRoot, kind) {
10182
+ if (kind === 'gratitude')
10183
+ return [];
10184
+ let names = [];
10185
+ try {
10186
+ let blocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
10187
+ if (blocks.length === 0)
10188
+ blocks = collectPlanBlocks(projectRoot, { certifiedOnly: false });
10189
+ names = blocks.slice(0, 3).map((block) => block.name).filter(Boolean);
10190
+ }
10191
+ catch {
10192
+ names = [];
10193
+ }
10194
+ if (names.length === 0) {
10195
+ return [
10196
+ 'What is total revenue?',
10197
+ 'Top customers by revenue this quarter',
10198
+ 'Why is revenue down by region?',
10199
+ ];
10200
+ }
10201
+ const humanize = (name) => name.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
10202
+ return names.map((name) => `Show me ${humanize(name)}`);
10203
+ }
10204
+ /** The system prompt for a conversational reply — persona + capability card + catalog. */
10205
+ function buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, audience) {
10206
+ const capabilities = audience === 'stakeholder'
10207
+ ? [
10208
+ 'answer questions about their data, grounded in certified metrics and dbt lineage',
10209
+ 'run a deeper investigation when a question needs it ("dig deeper")',
10210
+ ]
10211
+ : [
10212
+ 'answer questions about their data, grounded in certified metrics and dbt lineage',
10213
+ 'run a deeper multi-step investigation ("dig deeper")',
10214
+ 'draft SQL cells and DQL blocks for review',
10215
+ 'build dashboards and apps from certified blocks',
10216
+ ];
10217
+ const lines = [
10218
+ 'You are DQL\'s assistant inside a governed analytics notebook. Be warm, concise, and helpful.',
10219
+ `You can: ${capabilities.map((c) => `(${c})`).join(', ')}.`,
10220
+ 'Reply in at most 2-3 short sentences. Do NOT invent data values, metrics, or numbers.',
10221
+ 'If the user seems to want data, invite them to ask — their question will run through the governed loop.',
10222
+ ];
10223
+ if (isGeneralKnowledge) {
10224
+ lines.push('This is a general-knowledge question. Answer from your own knowledge, plainly and briefly.', 'Do NOT imply the answer comes from the user\'s data or warehouse. If it relates to their work, offer to look at their governed data next.');
10225
+ }
10226
+ else if (kind === 'meta_capability') {
10227
+ lines.push('The user is asking what you can do — briefly describe your capabilities and suggest a concrete first question.');
10228
+ }
10229
+ if (catalogContext) {
10230
+ lines.push('', 'When suggesting example questions, prefer these real objects in their workspace:', catalogContext);
10231
+ }
10232
+ return lines.join('\n');
10233
+ }
10234
+ /** Deterministic, warm reply when no AI provider is configured — never governance-speak. */
10235
+ function buildConversationalFallback(kind, isGeneralKnowledge, question, suggestions) {
10236
+ const examples = suggestions.length > 0
10237
+ ? ` For example: ${suggestions.map((s) => `"${s}"`).join(', ')}.`
10238
+ : '';
10239
+ if (isGeneralKnowledge) {
10240
+ return `That's a general-knowledge question, so I can't answer it from your governed data. Ask me about your metrics or dbt models and I'll ground the answer in your workspace.${examples}`;
10241
+ }
10242
+ switch (kind) {
10243
+ case 'greeting':
10244
+ return `Hi! I'm your DQL assistant — I answer questions about your data, grounded in your certified metrics and dbt lineage.${examples}`;
10245
+ case 'gratitude':
10246
+ return "You're welcome! Ask me anything else about your data whenever you're ready.";
10247
+ case 'meta_capability':
10248
+ return `I can answer questions about your data (grounded in certified metrics and dbt lineage), dig deeper with a multi-step investigation, draft SQL and DQL blocks, and build apps from certified blocks.${examples}`;
10249
+ default:
10250
+ return `I'm here to help you explore your data. Ask me a question and I'll ground the answer in your certified metrics and dbt models.${examples}`;
10251
+ }
10252
+ }
9919
10253
  function buildDeterministicAiAssistSummary(action, candidate, validation) {
9920
10254
  const tables = candidate.lineage.sourceTables.length > 0 ? candidate.lineage.sourceTables.join(', ') : 'no source tables detected';
9921
10255
  const params = candidate.lineage.parameters.length > 0 ? candidate.lineage.parameters.join(', ') : 'no parameters detected';
@@ -11687,7 +12021,10 @@ async function testProviderConfig(projectRoot, id, overrides) {
11687
12021
  };
11688
12022
  const label = providerSettingsLabel(id);
11689
12023
  const details = providerConfigDetails(id, config);
11690
- if (!config.enabled) {
12024
+ const isSubscriptionCli = id === 'claude-code' || id === 'codex';
12025
+ // Subscription CLI providers are always testable — detection (installed + logged in)
12026
+ // needs no saved config; the others require enabling/saving first.
12027
+ if (!config.enabled && !isSubscriptionCli) {
11691
12028
  return { ok: false, message: `${label} is disabled. Enable it (or Save) before testing.` };
11692
12029
  }
11693
12030
  if (id === 'openai')
@@ -11702,6 +12039,12 @@ async function testProviderConfig(projectRoot, id, overrides) {
11702
12039
  case 'ollama':
11703
12040
  provider = new OllamaProvider({ baseUrl: config.baseUrl, model: config.model });
11704
12041
  break;
12042
+ case 'claude-code':
12043
+ provider = new ClaudeCodeCliProvider({ model: config.model });
12044
+ break;
12045
+ case 'codex':
12046
+ provider = new CodexCliProvider({ model: config.model });
12047
+ break;
11705
12048
  case 'custom-openai':
11706
12049
  default:
11707
12050
  provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model, allowNoApiKey: true });
@@ -11709,9 +12052,12 @@ async function testProviderConfig(projectRoot, id, overrides) {
11709
12052
  }
11710
12053
  const available = await provider.available().catch(() => false);
11711
12054
  if (!available) {
12055
+ const cli = id === 'claude-code' ? 'claude' : 'codex';
11712
12056
  return {
11713
12057
  ok: false,
11714
- message: `${label} is not configured or reachable${details}. Check API key, base URL, and local service state.`,
12058
+ message: isSubscriptionCli
12059
+ ? `${label} is not ready. Install the \`${cli}\` CLI and run \`${cli} ${id === 'claude-code' ? '/login' : 'login'}\` with your subscription, then test again.`
12060
+ : `${label} is not configured or reachable${details}. Check API key, base URL, and local service state.`,
11715
12061
  };
11716
12062
  }
11717
12063
  try {
@@ -11788,6 +12134,8 @@ function providerSettingsLabel(id) {
11788
12134
  case 'gemini': return 'Gemini';
11789
12135
  case 'ollama': return 'Ollama';
11790
12136
  case 'custom-openai': return 'Custom OpenAI-compatible provider';
12137
+ case 'claude-code': return 'Claude subscription (Claude Code CLI)';
12138
+ case 'codex': return 'ChatGPT subscription (Codex CLI)';
11791
12139
  }
11792
12140
  }
11793
12141
  function providerConfigDetails(id, config) {