@clue-ai/cli 0.0.13 → 0.0.15

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.
package/README.md CHANGED
@@ -62,13 +62,21 @@ leaks, and SDK lifecycle presence when requested. With
62
62
  installation, SDK imports in the target environments, app startup, and event
63
63
  delivery remain required before setup can be called complete.
64
64
 
65
- `npx -y @clue-ai/cli setup-watch` polls the Clue API setup-check endpoint while you operate
66
- the local service. `--clue-api-base-url` is the Clue API URL, not the customer
67
- frontend URL. Use `--watch-targets` to list every local frontend/backend app by
68
- service key and the lifecycle checks expected for that service, for example
65
+ `npx -y @clue-ai/cli setup-watch` polls the Clue API setup-check endpoint in
66
+ remote mode while you operate the service. `--clue-api-base-url` is the Clue API
67
+ URL, not the customer frontend URL. Use `--watch-targets` to list every
68
+ frontend/backend producer by service key and the lifecycle checks expected for
69
+ that service, for example
69
70
  `frontend:web[init,identify,set-account,event-sent]=<frontend-url>,backend:api[init,identify,set-account,logout,event-sent]=<backend-url>`.
70
71
  Do not assume localhost ports; use the actual URLs printed by the repository's
71
72
  dev scripts or configured in its local env.
73
+ In `--local` mode, the watcher stays open until the expected lifecycle checks
74
+ pass or you stop it with Ctrl+C. The frontend/backend endpoint URLs printed by
75
+ the command are local Clue receiver endpoints; configure the customer's Clue SDK
76
+ ingest endpoint to those receiver URLs while testing. Local mode does not ask
77
+ for or health-check customer service URLs. It prints a per-service Clue
78
+ lifecycle checklist and only prints a new snapshot when the observed state
79
+ changes.
72
80
 
73
81
  `npx -y @clue-ai/cli setup` reads Clue values from the setup screen flags, detects local
74
82
  services, writes `.clue/setup-manifest.json`, and prints service-specific env
package/bin/clue-cli.mjs CHANGED
@@ -135,6 +135,15 @@ const DEFAULT_WATCH_LIFECYCLE = [
135
135
  "event-sent",
136
136
  ];
137
137
  const DEFAULT_SETUP_MANIFEST_PATH = ".clue/setup-manifest.json";
138
+ const DEFAULT_REMOTE_SETUP_WATCH_TIMEOUT_MS = 120_000;
139
+
140
+ const WATCH_LIFECYCLE_LABELS = new Map([
141
+ ["init", "ClueInit"],
142
+ ["identify", "ClueIdentify"],
143
+ ["set-account", "ClueSetAccount"],
144
+ ["logout", "ClueLogout"],
145
+ ["event-sent", "Event delivery"],
146
+ ]);
138
147
 
139
148
  const parseExpectedLifecycle = (value) => {
140
149
  if (typeof value !== "string" || !value.trim()) {
@@ -295,7 +304,18 @@ const maybeProtectEnvironmentGuide = async ({
295
304
  };
296
305
  };
297
306
 
298
- const confirmTargetUrls = async ({ flags, watchTargets, env }) => {
307
+ const clearWatchTargetUrls = (watchTargets) =>
308
+ watchTargets.map((target) => ({
309
+ ...target,
310
+ url: null,
311
+ urlEnvName: null,
312
+ localUrlCandidates: [],
313
+ }));
314
+
315
+ const confirmTargetUrls = async ({ flags, localMode, watchTargets, env }) => {
316
+ if (localMode) {
317
+ return clearWatchTargetUrls(watchTargets);
318
+ }
299
319
  const initialTargets = watchTargets.map((target) => ({
300
320
  ...target,
301
321
  url: resolveTargetUrlFromEnv({ target, env }),
@@ -333,6 +353,20 @@ const buildWatchProducerIds = ({ explicitProducerIds, watchTargets }) =>
333
353
  ...watchTargets.map((target) => target.producerId),
334
354
  ]);
335
355
 
356
+ const parseSetupWatchTimeoutMs = ({ flags, localMode }) => {
357
+ if (!flags.has("timeout-ms")) {
358
+ return localMode ? null : DEFAULT_REMOTE_SETUP_WATCH_TIMEOUT_MS;
359
+ }
360
+ const timeoutMs = Number(flags.get("timeout-ms"));
361
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
362
+ throw new Error("--timeout-ms must be a non-negative number");
363
+ }
364
+ return timeoutMs;
365
+ };
366
+
367
+ const isSetupWatchTimedOut = ({ started, timeoutMs }) =>
368
+ timeoutMs !== null && Date.now() - started > timeoutMs;
369
+
336
370
  const checkTargetUrl = async (url) => {
337
371
  if (!url) return { checked: true, reachable: false, status: null };
338
372
  try {
@@ -347,14 +381,20 @@ const checkTargetUrl = async (url) => {
347
381
  }
348
382
  };
349
383
 
350
- const evaluateWatchTargets = async ({ latest, watchTargets }) => {
384
+ const evaluateWatchTargets = async ({
385
+ latest,
386
+ requireTargetUrl = true,
387
+ watchTargets,
388
+ }) => {
351
389
  const producers = Array.isArray(latest?.producers) ? latest.producers : [];
352
390
  return Promise.all(
353
391
  watchTargets.map(async (target) => {
354
392
  const producer = producers.find(
355
393
  (entry) => entry.id === target.producerId,
356
394
  );
357
- const urlHealth = await checkTargetUrl(target.url);
395
+ const urlHealth = requireTargetUrl
396
+ ? await checkTargetUrl(target.url)
397
+ : { checked: false, reachable: true, status: null };
358
398
  const lifecycleStatus = {
359
399
  init: Boolean(producer?.clueInit ?? producer?.sdkInitialized),
360
400
  identify: Boolean(producer?.clueIdentify),
@@ -388,7 +428,7 @@ const evaluateWatchTargets = async ({ latest, watchTargets }) => {
388
428
  urlChecked: urlHealth.checked,
389
429
  urlReachable: urlHealth.reachable,
390
430
  urlStatus: urlHealth.status,
391
- passed: producerPassed && urlHealth.reachable,
431
+ passed: producerPassed && (!requireTargetUrl || urlHealth.reachable),
392
432
  };
393
433
  }),
394
434
  );
@@ -543,25 +583,60 @@ const startLocalSetupReceiver = async ({ host, port }) => {
543
583
  };
544
584
  };
545
585
 
586
+ const lifecycleLabel = (name) => WATCH_LIFECYCLE_LABELS.get(name) ?? name;
587
+
588
+ const statusMark = (passed) => (passed ? "x" : " ");
589
+
590
+ const lifecycleStatusText = (passed) => (passed ? "done!" : "pending...");
591
+
592
+ const targetUrlStatusText = (target) => {
593
+ if (!target.urlChecked) return null;
594
+ return String(
595
+ target.urlReachable
596
+ ? target.urlStatus
597
+ : (target.urlStatus ?? "unreachable"),
598
+ );
599
+ };
600
+
546
601
  const renderWatchTargets = (targetChecks) =>
547
602
  targetChecks
548
603
  .map((target) => {
549
- const urlStatus = target.urlChecked
550
- ? ` url:${target.urlReachable ? target.urlStatus : (target.urlStatus ?? "unreachable")}`
551
- : "";
552
- const lifecycle = target.expectedLifecycle
553
- .map(
554
- (name) =>
555
- `${name}:${target.lifecycleStatus[name] ? "ok" : "waiting"}`,
556
- )
557
- .join(" ");
558
- const unexpected = target.unexpectedLifecycle.length
559
- ? ` unexpected:${target.unexpectedLifecycle.join(",")}`
560
- : "";
561
- return `${target.passed ? "[x]" : "[ ]"} ${target.kind}:${target.serviceKey} ${lifecycle} events:${target.eventCount}${urlStatus}${unexpected}`;
604
+ const lifecycleLines = target.expectedLifecycle.map((name) => {
605
+ const passed = Boolean(target.lifecycleStatus[name]);
606
+ return ` [${statusMark(passed)}] ${lifecycleLabel(name)} ${lifecycleStatusText(passed)}`;
607
+ });
608
+ const urlStatus = targetUrlStatusText(target);
609
+ const detailLines = [` events: ${target.eventCount}`];
610
+ if (urlStatus !== null) {
611
+ detailLines.push(` url: ${urlStatus}`);
612
+ }
613
+ if (target.unexpectedLifecycle.length) {
614
+ detailLines.push(
615
+ ` unexpected: ${target.unexpectedLifecycle.map(lifecycleLabel).join(", ")}`,
616
+ );
617
+ }
618
+ return [
619
+ `[${statusMark(target.passed)}] ${target.kind}:${target.serviceKey}`,
620
+ ...lifecycleLines,
621
+ ...detailLines,
622
+ ].join("\n");
562
623
  })
624
+ .join("\n\n");
625
+
626
+ const renderSetupCheckEntries = (entries) =>
627
+ entries
628
+ .map(([id, status]) => `${status === "passed" ? "[x]" : "[ ]"} ${id}`)
563
629
  .join("\n");
564
630
 
631
+ const joinRenderedSections = (...sections) =>
632
+ sections.filter((section) => section.trim()).join("\n\n");
633
+
634
+ const writeChangedSetupWatchSnapshot = ({ rendered, state }) => {
635
+ if (!rendered || rendered === state.lastRendered) return;
636
+ process.stdout.write(`${rendered}\n\n`);
637
+ state.lastRendered = rendered;
638
+ };
639
+
565
640
  const setupCheckUrl = ({
566
641
  clueApiBaseUrl,
567
642
  environment,
@@ -621,13 +696,14 @@ const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
621
696
  const startedAt = String(
622
697
  flags.get("started-at") || new Date().toISOString(),
623
698
  ).trim();
624
- const timeoutMs = Number(flags.get("timeout-ms") || 120_000);
699
+ const timeoutMs = parseSetupWatchTimeoutMs({ flags, localMode });
625
700
  const pollIntervalMs = Number(flags.get("poll-interval-ms") || 3000);
626
701
  const limit = Number(flags.get("limit") || 200);
627
702
  const projectId = flags.get("project-id");
628
703
  const explicitWatchTargets = parseWatchTargets(flags.get("watch-targets"));
629
704
  const watchTargets = await confirmTargetUrls({
630
705
  flags,
706
+ localMode,
631
707
  watchTargets:
632
708
  explicitWatchTargets.length > 0
633
709
  ? explicitWatchTargets
@@ -639,6 +715,11 @@ const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
639
715
  "setup-watch requires --watch-targets or .clue/setup-manifest.json lifecycle_verification.watch_targets before setup can be treated as verified",
640
716
  );
641
717
  }
718
+ if (localMode && watchTargets.length === 0) {
719
+ throw new Error(
720
+ "setup-watch --local requires .clue/setup-manifest.json lifecycle_verification.watch_targets",
721
+ );
722
+ }
642
723
  const producerIds = buildWatchProducerIds({
643
724
  explicitProducerIds: flags.get("producer-ids"),
644
725
  watchTargets,
@@ -664,26 +745,38 @@ const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
664
745
  `Watching targets: ${watchTargets.map((target) => `${target.kind}:${target.serviceKey}`).join(", ")}\n`,
665
746
  );
666
747
  }
667
- while (Date.now() - started <= timeoutMs) {
748
+ process.stdout.write(
749
+ timeoutMs === null
750
+ ? "No automatic timeout in local mode. Operate your local app, then press Ctrl+C to stop if needed.\n"
751
+ : `Timeout: ${timeoutMs}ms\n`,
752
+ );
753
+ const renderState = { lastRendered: "" };
754
+ while (!isSetupWatchTimedOut({ started, timeoutMs })) {
668
755
  latest = localSetupCheckSnapshot({
669
756
  receivedBatches: receiver.receivedBatches,
670
757
  });
671
758
  const targetChecks = await evaluateWatchTargets({
672
759
  latest,
760
+ requireTargetUrl: false,
673
761
  watchTargets,
674
762
  });
675
763
  const targetChecksPassed =
676
764
  targetChecks.length > 0 &&
677
765
  targetChecks.every((target) => target.passed);
678
766
  const renderedTargets = renderWatchTargets(targetChecks);
679
- process.stdout.write(`${renderedTargets}\n\n`);
767
+ writeChangedSetupWatchSnapshot({
768
+ rendered: renderedTargets,
769
+ state: renderState,
770
+ });
680
771
  if (targetChecksPassed) {
681
772
  process.stdout.write("Clue local setup checks passed.\n");
682
773
  return { ...latest, local: true, watchTargets: targetChecks };
683
774
  }
684
775
  await sleep(pollIntervalMs);
685
776
  }
686
- process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
777
+ if (flags.has("json")) {
778
+ process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
779
+ }
687
780
  throw new Error(
688
781
  "setup-watch --local timed out before all Clue setup checks passed",
689
782
  );
@@ -701,7 +794,8 @@ const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
701
794
  );
702
795
  }
703
796
 
704
- while (Date.now() - started <= timeoutMs) {
797
+ const renderState = { lastRendered: "" };
798
+ while (!isSetupWatchTimedOut({ started, timeoutMs })) {
705
799
  const response = await fetch(
706
800
  setupCheckUrl({
707
801
  clueApiBaseUrl,
@@ -724,13 +818,12 @@ const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
724
818
  const targetChecks = await evaluateWatchTargets({ latest, watchTargets });
725
819
  const targetChecksPassed = targetChecks.every((target) => target.passed);
726
820
  const passed = setupChecksPassed && targetChecksPassed;
727
- const rendered = entries
728
- .map(([id, status]) => `${status === "passed" ? "[x]" : "[ ]"} ${id}`)
729
- .join("\n");
821
+ const rendered = renderSetupCheckEntries(entries);
730
822
  const renderedTargets = renderWatchTargets(targetChecks);
731
- process.stdout.write(
732
- `${rendered}${renderedTargets ? `\n${renderedTargets}` : ""}\n\n`,
733
- );
823
+ writeChangedSetupWatchSnapshot({
824
+ rendered: joinRenderedSections(rendered, renderedTargets),
825
+ state: renderState,
826
+ });
734
827
  if (passed) {
735
828
  process.stdout.write("Clue setup checks passed.\n");
736
829
  return { ...latest, watchTargets: targetChecks };
@@ -738,7 +831,9 @@ const runSetupWatch = async ({ flags, repoRoot = ".", env = process.env }) => {
738
831
  await sleep(pollIntervalMs);
739
832
  }
740
833
 
741
- process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
834
+ if (flags.has("json")) {
835
+ process.stdout.write(`${JSON.stringify(latest, null, 2)}\n`);
836
+ }
742
837
  throw new Error("setup-watch timed out before all Clue setup checks passed");
743
838
  };
744
839
 
@@ -753,7 +848,7 @@ const usage = () =>
753
848
  "",
754
849
  "Usage:",
755
850
  " /clue-init",
756
- ` ${clueCliCommand("setup --clue-api-key <key> --clue-api-base-url <url> --project-key <key> --environment dev")}`,
851
+ ` ${clueCliCommand("setup --clue-api-key <key> --clue-api-base-url <url> --project-key <key> --environment dev --documents-url <url>")}`,
757
852
  ` ${clueCliCommand("setup-detect --repo .")}`,
758
853
  ` ${clueCliCommand("semantic-inventory --framework fastapi --backend-root-path backend --repo .")}`,
759
854
  ` ${clueCliCommand("semantic-agent-skills --output .clue/semantic-agent-skills.json")}`,
@@ -827,6 +922,7 @@ const main = async () => {
827
922
  if (command === "setup") {
828
923
  const report = await installSetupSkills({
829
924
  repoRoot,
925
+ documentsUrl: flags.get("documents-url"),
830
926
  target:
831
927
  typeof flags.get("target") === "string"
832
928
  ? flags.get("target")
@@ -848,12 +944,13 @@ const main = async () => {
848
944
  repoRoot,
849
945
  target: report.target,
850
946
  skillRoot: report.skill_root,
851
- setupContext: {
852
- clueApiKey: flags.get("clue-api-key"),
853
- clueApiBaseUrl: flags.get("clue-api-base-url"),
854
- projectKey: flags.get("project-key"),
855
- environment: flags.get("environment"),
856
- },
947
+ setupContext: {
948
+ clueApiKey: flags.get("clue-api-key"),
949
+ clueApiBaseUrl: flags.get("clue-api-base-url"),
950
+ documentsUrl: flags.get("documents-url"),
951
+ projectKey: flags.get("project-key"),
952
+ environment: flags.get("environment"),
953
+ },
857
954
  });
858
955
  const environmentFileProtection =
859
956
  preEnvironmentFileProtection?.env_file_path ===
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clue-ai/cli",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clue-ai": "bin/clue-cli.mjs"
@@ -568,12 +568,19 @@ const buildLifecyclePrompt = ({ request, files }) =>
568
568
  "Prefer stable ids and non-PII booleans/counts for ClueIdentify and ClueSetAccount traits.",
569
569
  "Use environment variable names for Clue configuration values.",
570
570
  "For Python/FastAPI code, read CLUE_PROJECT_KEY, CLUE_ENVIRONMENT, CLUE_API_KEY, and CLUE_INGEST_ENDPOINT from the backend env block.",
571
- "CLUE_API_BASE_URL is for Clue CLI and semantic snapshot CI configuration, not backend SDK runtime configuration. Do not add it to application config unless existing non-Clue code already requires it.",
572
- "For browser code, read CLUE_PROJECT_KEY, CLUE_ENVIRONMENT, CLUE_SERVICE_KEY, and CLUE_INGEST_ENDPOINT from environment variables through the target framework's safe client config mechanism. Do not hard-code a Next.js-only prefix.",
571
+ "CLUE_API_BASE_URL is not part of backend SDK initialization. Use it only when the application backend owns a browser-token proxy for a frontend service.",
572
+ "For Next.js browser/client code, read NEXT_PUBLIC_CLUE_PROJECT_KEY, NEXT_PUBLIC_CLUE_ENVIRONMENT, NEXT_PUBLIC_CLUE_SERVICE_KEY, and NEXT_PUBLIC_CLUE_INGEST_ENDPOINT from the frontend .env.local block.",
573
+ "Do not read process.env.CLUE_PROJECT_KEY, process.env.CLUE_ENVIRONMENT, process.env.CLUE_SERVICE_KEY, or process.env.CLUE_INGEST_ENDPOINT in Next.js browser/client code, and do not add non-public CLUE_* fallbacks there.",
574
+ "For non-Next.js browser code, use the exact frontend env names written in .env.clue for that service instead of inventing a framework-specific prefix.",
573
575
  "Never place CLUE_API_KEY in frontend code, frontend env files, browser bundles, or client-readable config.",
574
576
  "When browser SDK ingest is configured, implement a backend-owned browser token endpoint that reads server-side CLUE_API_KEY and requests POST /api/v1/ingest/browser-tokens from Clue.",
575
577
  "Configure frontend ClueInit with browserTokenProvider that calls the local backend token endpoint and returns the token string.",
578
+ "The local backend token endpoint is part of the customer app, not the Clue API. It may use the customer's route convention, but it must call Clue server-side at /api/v1/ingest/browser-tokens.",
579
+ "The frontend browserTokenProvider must send the same service key used by ClueInit to the customer backend token endpoint. For Next.js this value comes from NEXT_PUBLIC_CLUE_SERVICE_KEY.",
576
580
  "The browser token request must include project key, environment, service key, and the current browser origin; the backend must attach x-clue-api-key server-side when calling Clue.",
581
+ "For browser token proxy code, the service key sent to Clue must be the frontend ClueInit serviceKey from the browser request, not the backend service's CLUE_SERVICE_KEY.",
582
+ "If a backend-owned browser token endpoint is implemented, read CLUE_API_BASE_URL from the backend env block and normalize it so values with or without a trailing /api/v1 do not produce duplicate paths.",
583
+ "Do not add @clue-ai/browser-sdk or backend SDK dependencies with * or latest; use a concrete published version or package-manager-resolved semver range and update the repository lockfile when one exists.",
577
584
  "Prefer minimal edits that engineers can review in one PR.",
578
585
  "Do not run broad formatters, import sorters, cleanup tools, or style-only edits. Whitespace-only changes are allowed only on lines directly changed for Clue SDK wiring.",
579
586
  "If a lifecycle point is unclear, skip that edit and include a warning.",
@@ -584,13 +591,17 @@ const buildLifecyclePrompt = ({ request, files }) =>
584
591
  target_tool: request.target_tool,
585
592
  framework: request.framework,
586
593
  project_key_env: "CLUE_PROJECT_KEY",
587
- browser_project_key_env: "CLUE_PROJECT_KEY",
594
+ browser_project_key_env: "framework_specific",
595
+ nextjs_browser_project_key_env: "NEXT_PUBLIC_CLUE_PROJECT_KEY",
588
596
  environment_env: "CLUE_ENVIRONMENT",
589
- browser_environment_env: "CLUE_ENVIRONMENT",
597
+ browser_environment_env: "framework_specific",
598
+ nextjs_browser_environment_env: "NEXT_PUBLIC_CLUE_ENVIRONMENT",
590
599
  clue_ingest_endpoint_env: "CLUE_INGEST_ENDPOINT",
591
600
  tooling_api_base_url_env: "CLUE_API_BASE_URL",
592
- browser_ingest_endpoint_env: "CLUE_INGEST_ENDPOINT",
601
+ browser_ingest_endpoint_env: "framework_specific",
602
+ nextjs_browser_ingest_endpoint_env: "NEXT_PUBLIC_CLUE_INGEST_ENDPOINT",
593
603
  browser_token_endpoint_path: "/api/v1/ingest/browser-tokens",
604
+ nextjs_browser_service_key_env: "NEXT_PUBLIC_CLUE_SERVICE_KEY",
594
605
  service_key: request.service_key,
595
606
  },
596
607
  output_shape: {
@@ -845,9 +845,23 @@ const semanticSnapshotRequestSchema = zod_1.z
845
845
  ], "semantic_meaning_candidates[].selection_ai_inference_evidence_ref");
846
846
  });
847
847
  });
848
+ const semanticSnapshotResponseSchema = zod_1.z.object({
849
+ accepted: zod_1.z.literal(true),
850
+ duplicate: zod_1.z.boolean(),
851
+ semantic_snapshot_id: nonEmptyStringSchema,
852
+ route_count: zod_1.z.number().int().nonnegative(),
853
+ diff_summary: zod_1.z.object({
854
+ added_routes: zod_1.z.number().int().nonnegative(),
855
+ removed_routes: zod_1.z.number().int().nonnegative(),
856
+ changed_routes: zod_1.z.number().int().nonnegative(),
857
+ unchanged_routes: zod_1.z.number().int().nonnegative(),
858
+ low_confidence_routes: zod_1.z.number().int().nonnegative(),
859
+ }),
860
+ });
848
861
 
849
862
  return {
850
863
  semanticSnapshotRequestSchema,
864
+ semanticSnapshotResponseSchema,
851
865
  };
852
866
  })();
853
867
 
@@ -855,6 +869,7 @@ const schemaPackage = {
855
869
  clueInitToolRequestSchema: toolingSchemas.clueInitToolRequestSchema,
856
870
  clueInitToolReportSchema: toolingSchemas.clueInitToolReportSchema,
857
871
  semanticSnapshotRequestSchema: semanticSchemas.semanticSnapshotRequestSchema,
872
+ semanticSnapshotResponseSchema: semanticSchemas.semanticSnapshotResponseSchema,
858
873
  };
859
874
 
860
875
  module.exports = schemaPackage;
@@ -706,7 +706,7 @@ const FORBIDDEN_TARGET_TEXT_PATTERNS = [
706
706
  /\b[A-Za-z0-9_./-]+\.py\b/i,
707
707
  /\b(from\s+[A-Za-z_][A-Za-z0-9_.]*\s+import|import\s+[A-Za-z_][A-Za-z0-9_.]*)\b/i,
708
708
  /\b(class|def)\s+[A-Za-z_][A-Za-z0-9_]*/,
709
- /\b(select|insert|update|delete)\s+.+\b(from|into|set)\b/i,
709
+ /\b(?:select\s+(?:\*|[a-z0-9_.,\s]+)\s+from|insert\s+into|update\s+[a-z0-9_."-]+\s+set|delete\s+from)\b/i,
710
710
  /\b(system|user|assistant)\s*:\s*.+\b(prompt|completion|transcript)\b/i,
711
711
  /\b[A-Z][A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)\b(?:\s*=\s*["']?[^"'\s,;}]+)?/,
712
712
  /\b[A-Z][A-Z0-9_]{2,}\s*=\s*["']?[^"'\s,;}]+/,
@@ -1358,6 +1358,15 @@ const unresolvedEffect = ({
1358
1358
  .map((entry) => sanitizeText(entry.trim(), route)),
1359
1359
  });
1360
1360
 
1361
+ const findCatalogEntryByTargetObjectKey = (catalogByGroup, targetObjectKey) => {
1362
+ for (const [mapKey, entry] of catalogByGroup.entries()) {
1363
+ if (entry?.target_object_key === targetObjectKey) {
1364
+ return { mapKey, entry };
1365
+ }
1366
+ }
1367
+ return null;
1368
+ };
1369
+
1361
1370
  const buildOperationAssignmentCollections = ({
1362
1371
  route,
1363
1372
  aiRoute,
@@ -1535,7 +1544,15 @@ const buildOperationAssignmentCollections = ({
1535
1544
  }
1536
1545
 
1537
1546
  const groupKey = `${concept.toLowerCase()}::${businessRole.toLowerCase()}`;
1538
- const existingCatalog = catalogByGroup.get(groupKey);
1547
+ const existingCatalogByTarget = findCatalogEntryByTargetObjectKey(
1548
+ catalogByGroup,
1549
+ targetKeyCandidate,
1550
+ );
1551
+ const existingCatalog =
1552
+ catalogByGroup.get(groupKey) ?? existingCatalogByTarget?.entry;
1553
+ const catalogMapKey = catalogByGroup.has(groupKey)
1554
+ ? groupKey
1555
+ : (existingCatalogByTarget?.mapKey ?? groupKey);
1539
1556
  const targetObjectKey =
1540
1557
  existingCatalog?.target_object_key ?? targetKeyCandidate;
1541
1558
  const operationEffectKey = `${targetObjectKey}.${effectAction}`;
@@ -1706,7 +1723,7 @@ const buildOperationAssignmentCollections = ({
1706
1723
  catalogEntry.grouping_evidence_refs.push(ref);
1707
1724
  }
1708
1725
  }
1709
- catalogByGroup.set(groupKey, catalogEntry);
1726
+ catalogByGroup.set(catalogMapKey, catalogEntry);
1710
1727
 
1711
1728
  mappings.push({
1712
1729
  id: mappingId,
@@ -1991,7 +2008,7 @@ const sanitizeText = (value, route) => {
1991
2008
  }
1992
2009
  result = result
1993
2010
  .replace(
1994
- /\b(select|insert|update|delete)\s+.+?\b(from|into|set)\b[^"',;}]+/gi,
2011
+ /\b(?:select\s+(?:\*|[a-z0-9_.,\s]+)\s+from|insert\s+into|update\s+[a-z0-9_."-]+\s+set|delete\s+from)\b[^"',;}]+/gi,
1995
2012
  "[sql]",
1996
2013
  )
1997
2014
  .replace(
@@ -2036,25 +2053,80 @@ const sanitizeSemantics = (value, route) => {
2036
2053
  record && typeof record === "object" && !Array.isArray(record)
2037
2054
  ? record
2038
2055
  : {};
2056
+ const routeConfidence = Number.isFinite(Number(safeRecord.route_confidence))
2057
+ ? Math.max(0, Math.min(1, Number(safeRecord.route_confidence)))
2058
+ : 0;
2039
2059
  return {
2040
2060
  route_summary:
2041
2061
  typeof safeRecord.route_summary === "string" &&
2042
2062
  safeRecord.route_summary.trim()
2043
2063
  ? safeRecord.route_summary
2044
2064
  : "unknown",
2045
- action_candidates: Array.isArray(safeRecord.action_candidates)
2046
- ? safeRecord.action_candidates
2047
- : [],
2048
- outcome_candidates: Array.isArray(safeRecord.outcome_candidates)
2049
- ? safeRecord.outcome_candidates
2050
- : [],
2065
+ action_candidates: sanitizeSemanticCandidates(
2066
+ safeRecord.action_candidates,
2067
+ route,
2068
+ routeConfidence,
2069
+ ),
2070
+ outcome_candidates: sanitizeSemanticCandidates(
2071
+ safeRecord.outcome_candidates,
2072
+ route,
2073
+ routeConfidence,
2074
+ ),
2051
2075
  value_event_candidates: [],
2052
- route_confidence: Number.isFinite(Number(safeRecord.route_confidence))
2053
- ? Math.max(0, Math.min(1, Number(safeRecord.route_confidence)))
2054
- : 0,
2076
+ route_confidence: routeConfidence,
2055
2077
  };
2056
2078
  };
2057
2079
 
2080
+ const sanitizeSemanticCandidates = (value, route, routeConfidence) => {
2081
+ if (!Array.isArray(value)) {
2082
+ return [];
2083
+ }
2084
+ return value
2085
+ .map((entry) => {
2086
+ if (typeof entry === "string") {
2087
+ const label = sanitizeText(entry, route).trim();
2088
+ return label
2089
+ ? {
2090
+ label,
2091
+ confidence: routeConfidence > 0 ? routeConfidence : 0.5,
2092
+ }
2093
+ : null;
2094
+ }
2095
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
2096
+ return null;
2097
+ }
2098
+ const label =
2099
+ typeof entry.label === "string" && entry.label.trim()
2100
+ ? sanitizeText(entry.label, route).trim()
2101
+ : "";
2102
+ if (!label) {
2103
+ return null;
2104
+ }
2105
+ const candidate = {
2106
+ label,
2107
+ confidence: Number.isFinite(Number(entry.confidence))
2108
+ ? Math.max(0, Math.min(1, Number(entry.confidence)))
2109
+ : routeConfidence > 0
2110
+ ? routeConfidence
2111
+ : 0.5,
2112
+ };
2113
+ for (const key of [
2114
+ "subject_type",
2115
+ "action_category",
2116
+ "outcome_kind",
2117
+ "value_kind",
2118
+ ]) {
2119
+ if (typeof entry[key] === "string" && entry[key].trim()) {
2120
+ candidate[key] = sanitizeText(entry[key], route).trim();
2121
+ } else if (entry[key] === null) {
2122
+ candidate[key] = null;
2123
+ }
2124
+ }
2125
+ return candidate;
2126
+ })
2127
+ .filter(Boolean);
2128
+ };
2129
+
2058
2130
  const sanitizeForClueStorage = (value, route) => {
2059
2131
  if (typeof value === "string") {
2060
2132
  return sanitizeText(value, route);
@@ -2152,6 +2224,10 @@ const previousRouteMap = (previousSnapshot) =>
2152
2224
  ]),
2153
2225
  );
2154
2226
 
2227
+ const hasReusablePreviousRouteSemantics = (route) =>
2228
+ Number(route?.semantics?.route_confidence ?? 0) > 0 &&
2229
+ route?.semantics?.route_summary !== "unknown";
2230
+
2155
2231
  const buildRouteEvidencePromptEntry = ({ route, hashScope }) => ({
2156
2232
  operation_source_key: route.operation_source_key,
2157
2233
  method: route.method,
@@ -2192,7 +2268,10 @@ const classifyRoutesForSnapshot = async ({
2192
2268
  continue;
2193
2269
  }
2194
2270
 
2195
- if (previousRoute.route_input_hash === currentHash) {
2271
+ if (
2272
+ previousRoute.route_input_hash === currentHash &&
2273
+ hasReusablePreviousRouteSemantics(previousRoute)
2274
+ ) {
2196
2275
  plans.set(route.operation_source_key, {
2197
2276
  origin: "unchanged_route_reused",
2198
2277
  route_input_hash: currentHash,
@@ -2206,6 +2285,21 @@ const classifyRoutesForSnapshot = async ({
2206
2285
  continue;
2207
2286
  }
2208
2287
 
2288
+ if (previousRoute.route_input_hash === currentHash) {
2289
+ plans.set(route.operation_source_key, {
2290
+ origin: "changed_route_semantic_regenerated",
2291
+ route_input_hash: currentHash,
2292
+ previous_route: previousRoute,
2293
+ previous_route_input_hash: previousRoute.route_input_hash,
2294
+ previous_route_semantic_hash:
2295
+ previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
2296
+ semantic_change_reason:
2297
+ "Previous route semantics had no reusable confidence, so the route was regenerated.",
2298
+ });
2299
+ routesRequiringGeneration.push(route);
2300
+ continue;
2301
+ }
2302
+
2209
2303
  const decision = await callAiReuseDecisionProvider({
2210
2304
  request,
2211
2305
  env,
@@ -2483,7 +2577,8 @@ const FORBIDDEN_CODE_STRUCTURE_PATTERNS = [
2483
2577
  },
2484
2578
  {
2485
2579
  label: "raw sql",
2486
- pattern: /\b(select|insert|update|delete)\s+.+\b(from|into|set)\b/i,
2580
+ pattern:
2581
+ /\b(?:select\s+(?:\*|[a-z0-9_.,\s]+)\s+from|insert\s+into|update\s+[a-z0-9_."-]+\s+set|delete\s+from)\b/i,
2487
2582
  },
2488
2583
  {
2489
2584
  label: "raw prompt",
@@ -2627,6 +2722,9 @@ const fetchLatestSnapshot = async ({ request, env }) => {
2627
2722
  );
2628
2723
  }
2629
2724
  const body = await response.json();
2725
+ if (body?.found === false && body?.snapshot === undefined) {
2726
+ return null;
2727
+ }
2630
2728
  const candidate = body?.snapshot ?? body;
2631
2729
  if (!candidate || !Array.isArray(candidate.routes)) {
2632
2730
  if (allowFullRegeneration) {
@@ -2664,7 +2762,16 @@ const sendSnapshot = async ({ request, env, snapshot }) => {
2664
2762
  body: JSON.stringify(validatedSnapshot),
2665
2763
  });
2666
2764
  if (!response.ok) {
2667
- throw new Error(`Clue semantic snapshot upload failed: ${response.status}`);
2765
+ const responseBody =
2766
+ typeof response.text === "function"
2767
+ ? await response.text().catch(() => "")
2768
+ : "";
2769
+ const responseDetail = responseBody.trim()
2770
+ ? `: ${responseBody.trim().slice(0, 1000)}`
2771
+ : "";
2772
+ throw new Error(
2773
+ `Clue semantic snapshot upload failed: ${response.status}${responseDetail}`,
2774
+ );
2668
2775
  }
2669
2776
  const body = await response.json();
2670
2777
  try {
@@ -2782,7 +2889,11 @@ export const runSemanticCi = async ({
2782
2889
  const previousRoutes = previousRouteMap(previousSnapshot);
2783
2890
  const routeNeedsAi = routes.some((route) => {
2784
2891
  const previousRoute = previousRoutes.get(route.operation_source_key);
2785
- return !previousRoute || previousRoute.route_input_hash !== routeInputHash(route);
2892
+ return (
2893
+ !previousRoute ||
2894
+ previousRoute.route_input_hash !== routeInputHash(route) ||
2895
+ !hasReusablePreviousRouteSemantics(previousRoute)
2896
+ );
2786
2897
  });
2787
2898
  if (routeNeedsAi && !env.CLUE_AI_PROVIDER_API_KEY) {
2788
2899
  throw new Error("CLUE_AI_PROVIDER_API_KEY is required for semantic generation");