@bike4mind/cli 1.2.0 → 2.0.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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as withRetry, A as NotFoundError, B as WORK_ITEM_STATUSES, E as LOCATION_MAP_LANGUAGE, G as googleMapsSearchUrl, I as SEARCH_RESULT_CARDS_LANGUAGE, J as isRetryableError, M as OpenAIEmbeddingModel, O as McpServerName, P as PermissionDeniedError, Q as signImageUrl, R as SupportedFabFileMimeTypes, U as getMcpProviderMetadata, X as obfuscateApiKey, Z as secureParameters, a as canTrustTool, b as ApiKeyType, c as SESSION_ID_PATTERN, i as loadContextFiles, j as OllamaEmbeddingModel, l as isValidSessionId, n as logger, o as getToolCategory, q as isPlaceholderImageSigningSecret, s as isReadOnlyTool, w as ChatModels, x as BedrockEmbeddingModel, z as VoyageAIEmbeddingModel } from "./ConfigStore-qV7NrCgZ.mjs";
2
+ import { F as NotFoundError, G as WORK_ITEM_STATUSES, I as OllamaEmbeddingModel, L as OpenAIEmbeddingModel, N as McpServerName, O as ChatModels, Q as googleMapsSearchUrl, T as BedrockEmbeddingModel, U as SupportedFabFileMimeTypes, V as SEARCH_RESULT_CARDS_LANGUAGE, W as VoyageAIEmbeddingModel, X as getMcpProviderMetadata, a as htmlErrorTitle, at as secureParameters, bt as APP_NAME, c as loadContextFiles, d as isReadOnlyTool, i as isValidSessionId, it as obfuscateApiKey, j as LOCATION_MAP_LANGUAGE, l as canTrustTool, mt as withRetry, n as logger, nt as isRetryableError, o as htmlFirstH1, ot as signImageUrl, r as SESSION_ID_PATTERN, st as splitEquals, tt as isPlaceholderImageSigningSecret, u as getToolCategory, w as ApiKeyType, xt as WEBSITE_URL, z as PermissionDeniedError } from "./ConfigStore-DIs-K9oD.mjs";
3
3
  import { n as isPathAllowed, t as assertPathAllowed } from "./pathValidation-BRqf4HFX-CHwtwp3O.mjs";
4
4
  import { n as isTerminalShellStatus, t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
5
5
  import { execFile, execFileSync, spawn } from "child_process";
@@ -864,7 +864,7 @@ const getSerperKey = async (adapters) => {
864
864
  };
865
865
  const getSearxngUrl = async (adapters) => {
866
866
  const { db } = adapters;
867
- return (await db.adminSettings.findBySettingName("SearxngUrl"))?.settingValue?.trim() || envKey("SEARXNG_BASE_URL");
867
+ return (await db.adminSettings.findBySettingName("SearxngUrl"))?.settingValue?.trim() || process.env.SEARXNG_BASE_URL?.trim() || null;
868
868
  };
869
869
  const getWebSearchProviderSetting = async (adapters) => {
870
870
  const { db } = adapters;
@@ -982,7 +982,10 @@ const setApiKey = async (userId, parameters, { db }) => {
982
982
  if (!apiKey) throw new NotFoundError("API key not found");
983
983
  await db.apiKeys.updateAllByUserIdAndType(userId, type, { isActive: false });
984
984
  apiKey.isActive = true;
985
- await db.apiKeys.update(apiKey);
985
+ await db.apiKeys.update({
986
+ id: apiKey.id,
987
+ isActive: true
988
+ });
986
989
  return apiKey;
987
990
  };
988
991
  const listOwnApiKeys = async (userId, adapters, options = {}) => {
@@ -1629,6 +1632,7 @@ new https.Agent({
1629
1632
  lookup: ssrfSafeLookup,
1630
1633
  keepAlive: false
1631
1634
  });
1635
+ `${APP_NAME || "App"}`, WEBSITE_URL && `${WEBSITE_URL}`;
1632
1636
  `${"a, span, em, strong, b, i, u, code, kbd, samp, var, sub, sup, small, abbr, cite, q, time, mark, s, del, ins, bdi, bdo, wbr, ruby, rt, rp".split(", ").join("):not(")}`;
1633
1637
  [
1634
1638
  "[aria-hidden=\"true\"]",
@@ -2206,7 +2210,39 @@ const webFetchTool = {
2206
2210
  })
2207
2211
  };
2208
2212
  //#endregion
2209
- //#region ../../b4m-core/services/dist/websearch-CChnLwlz.mjs
2213
+ //#region ../../b4m-core/services/dist/websearch-ClW051tj.mjs
2214
+ /**
2215
+ * Per-process cache for web-search results. Keyed on normalized query + result count, with a
2216
+ * short TTL so repeated/similar searches within a turn or across back-to-back turns reuse the
2217
+ * same provider response. The cache lives in the Fargate container's memory and is never shared
2218
+ * across containers or persisted.
2219
+ */
2220
+ const SEARCH_CACHE_TTL_MS = 3e5;
2221
+ const SEARCH_CACHE_MAX_ENTRIES = 200;
2222
+ const searchCache = /* @__PURE__ */ new Map();
2223
+ function searchCacheKey(query, numResults, placeSearch) {
2224
+ return `${query.trim().toLowerCase()}::${numResults}${placeSearch ? "::places" : ""}`;
2225
+ }
2226
+ function searchCacheGet(query, numResults, placeSearch) {
2227
+ const key = searchCacheKey(query, numResults, placeSearch);
2228
+ const entry = searchCache.get(key);
2229
+ if (!entry) return void 0;
2230
+ if (Date.now() > entry.expiresAt) {
2231
+ searchCache.delete(key);
2232
+ return;
2233
+ }
2234
+ return entry.results;
2235
+ }
2236
+ function searchCacheSet(query, numResults, results, placeSearch) {
2237
+ if (searchCache.size >= SEARCH_CACHE_MAX_ENTRIES) {
2238
+ const firstKey = searchCache.keys().next().value;
2239
+ if (firstKey !== void 0) searchCache.delete(firstKey);
2240
+ }
2241
+ searchCache.set(searchCacheKey(query, numResults, placeSearch), {
2242
+ results,
2243
+ expiresAt: Date.now() + SEARCH_CACHE_TTL_MS
2244
+ });
2245
+ }
2210
2246
  /**
2211
2247
  * The coarse recency bucket both providers speak, as the smallest one containing `recencyDays`.
2212
2248
  * Null when there is no constraint, or when the window is wider than the widest bucket - a
@@ -2228,10 +2264,19 @@ const SERPAPI_QDR = {
2228
2264
  year: "qdr:y"
2229
2265
  };
2230
2266
  const DEFAULT_NUM_RESULTS = 3;
2231
- const SEARCH_TIMEOUT_MS = 6e4;
2232
- const SERPAPI_ATTEMPT_TIMEOUT_MS = 2e4;
2267
+ const SEARCH_TIMEOUT_MS = 1e4;
2268
+ const SERPAPI_ATTEMPT_TIMEOUT_MS = 1e4;
2233
2269
  const SERPAPI_MAX_ATTEMPTS = 2;
2234
2270
  const SERPAPI_RETRY_DELAY_MS = 500;
2271
+ /**
2272
+ * How long the lead provider gets before the backup is started alongside it (sooner if the lead
2273
+ * fails or comes back empty first). Just past SerpAPI's normal response time in production: answers come back in about
2274
+ * 1s typically and 3s at the 90th percentile, while a stalled request usually never answers.
2275
+ */
2276
+ const WEB_SEARCH_HEDGE_DELAY_MS = 3e3;
2277
+ const SERPAPI_ORGANIC_MS = 20500;
2278
+ const HEDGED_BACKUP_MS = WEB_SEARCH_HEDGE_DELAY_MS + Math.max(SEARCH_TIMEOUT_MS, 1 * SERPAPI_ATTEMPT_TIMEOUT_MS);
2279
+ Math.max(SERPAPI_ORGANIC_MS, HEDGED_BACKUP_MS) + SEARCH_TIMEOUT_MS;
2235
2280
  const MAX_IMAGES_PER_RESULT = 4;
2236
2281
  const DEFAULT_IMAGE_RESULTS = 12;
2237
2282
  const DEFAULT_PLACE_RESULTS = 10;
@@ -2319,6 +2364,11 @@ function indexImagesByLink(inlineImages, shoppingResults) {
2319
2364
  function isAbortError$1(error) {
2320
2365
  return typeof error === "object" && error !== null && error.name === "AbortError";
2321
2366
  }
2367
+ function setSerpApiGeoTargeting(searchParams, options) {
2368
+ if (options?.locationInQuery) return;
2369
+ searchParams.set("location", "United States");
2370
+ searchParams.set("gl", "us");
2371
+ }
2322
2372
  const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2323
2373
  /**
2324
2374
  * One SerpAPI organic-search attempt, bounded by SERPAPI_ATTEMPT_TIMEOUT_MS. Never throws -
@@ -2413,21 +2463,21 @@ async function serpApiSearch(adapters, query, num_results, options) {
2413
2463
  engine: "google",
2414
2464
  api_key: apiKey,
2415
2465
  q: query,
2416
- location: "United States",
2417
2466
  google_domain: "google.com",
2418
- gl: "us",
2419
2467
  hl: "en",
2420
2468
  num: (num_results || DEFAULT_NUM_RESULTS).toString()
2421
2469
  });
2470
+ setSerpApiGeoTargeting(searchParams, options);
2422
2471
  const bucket = recencyBucket(options?.recencyDays);
2423
2472
  if (bucket) searchParams.set("tbs", SERPAPI_QDR[bucket]);
2424
2473
  url.search = searchParams.toString();
2474
+ const maxAttempts = Math.min(Math.max(1, options?.maxAttempts ?? SERPAPI_MAX_ATTEMPTS), SERPAPI_MAX_ATTEMPTS);
2425
2475
  const failures = [];
2426
- for (let attempt = 1; attempt <= SERPAPI_MAX_ATTEMPTS; attempt++) {
2476
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2427
2477
  const outcome = await attemptSerpApiRequest(url, attempt);
2428
2478
  if (outcome.ok) return outcome.data;
2429
2479
  failures.push(outcome);
2430
- if (!outcome.retryable || attempt === SERPAPI_MAX_ATTEMPTS) break;
2480
+ if (!outcome.retryable || attempt === maxAttempts) break;
2431
2481
  Logger.globalInstance.log("📡 WebSearch Tool: retrying SerpAPI after transient failure", {
2432
2482
  attempt,
2433
2483
  status: outcome.status,
@@ -2442,20 +2492,20 @@ async function serpApiSearch(adapters, query, num_results, options) {
2442
2492
  * `include_images` on a visual query. Failures resolve to [] - missing pictures degrade the reply
2443
2493
  * to prose, they never fail the search.
2444
2494
  */
2445
- async function serpApiImageSearch(adapters, query, limit) {
2495
+ async function serpApiImageSearch(adapters, query, limit, options) {
2446
2496
  const apiKey = await (0, apiKeyService_exports.getSerperKey)(adapters);
2447
2497
  if (!apiKey) return [];
2448
2498
  const url = new URL("https://serpapi.com/search");
2449
- url.search = new URLSearchParams({
2499
+ const searchParams = new URLSearchParams({
2450
2500
  engine: "google_images",
2451
2501
  api_key: apiKey,
2452
2502
  q: query,
2453
- location: "United States",
2454
2503
  google_domain: "google.com",
2455
- gl: "us",
2456
2504
  hl: "en",
2457
2505
  safe: "active"
2458
- }).toString();
2506
+ });
2507
+ setSerpApiGeoTargeting(searchParams, options);
2508
+ url.search = searchParams.toString();
2459
2509
  const controller = new AbortController();
2460
2510
  const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
2461
2511
  try {
@@ -2555,7 +2605,7 @@ function safeHost(url) {
2555
2605
  function createSerpApiProvider(adapters) {
2556
2606
  return {
2557
2607
  name: "serpapi",
2558
- searchImages: (query, limit) => serpApiImageSearch(adapters, query, limit ?? DEFAULT_IMAGE_RESULTS),
2608
+ searchImages: (query, limit, options) => serpApiImageSearch(adapters, query, limit ?? DEFAULT_IMAGE_RESULTS, options),
2559
2609
  searchPlaces: (query, limit) => serpApiPlaceSearch(adapters, query, limit ?? DEFAULT_PLACE_RESULTS),
2560
2610
  async search(query, numResults, options) {
2561
2611
  const data = await serpApiSearch(adapters, query, numResults, options);
@@ -2696,36 +2746,49 @@ function createSearxngProvider(baseUrl) {
2696
2746
  status: response.status,
2697
2747
  statusText: response.statusText
2698
2748
  });
2749
+ if (options?.throwOnError) throw new Error(`SearXNG error: HTTP ${response.status}`);
2699
2750
  return [];
2700
2751
  }
2701
2752
  return parseSearxngResults(await response.json(), limit);
2702
2753
  } catch (error) {
2703
2754
  Logger.globalInstance.error("❌ WebSearch Tool: SearXNG request failed:", error);
2704
- return [];
2755
+ if (!options?.throwOnError) return [];
2756
+ if (isAbortError$1(error)) throw new Error(`Web search timed out: SearXNG did not respond within ${SEARCH_TIMEOUT_MS / 1e3}s`);
2757
+ throw error;
2705
2758
  } finally {
2706
2759
  clearTimeout(timeoutId);
2707
2760
  }
2708
2761
  }
2709
2762
  };
2710
2763
  }
2711
- /**
2712
- * Resolve the active web-search provider, or null when none is configured. Precedence:
2713
- * - explicit admin choice ('serpapi' | 'searxng') forces that provider (null if it's unconfigured)
2714
- * - 'auto' (default): SearXNG if a URL is configured (admin setting or SEARXNG_BASE_URL env),
2715
- * else SerpAPI if a Serper key is set, else null.
2716
- * Mirrored by computeToolAvailability in serverConfig.ts so the picker's gating matches the tool.
2717
- */
2718
- async function resolveWebSearchProvider(adapters) {
2719
- const choice = await (0, apiKeyService_exports.getWebSearchProviderSetting)(adapters) ?? "auto";
2720
- const searxngUrl = await (0, apiKeyService_exports.getSearxngUrl)(adapters);
2721
- const serperKey = await (0, apiKeyService_exports.getSerperKey)(adapters);
2764
+ async function readProviderSettings(adapters) {
2765
+ return {
2766
+ choice: await (0, apiKeyService_exports.getWebSearchProviderSetting)(adapters) ?? "auto",
2767
+ searxngUrl: await (0, apiKeyService_exports.getSearxngUrl)(adapters),
2768
+ serperKey: await (0, apiKeyService_exports.getSerperKey)(adapters)
2769
+ };
2770
+ }
2771
+ function pickPrimaryProvider(adapters, { choice, searxngUrl, serperKey }) {
2722
2772
  if (choice === "searxng") return searxngUrl ? createSearxngProvider(searxngUrl) : null;
2723
2773
  if (choice === "serpapi") return serperKey ? createSerpApiProvider(adapters) : null;
2724
- if (searxngUrl) return createSearxngProvider(searxngUrl);
2725
2774
  if (serperKey) return createSerpApiProvider(adapters);
2775
+ if (searxngUrl) return createSearxngProvider(searxngUrl);
2726
2776
  return null;
2727
2777
  }
2728
2778
  /**
2779
+ * Returns [lead, backup] for searchWithHedge. The lead is exactly what
2780
+ * resolveWebSearchProvider picks: the admin's WebSearchProvider choice, or under 'auto' SerpAPI
2781
+ * when a key is set, else SearXNG. The backup is the other provider when it is configured too. An
2782
+ * unconfigured explicit choice yields no lead at all; it is never quietly replaced by the other.
2783
+ */
2784
+ async function resolveWebSearchProviders(adapters) {
2785
+ const settings = await readProviderSettings(adapters);
2786
+ const lead = pickPrimaryProvider(adapters, settings);
2787
+ if (!lead) return [null, null];
2788
+ if (lead.name === "serpapi") return [lead, settings.searxngUrl ? createSearxngProvider(settings.searxngUrl) : null];
2789
+ return [lead, settings.serperKey ? createSerpApiProvider(adapters) : null];
2790
+ }
2791
+ /**
2729
2792
  * Teaches the `b4m_cards` fence, appended to a web_search result ONLY when that search actually
2730
2793
  * returned images (see shouldIncludeImages). Delivering it with the results rather than in the system
2731
2794
  * prompt costs nothing on the turns that will never use it, and arrives exactly when it is actionable.
@@ -2864,9 +2927,73 @@ function placeCitable(place, imageUrlSigningSecret, canSignImages) {
2864
2927
  };
2865
2928
  }
2866
2929
  const WEB_SEARCH_NOT_CONFIGURED_MSG = "Web search is not configured: an administrator needs to set a Serper API key or a local SearXNG URL in Admin > API Keys. No search was performed.";
2867
- async function performWebSearch(adapters, params, imageUrlSigningSecret = "") {
2930
+ var EmptySearchAnswer = class extends Error {};
2931
+ const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
2932
+ /**
2933
+ * Organic search as a hedged request: the lead starts at once, and the backup starts alongside it
2934
+ * once the lead has been silent for WEB_SEARCH_HEDGE_DELAY_MS, or as soon as the lead fails. The
2935
+ * first non-empty answer wins; the loser runs on to its own timeout and is ignored. An empty lead
2936
+ * answer also starts the backup, since a throttled SearXNG answers 200 with no results. With no
2937
+ * backup the lead keeps its own failure mode (SearXNG fail-soft, SerpAPI throws). Returns the
2938
+ * provider that answered so follow-up calls (images, places) go to a live provider. With a backup,
2939
+ * results are empty when neither provider has any (both empty, or one empty and the other failed),
2940
+ * and it throws only when both failed.
2941
+ */
2942
+ async function searchWithHedge(lead, backup, query, numResults, searchOptions) {
2943
+ if (!backup) return {
2944
+ results: await lead.search(query, numResults, searchOptions),
2945
+ servedBy: lead
2946
+ };
2947
+ const fromLead = lead.search(query, numResults, {
2948
+ ...searchOptions,
2949
+ throwOnError: true
2950
+ });
2951
+ const fromBackup = new Promise((start) => {
2952
+ const timer = setTimeout(() => start("slow"), WEB_SEARCH_HEDGE_DELAY_MS);
2953
+ fromLead.then((results) => {
2954
+ clearTimeout(timer);
2955
+ if (results.length === 0) start("empty");
2956
+ }, () => {
2957
+ clearTimeout(timer);
2958
+ start("failed");
2959
+ });
2960
+ }).then((reason) => {
2961
+ Logger.globalInstance.log("WebSearch Tool: starting backup provider", {
2962
+ lead: lead.name,
2963
+ backup: backup.name,
2964
+ reason
2965
+ });
2966
+ return backup.search(query, numResults, {
2967
+ ...searchOptions,
2968
+ throwOnError: true,
2969
+ maxAttempts: 1
2970
+ });
2971
+ });
2972
+ const nonEmpty = (answer, provider) => answer.then((results) => {
2973
+ if (results.length === 0) throw new EmptySearchAnswer();
2974
+ return {
2975
+ results,
2976
+ servedBy: provider
2977
+ };
2978
+ });
2979
+ try {
2980
+ return await Promise.any([nonEmpty(fromLead, lead), nonEmpty(fromBackup, backup)]);
2981
+ } catch (error) {
2982
+ const [leadError, backupError] = error instanceof AggregateError ? error.errors : [error, error];
2983
+ if (leadError instanceof EmptySearchAnswer) return {
2984
+ results: [],
2985
+ servedBy: lead
2986
+ };
2987
+ if (backupError instanceof EmptySearchAnswer) return {
2988
+ results: [],
2989
+ servedBy: backup
2990
+ };
2991
+ throw new Error(`Web search failed on both providers. ${lead.name}: ${errorMessage$1(leadError)}; ${backup.name}: ${errorMessage$1(backupError)}`);
2992
+ }
2993
+ }
2994
+ async function performWebSearch(adapters, params, imageUrlSigningSecret = "", resolvedProviders) {
2868
2995
  Logger.globalInstance.log("🔍 WebSearch Tool: Starting search for query:", params.query);
2869
- const provider = await resolveWebSearchProvider(adapters);
2996
+ const [provider, backup] = await (resolvedProviders ?? resolveWebSearchProviders(adapters));
2870
2997
  if (!provider) {
2871
2998
  Logger.globalInstance.error("❌ WebSearch Tool: No web-search provider configured. Skipping search.");
2872
2999
  return {
@@ -2875,21 +3002,33 @@ async function performWebSearch(adapters, params, imageUrlSigningSecret = "") {
2875
3002
  };
2876
3003
  }
2877
3004
  try {
2878
- const results = await provider.search(params.query, params.num_results);
2879
- Logger.globalInstance.log(`📊 WebSearch Tool: ${provider.name} found ${results.length} results`);
3005
+ const searchOptions = params.include_places ? { locationInQuery: true } : void 0;
3006
+ const numResults = params.num_results ?? 3;
3007
+ const isPlaceSearch = !!params.include_places;
3008
+ const cached = searchCacheGet(params.query, numResults, isPlaceSearch);
3009
+ let results;
3010
+ let servedBy = provider;
3011
+ if (cached) {
3012
+ Logger.globalInstance.log("WebSearch Tool: cache hit", { query: params.query });
3013
+ results = cached;
3014
+ } else {
3015
+ ({results, servedBy} = await searchWithHedge(provider, backup, params.query, numResults, searchOptions));
3016
+ if (results.length > 0) searchCacheSet(params.query, numResults, results, isPlaceSearch);
3017
+ }
3018
+ Logger.globalInstance.log(`📊 WebSearch Tool: ${servedBy.name} found ${results.length} results${cached ? " (cached)" : ""}`);
2880
3019
  const canSignImages = !isPlaceholderImageSigningSecret(imageUrlSigningSecret);
2881
3020
  const wantsImages = !!params.include_images && canSignImages;
2882
3021
  const anchorQuery = params.include_places ? params.anchor_location?.trim() : void 0;
2883
3022
  const [imageResults, placeResults, anchorResults] = await Promise.all([
2884
- wantsImages ? provider.searchImages?.(params.query) : void 0,
2885
- params.include_places ? provider.searchPlaces?.(params.query) : void 0,
2886
- anchorQuery ? provider.searchPlaces?.(anchorQuery, 1) : void 0
3023
+ wantsImages ? servedBy.searchImages?.(params.query, void 0, searchOptions) : void 0,
3024
+ params.include_places ? servedBy.searchPlaces?.(params.query) : void 0,
3025
+ anchorQuery ? servedBy.searchPlaces?.(anchorQuery, 1) : void 0
2887
3026
  ]).then((all) => all.map((result) => result ?? []));
2888
3027
  const anchor = anchorResults[0];
2889
3028
  const places = placeResults.filter((place) => place.id !== anchor?.id);
2890
3029
  const withPlaces = shouldIncludePlaces(places, params.include_places);
2891
- if (placeResults.length) Logger.globalInstance.log(`WebSearch Tool: ${provider.name} found ${placeResults.length} places`);
2892
- if (imageResults.length) Logger.globalInstance.log(`🖼️ WebSearch Tool: ${provider.name} found ${imageResults.length} images`);
3030
+ if (placeResults.length) Logger.globalInstance.log(`WebSearch Tool: ${servedBy.name} found ${placeResults.length} places`);
3031
+ if (imageResults.length) Logger.globalInstance.log(`🖼️ WebSearch Tool: ${servedBy.name} found ${imageResults.length} images`);
2893
3032
  const withImages = shouldIncludeImages(results, wantsImages, imageResults);
2894
3033
  const citables = results.map((result, index) => ({
2895
3034
  id: result.url,
@@ -2927,50 +3066,66 @@ async function performWebSearch(adapters, params, imageUrlSigningSecret = "") {
2927
3066
  }
2928
3067
  const webSearchTool = {
2929
3068
  name: "web_search",
2930
- implementation: (context, toolConfig) => ({
2931
- toolFn: async (value) => {
2932
- const params = value;
2933
- await context.onStart?.("web_search", params);
2934
- const { formattedResults, citables } = await performWebSearch({ db: context.db }, params, toolConfig?.imageUrlSigningSecret);
2935
- if (citables.length > 0) {
2936
- await context.statusUpdate({ promptMeta: { citables } }, "Web search complete");
2937
- Logger.globalInstance.log(`📚 WebSearch Tool: Stored ${citables.length} citables`);
2938
- }
2939
- return formattedResults;
2940
- },
2941
- toolSchema: {
2942
- name: "web_search",
2943
- description: "Search the web using Google Search API to FIND pages about a topic. Use this when you need to find URLs or search for information. DO NOT use this if the user provides a specific URL - use web_fetch instead to read the full content.",
2944
- parameters: {
2945
- type: "object",
2946
- properties: {
2947
- query: {
2948
- type: "string",
2949
- description: "The search query to look up"
2950
- },
2951
- num_results: {
2952
- type: "number",
2953
- description: "Number of results to return (default: 3, max: 10)",
2954
- minimum: 1,
2955
- maximum: 10
2956
- },
2957
- include_images: {
2958
- type: "boolean",
2959
- description: "Set true whenever the answer is about things worth SEEING - products, watches, gear, places, buildings, plants, animals, people, cars, art, food, anything with a look. Decide this yourself from the subject matter: the user will NOT ask for pictures, and an answer that describes a physical object without showing it is a worse answer. Adds a set of attributed images so you can illustrate your reply with a b4m_cards block. Leave unset only for genuinely non-visual questions - code, math, definitions, policy - where images would be junk tokens."
2960
- },
2961
- include_places: {
2962
- type: "boolean",
2963
- description: "Set true when the answer is a set of PLACES the user would want to see on a map - restaurants, bars, cafes, shops, hotels, attractions, things to do in or near somewhere. Decide this yourself: the user will not ask for a map. Adds places with map locations so you can show them with a b4m_map block. Leave unset for anything that is not about physical locations."
3069
+ implementation: (context, config) => {
3070
+ let searchCallCount = 0;
3071
+ let providersPromise;
3072
+ return {
3073
+ toolFn: async (value) => {
3074
+ const params = value;
3075
+ const callNumber = ++searchCallCount;
3076
+ await context.onStart?.("web_search", params);
3077
+ providersPromise ??= resolveWebSearchProviders({ db: context.db }).catch((error) => {
3078
+ providersPromise = void 0;
3079
+ throw error;
3080
+ });
3081
+ const startedAt = Date.now();
3082
+ const { formattedResults, citables } = await performWebSearch({ db: context.db }, params, config?.imageUrlSigningSecret, providersPromise);
3083
+ context.logger.log("🔍 WebSearch Tool: search timing", {
3084
+ callNumber,
3085
+ query: params.query,
3086
+ durationMs: Date.now() - startedAt,
3087
+ resultCount: citables.length
3088
+ });
3089
+ if (citables.length > 0) {
3090
+ await context.statusUpdate({ promptMeta: { citables } }, "Web search complete");
3091
+ Logger.globalInstance.log(`📚 WebSearch Tool: Stored ${citables.length} citables`);
3092
+ }
3093
+ return formattedResults;
3094
+ },
3095
+ toolSchema: {
3096
+ name: "web_search",
3097
+ description: "Search the web using Google Search API to FIND pages about a topic. Use this when you need to find URLs or search for information. DO NOT use this if the user provides a specific URL - use web_fetch instead to read the full content.",
3098
+ parameters: {
3099
+ type: "object",
3100
+ properties: {
3101
+ query: {
3102
+ type: "string",
3103
+ description: "The search query to look up"
3104
+ },
3105
+ num_results: {
3106
+ type: "number",
3107
+ description: "Number of results to return (default: 3, max: 10)",
3108
+ minimum: 1,
3109
+ maximum: 10
3110
+ },
3111
+ include_images: {
3112
+ type: "boolean",
3113
+ description: "Set true whenever the answer is about things worth SEEING - products, watches, gear, places, buildings, plants, animals, people, cars, art, food, anything with a look. Decide this yourself from the subject matter: the user will NOT ask for pictures, and an answer that describes a physical object without showing it is a worse answer. Adds a set of attributed images so you can illustrate your reply with a b4m_cards block. Leave unset only for genuinely non-visual questions - code, math, definitions, policy - where images would be junk tokens."
3114
+ },
3115
+ include_places: {
3116
+ type: "boolean",
3117
+ description: "Set true when the answer is a set of PLACES the user would want to see on a map - restaurants, bars, cafes, shops, hotels, attractions, things to do in or near somewhere. Decide this yourself: the user will not ask for a map. Adds places with map locations so you can show them with a b4m_map block. Leave unset for anything that is not about physical locations."
3118
+ },
3119
+ anchor_location: {
3120
+ type: "string",
3121
+ description: "With include_places: the reference place the user named that results should be near, as a searchable name with its city, e.g. \"citizenM Copenhagen Radhuspladsen\". Shown distinctly on the map. Omit when the user named none."
3122
+ }
2964
3123
  },
2965
- anchor_location: {
2966
- type: "string",
2967
- description: "With include_places: the reference place the user named that results should be near, as a searchable name with its city, e.g. \"citizenM Copenhagen Radhuspladsen\". Shown distinctly on the map. Omit when the user named none."
2968
- }
2969
- },
2970
- required: ["query"]
3124
+ required: ["query"]
3125
+ }
2971
3126
  }
2972
- }
2973
- })
3127
+ };
3128
+ }
2974
3129
  };
2975
3130
  //#endregion
2976
3131
  //#region ../../b4m-core/services/dist/weather-jxxOpyX5.mjs
@@ -3086,81 +3241,32 @@ function normalizeId(value) {
3086
3241
  return typeof value === "number" && Number.isFinite(value) ? String(value) : void 0;
3087
3242
  }
3088
3243
  //#endregion
3089
- //#region ../../b4m-core/services/dist/latticeModelService-9biwOOa0.mjs
3090
- const WHITESPACE$1 = /\s/;
3091
- const EQUALS_OPS = [
3092
- "=",
3093
- "equals",
3094
- "equal"
3095
- ];
3096
- function isLineTerminator(s, i) {
3097
- const c = s.charCodeAt(i);
3098
- return c === 10 || c === 13 || c === 8232 || c === 8233;
3099
- }
3100
- function indexText(s) {
3101
- const n = s.length;
3102
- const wsEnd = new Int32Array(n + 1);
3103
- const lineEnd = new Int32Array(n + 1);
3104
- wsEnd[n] = n;
3105
- lineEnd[n] = n;
3106
- let lastLineTerminator = -1;
3107
- for (let i = n - 1; i >= 0; i--) {
3108
- wsEnd[i] = WHITESPACE$1.test(s[i]) ? wsEnd[i + 1] : i;
3109
- const lt = isLineTerminator(s, i);
3110
- lineEnd[i] = lt ? i : lineEnd[i + 1];
3111
- if (lt && lastLineTerminator < 0) lastLineTerminator = i;
3112
- }
3113
- return {
3114
- s,
3115
- n,
3116
- wsEnd,
3117
- lineEnd,
3118
- lastLineTerminator
3119
- };
3120
- }
3121
- /** Case-insensitive match of a lowercase ASCII `word` at i, as the `i` flag compares ASCII letters. */
3122
- function wordAt(s, i, word) {
3123
- if (i + word.length > s.length) return false;
3124
- for (let j = 0; j < word.length; j++) {
3125
- let c = s.charCodeAt(i + j);
3126
- if (c >= 65 && c <= 90) c += 32;
3127
- if (c !== word.charCodeAt(j)) return false;
3128
- }
3129
- return true;
3130
- }
3131
- /** `/^(.+?)\s*(?:=|equals?)\s*(.+)$/i` -> [output, expression]. */
3132
- function splitEquals(s) {
3133
- const ix = indexText(s);
3134
- const limit = Math.min(ix.lineEnd[0], ix.n - 1);
3135
- let lastK = -1;
3136
- for (let g = 1; g <= limit; g++) {
3137
- const k = ix.wsEnd[g];
3138
- if (k === lastK) continue;
3139
- lastK = k;
3140
- for (const op of EQUALS_OPS) {
3141
- if (!wordAt(s, k, op)) continue;
3142
- const p = k + op.length;
3143
- const w = ix.wsEnd[p];
3144
- let rest = -1;
3145
- if (w < ix.n) rest = ix.lastLineTerminator < w ? w : -1;
3146
- else if (ix.n - 1 >= p && !isLineTerminator(s, ix.n - 1)) rest = ix.n - 1;
3147
- if (rest >= 0) return [s.slice(0, g), s.slice(rest)];
3148
- }
3149
- }
3150
- return null;
3151
- }
3244
+ //#region ../../b4m-core/services/dist/latticeModelService-DTKpo_hV.mjs
3152
3245
  /**
3153
3246
  * The model's creator, compared through `normalizeId` on both sides.
3154
3247
  *
3155
- * This is the WRITE gate (via `getModelForWrite`) and the owner arm of the read gate, and the
3156
- * subagent Lattice tools in `llm/tools/implementation/lattice` import it so the two surfaces
3157
- * cannot drift apart again.
3248
+ * This is the WRITE gate (via `getModelForWrite`) and the owner arm of the read gate.
3158
3249
  */
3159
3250
  function isModelOwner(model, user) {
3160
3251
  const ownerId = normalizeId(model.userId);
3161
3252
  return ownerId !== void 0 && ownerId === normalizeId(user.id);
3162
3253
  }
3163
3254
  /**
3255
+ * READ gate: the creator, or a member of the organization the model was created in.
3256
+ *
3257
+ * Both org ids go through `normalizeId` because they are NOT the same runtime type:
3258
+ * `LatticeModel.organizationId` is a String schema path while `req.user.organizationId` is an
3259
+ * ObjectId on the hydrated user document, so a raw `===` was always false and silently collapsed
3260
+ * org sharing to owner-only everywhere.
3261
+ *
3262
+ * Write authority stays narrower on purpose - see `getModelForWrite`.
3263
+ */
3264
+ function canReadModel(model, user) {
3265
+ if (isModelOwner(model, user)) return true;
3266
+ const modelOrgId = normalizeId(model.organizationId);
3267
+ return modelOrgId !== void 0 && modelOrgId === normalizeId(user.organizationId);
3268
+ }
3269
+ /**
3164
3270
  * The document every newly created model starts from: ownership, org membership, session/project
3165
3271
  * scoping, and the empty three-layer skeleton.
3166
3272
  *
@@ -3205,8 +3311,36 @@ function buildNewModel(user, options) {
3205
3311
  updatedAt: now
3206
3312
  };
3207
3313
  }
3314
+ /**
3315
+ * Get a model by ID with authorization check
3316
+ */
3317
+ async function getModel(user, modelId, deps) {
3318
+ const model = await deps.db.latticeModels.findById(modelId);
3319
+ if (!model) return null;
3320
+ return canReadModel(model, user) ? model : null;
3321
+ }
3322
+ /**
3323
+ * Get a model the caller may MUTATE, or null when they may not.
3324
+ *
3325
+ * Ownership only, and deliberately NARROWER than `getModel`. `getModel` is the READ gate and admits
3326
+ * same-org members by design (a model carries its creator's `organizationId`), but read access is
3327
+ * not write access - and every mutator took its authorization from it, so a colleague could rename,
3328
+ * re-value, re-rule or delete a model they did not create. Callers that only read keep using `getModel`: `listModels`,
3329
+ * `searchModels`, `hydrateModel` (computes derived values and stamps `lastComputedAt`), and
3330
+ * `duplicateModel` (reads one model and creates a NEW one owned by the caller).
3331
+ *
3332
+ * Lattice models carry no share/grant field, so the creator is the only principal that can hold
3333
+ * write authority today. If an explicit write grant is ever added, this is the one place to admit
3334
+ * it - keeping the check here rather than inlined in six mutators is what makes that a one-line
3335
+ * change instead of a six-site audit.
3336
+ */
3337
+ async function getModelForWrite(user, modelId, deps) {
3338
+ const model = await getModel(user, modelId, deps);
3339
+ if (!model) return null;
3340
+ return isModelOwner(model, user) ? model : null;
3341
+ }
3208
3342
  //#endregion
3209
- //#region ../../b4m-core/services/dist/toolGenerators-iHwi8W0C.mjs
3343
+ //#region ../../b4m-core/services/dist/toolGenerators-KiYiCRXT.mjs
3210
3344
  const diceRoll = async (parameters) => {
3211
3345
  if (!parameters?.sides || !parameters?.times) throw new Error("Tool dice roll: Missing required parameters");
3212
3346
  return sum(times(parameters.times, () => random(1, parameters.sides))).toString();
@@ -3936,7 +4070,7 @@ const askUserQuestionTool = {
3936
4070
  * re-export them without pulling the full tool graph. `index.ts` re-exports them
3937
4071
  * so the server barrel's public API is unchanged.
3938
4072
  */
3939
- const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionLakeScopeExplicit, sessionPreauthorizedLakeIds, questId, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
4073
+ const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionReaderConsentDatalakeTags, sessionLakeScopeExplicit, sessionPreauthorizedLakeIds, organizationId, apiKeyId, questId, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
3940
4074
  const context = {
3941
4075
  userId,
3942
4076
  user,
@@ -3960,17 +4094,26 @@ const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inl
3960
4094
  fullyInlinedAttachmentIds,
3961
4095
  suppressLakeArms,
3962
4096
  sessionRetrievalTags,
4097
+ sessionReaderConsentDatalakeTags,
3963
4098
  sessionLakeScopeExplicit,
3964
4099
  sessionPreauthorizedLakeIds,
4100
+ organizationId,
4101
+ apiKeyId,
3965
4102
  codeMinifier,
3966
4103
  availableModels,
3967
4104
  onToolLlmUsage,
3968
4105
  getAbortSignal
3969
4106
  };
3970
- return Object.entries(tools).reduce((acc, [key, tool]) => ({
3971
- ...acc,
3972
- [key]: tool.implementation(context, config[key])
3973
- }), {});
4107
+ return Object.entries(tools).reduce((acc, [key, tool]) => {
4108
+ const built = tool.implementation(context, config[key]);
4109
+ return {
4110
+ ...acc,
4111
+ [key]: tool.artifactType ? {
4112
+ ...built,
4113
+ artifactType: tool.artifactType
4114
+ } : built
4115
+ };
4116
+ }, {});
3974
4117
  };
3975
4118
  /**
3976
4119
  * Normalize MCP tool parameters for OpenAI compatibility.
@@ -5494,34 +5637,28 @@ const latticeAddEntityTool = {
5494
5637
  updatedAt: /* @__PURE__ */ new Date()
5495
5638
  };
5496
5639
  if (context.db.latticeModels && modelId && isObjectIdShaped$1(modelId)) try {
5497
- const model = await context.db.latticeModels.findById(modelId);
5498
- if (model && isModelOwner(model, { id: context.userId })) {
5499
- const existingIndex = model.data.entities.findIndex((e) => e.id === entityId);
5500
- if (existingIndex >= 0) model.data.entities[existingIndex] = entityData;
5501
- else model.data.entities.push(entityData);
5502
- await context.db.latticeModels.update({
5503
- id: modelId,
5504
- data: model.data,
5505
- updatedAt: /* @__PURE__ */ new Date()
5506
- });
5507
- context.logger.info(`[Lattice] Added entity ${entityId} to model ${modelId}`);
5508
- } else if (model) {
5509
- context.logger.warn(`[Lattice] Access denied: caller does not own model ${modelId}`);
5510
- return JSON.stringify({
5511
- success: false,
5512
- action: "ADD_ENTITY",
5513
- modelId,
5514
- error: `Access denied: you do not have permission to modify model ${modelId}`
5515
- });
5516
- } else {
5517
- context.logger.warn(`[Lattice] Model ${modelId} not found in database`);
5640
+ const model = await getModelForWrite({
5641
+ id: context.userId,
5642
+ organizationId: context.user?.organizationId
5643
+ }, modelId, { db: { latticeModels: context.db.latticeModels } });
5644
+ if (!model) {
5645
+ context.logger.warn(`[Lattice] Model ${modelId} not found or access denied for user ${context.userId}`);
5518
5646
  return JSON.stringify({
5519
5647
  success: false,
5520
5648
  action: "ADD_ENTITY",
5521
5649
  modelId,
5522
- error: `Model ${modelId} not found`
5650
+ error: `Model ${modelId} not found or you do not have permission to modify it`
5523
5651
  });
5524
5652
  }
5653
+ const existingIndex = model.data.entities.findIndex((e) => e.id === entityId);
5654
+ if (existingIndex >= 0) model.data.entities[existingIndex] = entityData;
5655
+ else model.data.entities.push(entityData);
5656
+ await context.db.latticeModels.update({
5657
+ id: modelId,
5658
+ data: model.data,
5659
+ updatedAt: /* @__PURE__ */ new Date()
5660
+ });
5661
+ context.logger.info(`[Lattice] Added entity ${entityId} to model ${modelId}`);
5525
5662
  } catch (error) {
5526
5663
  context.logger.error(`[Lattice] Failed to persist entity to database:`, error);
5527
5664
  return JSON.stringify({
@@ -5644,51 +5781,45 @@ const latticeSetValueTool = {
5644
5781
  else if (rawValue.toLowerCase() === "false") value = false;
5645
5782
  const entityId = toEntityId(entityName);
5646
5783
  if (context.db.latticeModels && modelId && isObjectIdShaped$1(modelId)) try {
5647
- const model = await context.db.latticeModels.findById(modelId);
5648
- if (model && isModelOwner(model, { id: context.userId })) {
5649
- const entity = model.data.entities.find((e) => e.id === entityId || toEntityId(e.name) === entityId);
5650
- if (!entity) {
5651
- context.logger.warn(`[Lattice] Entity ${entityName} not found in model ${modelId}`);
5652
- return JSON.stringify({
5653
- success: false,
5654
- action: "SET_VALUE",
5655
- modelId,
5656
- error: entityNotFoundError(model.data.entities, entityName, attributeKey)
5657
- });
5658
- }
5659
- const attrIndex = entity.attributes.findIndex((a) => a.key === attributeKey);
5660
- const attributeData = {
5661
- key: attributeKey,
5662
- value,
5663
- dataType: typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string",
5664
- isComputed: false
5665
- };
5666
- if (attrIndex >= 0) entity.attributes[attrIndex] = attributeData;
5667
- else entity.attributes.push(attributeData);
5668
- entity.updatedAt = /* @__PURE__ */ new Date();
5669
- await context.db.latticeModels.update({
5670
- id: modelId,
5671
- data: model.data,
5672
- updatedAt: /* @__PURE__ */ new Date()
5673
- });
5674
- context.logger.info(`[Lattice] Set ${entityId}.${attributeKey} = ${value} in model ${modelId}`);
5675
- } else if (model) {
5676
- context.logger.warn(`[Lattice] Access denied: caller does not own model ${modelId}`);
5784
+ const model = await getModelForWrite({
5785
+ id: context.userId,
5786
+ organizationId: context.user?.organizationId
5787
+ }, modelId, { db: { latticeModels: context.db.latticeModels } });
5788
+ if (!model) {
5789
+ context.logger.warn(`[Lattice] Model ${modelId} not found or access denied for user ${context.userId}`);
5677
5790
  return JSON.stringify({
5678
5791
  success: false,
5679
5792
  action: "SET_VALUE",
5680
5793
  modelId,
5681
- error: `Access denied: you do not have permission to modify model ${modelId}`
5794
+ error: `Model ${modelId} not found or you do not have permission to modify it`
5682
5795
  });
5683
- } else {
5684
- context.logger.warn(`[Lattice] Model ${modelId} not found in database`);
5796
+ }
5797
+ const entity = model.data.entities.find((e) => e.id === entityId || toEntityId(e.name) === entityId);
5798
+ if (!entity) {
5799
+ context.logger.warn(`[Lattice] Entity ${entityName} not found in model ${modelId}`);
5685
5800
  return JSON.stringify({
5686
5801
  success: false,
5687
5802
  action: "SET_VALUE",
5688
5803
  modelId,
5689
- error: `Model ${modelId} not found`
5804
+ error: entityNotFoundError(model.data.entities, entityName, attributeKey)
5690
5805
  });
5691
5806
  }
5807
+ const attrIndex = entity.attributes.findIndex((a) => a.key === attributeKey);
5808
+ const attributeData = {
5809
+ key: attributeKey,
5810
+ value,
5811
+ dataType: typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string",
5812
+ isComputed: false
5813
+ };
5814
+ if (attrIndex >= 0) entity.attributes[attrIndex] = attributeData;
5815
+ else entity.attributes.push(attributeData);
5816
+ entity.updatedAt = /* @__PURE__ */ new Date();
5817
+ await context.db.latticeModels.update({
5818
+ id: modelId,
5819
+ data: model.data,
5820
+ updatedAt: /* @__PURE__ */ new Date()
5821
+ });
5822
+ context.logger.info(`[Lattice] Set ${entityId}.${attributeKey} = ${value} in model ${modelId}`);
5692
5823
  } catch (error) {
5693
5824
  context.logger.error(`[Lattice] Failed to persist value to database:`, error);
5694
5825
  return JSON.stringify({
@@ -5795,61 +5926,55 @@ const latticeCreateRuleTool = {
5795
5926
  const outputEntityId = parsedRule.outputEntity.toLowerCase().replace(/\s+/g, "_");
5796
5927
  let entityCreatedMessage = "";
5797
5928
  if (context.db.latticeModels && modelId && isObjectIdShaped$1(modelId)) try {
5798
- const model = await context.db.latticeModels.findById(modelId);
5799
- if (model && isModelOwner(model, { id: context.userId })) {
5800
- if (!model.data.entities.some((e) => e.id === outputEntityId || e.name.toLowerCase() === parsedRule.outputEntity.toLowerCase()) && parsedRule.outputEntity !== "unknown") {
5801
- const now = /* @__PURE__ */ new Date();
5802
- const newEntity = {
5803
- id: outputEntityId,
5804
- type: "line_item",
5805
- name: parsedRule.outputEntity,
5806
- displayName: parsedRule.outputEntity,
5807
- attributes: [{
5808
- key: "value",
5809
- value: 0,
5810
- dataType: "currency",
5811
- isComputed: true
5812
- }, {
5813
- key: "category",
5814
- value: "Computed",
5815
- dataType: "string",
5816
- isComputed: false
5817
- }],
5818
- metadata: { isComputed: true },
5819
- createdAt: now,
5820
- updatedAt: now
5821
- };
5822
- model.data.entities.push(newEntity);
5823
- entityCreatedMessage = ` Created output entity "${parsedRule.outputEntity}".`;
5824
- context.logger.info(`[Lattice] Auto-created output entity ${outputEntityId} for rule ${ruleId}`);
5825
- }
5826
- const existingIndex = model.rules.rules.findIndex((r) => r.id === ruleId || r.name === name);
5827
- if (existingIndex >= 0) model.rules.rules[existingIndex] = ruleData;
5828
- else model.rules.rules.push(ruleData);
5829
- await context.db.latticeModels.update({
5830
- id: modelId,
5831
- data: model.data,
5832
- rules: model.rules,
5833
- updatedAt: /* @__PURE__ */ new Date()
5834
- });
5835
- context.logger.info(`[Lattice] Created rule ${ruleId} in model ${modelId}`);
5836
- } else if (model) {
5837
- context.logger.warn(`[Lattice] Access denied: caller does not own model ${modelId}`);
5838
- return JSON.stringify({
5839
- success: false,
5840
- action: "CREATE_RULE",
5841
- modelId,
5842
- error: `Access denied: you do not have permission to modify model ${modelId}`
5843
- });
5844
- } else {
5845
- context.logger.warn(`[Lattice] Model ${modelId} not found in database`);
5929
+ const model = await getModelForWrite({
5930
+ id: context.userId,
5931
+ organizationId: context.user?.organizationId
5932
+ }, modelId, { db: { latticeModels: context.db.latticeModels } });
5933
+ if (!model) {
5934
+ context.logger.warn(`[Lattice] Model ${modelId} not found or access denied for user ${context.userId}`);
5846
5935
  return JSON.stringify({
5847
5936
  success: false,
5848
5937
  action: "CREATE_RULE",
5849
5938
  modelId,
5850
- error: `Model ${modelId} not found`
5939
+ error: `Model ${modelId} not found or you do not have permission to modify it`
5851
5940
  });
5852
5941
  }
5942
+ if (!model.data.entities.some((e) => e.id === outputEntityId || e.name.toLowerCase() === parsedRule.outputEntity.toLowerCase()) && parsedRule.outputEntity !== "unknown") {
5943
+ const now = /* @__PURE__ */ new Date();
5944
+ const newEntity = {
5945
+ id: outputEntityId,
5946
+ type: "line_item",
5947
+ name: parsedRule.outputEntity,
5948
+ displayName: parsedRule.outputEntity,
5949
+ attributes: [{
5950
+ key: "value",
5951
+ value: 0,
5952
+ dataType: "currency",
5953
+ isComputed: true
5954
+ }, {
5955
+ key: "category",
5956
+ value: "Computed",
5957
+ dataType: "string",
5958
+ isComputed: false
5959
+ }],
5960
+ metadata: { isComputed: true },
5961
+ createdAt: now,
5962
+ updatedAt: now
5963
+ };
5964
+ model.data.entities.push(newEntity);
5965
+ entityCreatedMessage = ` Created output entity "${parsedRule.outputEntity}".`;
5966
+ context.logger.info(`[Lattice] Auto-created output entity ${outputEntityId} for rule ${ruleId}`);
5967
+ }
5968
+ const existingIndex = model.rules.rules.findIndex((r) => r.id === ruleId || r.name === name);
5969
+ if (existingIndex >= 0) model.rules.rules[existingIndex] = ruleData;
5970
+ else model.rules.rules.push(ruleData);
5971
+ await context.db.latticeModels.update({
5972
+ id: modelId,
5973
+ data: model.data,
5974
+ rules: model.rules,
5975
+ updatedAt: /* @__PURE__ */ new Date()
5976
+ });
5977
+ context.logger.info(`[Lattice] Created rule ${ruleId} in model ${modelId}`);
5853
5978
  } catch (error) {
5854
5979
  context.logger.error(`[Lattice] Failed to persist rule to database:`, error);
5855
5980
  return JSON.stringify({
@@ -6041,7 +6166,8 @@ This tool traces the calculation chain from the target value back through all it
6041
6166
  * This is a simplified implementation - production would use LLM structured output
6042
6167
  */
6043
6168
  function parseFormula(formula) {
6044
- const equalsMatch = splitEquals(formula.toLowerCase().trim());
6169
+ const normalized = formula.toLowerCase().trim();
6170
+ const equalsMatch = splitEquals(normalized);
6045
6171
  if (equalsMatch) {
6046
6172
  const [output, expression] = equalsMatch;
6047
6173
  if (expression.includes("+") || expression.includes("plus")) return {
@@ -9681,6 +9807,13 @@ var CliLogger = class extends Logger {
9681
9807
  debug(...args) {}
9682
9808
  log(...args) {}
9683
9809
  };
9810
+ const FILE_WRITE_SUCCESS = /^File (edited|created|overwritten) successfully: /;
9811
+ const FILE_WRITE_TOOLS = /* @__PURE__ */ new Set(["edit_local_file", "create_file"]);
9812
+ function notifyFileChanged(agentContext, toolName, args, result) {
9813
+ if (!agentContext.onFileChanged || !FILE_WRITE_TOOLS.has(toolName)) return;
9814
+ if (typeof args.path !== "string" || !FILE_WRITE_SUCCESS.test(result)) return;
9815
+ agentContext.onFileChanged(args.path);
9816
+ }
9684
9817
  /**
9685
9818
  * Wrap a tool with permission checking, server routing, and observation tracking.
9686
9819
  */
@@ -9727,8 +9860,8 @@ function wrapToolWithPermission(tool, permissionManager, showPermissionPrompt, a
9727
9860
  } else if (decision.type === "unsandboxed") sandboxOrchestrator.recordUnsandboxed();
9728
9861
  }
9729
9862
  const effectiveArgs = isSandboxed ? sandboxedArgs : args;
9730
- const { gateSnapshot: _modelSuppliedGateSnapshot, ...effectiveArgsSansGateSnapshot } = effectiveArgs ?? {};
9731
- let execArgs = effectiveArgsSansGateSnapshot;
9863
+ const { gateSnapshot: _modelSuppliedGateSnapshot, confirmedFuzzyHash: _modelSuppliedFuzzyHash, ...effectiveArgsSansGateFields } = effectiveArgs ?? {};
9864
+ let execArgs = effectiveArgsSansGateFields;
9732
9865
  const sandboxCleanupPaths = isSandboxed ? sandboxedArgs?._sandboxCleanup : void 0;
9733
9866
  /**
9734
9867
  * Shared execution flow: run tool, capture violations, offer retry on
@@ -9750,6 +9883,7 @@ function wrapToolWithPermission(tool, permissionManager, showPermissionPrompt, a
9750
9883
  toolName,
9751
9884
  result
9752
9885
  });
9886
+ notifyFileChanged(agentContext, toolName, execArgs, result);
9753
9887
  getProcessHooks()?.firePostToolUse(toolName);
9754
9888
  return result;
9755
9889
  }
@@ -10767,6 +10901,25 @@ function appendWorkflowReminder(messages, provider) {
10767
10901
  content: `${WORKFLOW_REMINDER_MARKER}\n${body}`
10768
10902
  });
10769
10903
  }
10904
+ const TOOL_RESULTS_NUDGE = "Based on the tool results above, please provide a complete answer. If I asked for multiple things, make sure to address all of them.";
10905
+ /**
10906
+ * The end-of-iteration nudge, with any drained host feedback folded in. Folding
10907
+ * (rather than pushing a second plain-string user message) keeps exactly one
10908
+ * iteration boundary for findIterationBoundary's trimming heuristic.
10909
+ */
10910
+ function buildToolResultsNudge(feedback) {
10911
+ return feedback ? `${TOOL_RESULTS_NUDGE}\n\n${feedback}` : TOOL_RESULTS_NUDGE;
10912
+ }
10913
+ /** Calls the host's feedback drain. A failing drain is logged and treated as empty - it must never fail the run. */
10914
+ async function drainHostFeedback(drain, phase, logger) {
10915
+ if (!drain) return null;
10916
+ try {
10917
+ return (await drain(phase))?.trim() || null;
10918
+ } catch (error) {
10919
+ logger.warn(`[ReActAgent] drainFeedback(${phase}) failed; continuing without feedback:`, error);
10920
+ return null;
10921
+ }
10922
+ }
10770
10923
  /**
10771
10924
  * Build the per-iteration prompt-cache strategy. History caching anchors on the
10772
10925
  * last message so the cached prefix grows each turn (standard Anthropic
@@ -11146,7 +11299,9 @@ var ReActAgent = class extends EventEmitter {
11146
11299
  }
11147
11300
  throw completionError;
11148
11301
  }
11149
- if (!hadToolCalls && currentText.trim()) {
11302
+ const finalFeedback = !hadToolCalls && currentText.trim() && iterations < maxIterations ? await drainHostFeedback(options.drainFeedback, "final", this.context.logger) : null;
11303
+ if (finalFeedback) this.reopenAnswerWithFeedback(messages, currentText.trim(), finalFeedback);
11304
+ else if (!hadToolCalls && currentText.trim()) {
11150
11305
  finalAnswer = currentText.trim();
11151
11306
  const iterInputTokens = this.totalInputTokens - preCallTotalInputTokens;
11152
11307
  const iterOutputTokens = this.totalOutputTokens - preCallTotalOutputTokens;
@@ -11194,10 +11349,13 @@ var ReActAgent = class extends EventEmitter {
11194
11349
  });
11195
11350
  }
11196
11351
  this.iterationConfidences = [];
11197
- if (!iterationComplete && hadToolCalls) messages.push({
11198
- role: "user",
11199
- content: `Based on the tool results above, please provide a complete answer. If I asked for multiple things, make sure to address all of them.`
11200
- });
11352
+ if (!iterationComplete && hadToolCalls) {
11353
+ const feedback = await drainHostFeedback(options.drainFeedback, "turn", this.context.logger);
11354
+ messages.push({
11355
+ role: "user",
11356
+ content: buildToolResultsNudge(feedback)
11357
+ });
11358
+ }
11201
11359
  if (maxTotalTokens !== void 0 && this.totalTokens >= maxTotalTokens && !iterationComplete) {
11202
11360
  reachedMaxTotalTokens = true;
11203
11361
  finalAnswer = currentText.trim() || `I stopped after reaching the cumulative token ceiling (${this.totalTokens}/${maxTotalTokens}) without arriving at a final answer.`;
@@ -11311,6 +11469,29 @@ BEHAVIOR GUIDELINES:
11311
11469
  Remember: You are an autonomous AGENT. Act independently and solve problems proactively.`;
11312
11470
  }
11313
11471
  /**
11472
+ * Demote a would-be final answer to a thought and hand the model drained host
11473
+ * feedback (see AgentRunOptions.drainFeedback) as the next user message, so
11474
+ * the loop runs another iteration instead of ending. The user message is a
11475
+ * plain string and so is a normal iteration boundary for trimming.
11476
+ */
11477
+ reopenAnswerWithFeedback(messages, answer, feedback) {
11478
+ const thoughtStep = {
11479
+ type: "thought",
11480
+ content: answer,
11481
+ metadata: { timestamp: Date.now() }
11482
+ };
11483
+ this.steps.push(thoughtStep);
11484
+ this.emit("thought", thoughtStep);
11485
+ messages.push({
11486
+ role: "assistant",
11487
+ content: answer
11488
+ }, {
11489
+ role: "user",
11490
+ content: feedback
11491
+ });
11492
+ return thoughtStep;
11493
+ }
11494
+ /**
11314
11495
  * Add an observation step (used by tool wrappers to record tool results).
11315
11496
  * Kept for backwards compatibility but no longer used. Observations are now
11316
11497
  * queued and drained in the completion callback for correct ordering.
@@ -11689,7 +11870,9 @@ Remember: You are an autonomous AGENT. Act independently and solve problems proa
11689
11870
  }
11690
11871
  }
11691
11872
  });
11692
- if (!hadToolCalls && currentText.trim()) {
11873
+ const finalFeedback = !hadToolCalls && currentText.trim() && this.iterations < maxIterations ? await drainHostFeedback(options.drainFeedback, "final", this.context.logger) : null;
11874
+ if (finalFeedback) iterationSteps.push(this.reopenAnswerWithFeedback(this.messages, currentText.trim(), finalFeedback));
11875
+ else if (!hadToolCalls && currentText.trim()) {
11693
11876
  finalAnswer = currentText.trim();
11694
11877
  const iterInputTokens = this.totalInputTokens - iterStartInputTokens;
11695
11878
  const iterOutputTokens = this.totalOutputTokens - iterStartOutputTokens;
@@ -11746,10 +11929,13 @@ Remember: You are an autonomous AGENT. Act independently and solve problems proa
11746
11929
  });
11747
11930
  }
11748
11931
  this.iterationConfidences = [];
11749
- if (!iterationComplete && hadToolCalls) this.messages.push({
11750
- role: "user",
11751
- content: "Based on the tool results above, please provide a complete answer. If I asked for multiple things, make sure to address all of them."
11752
- });
11932
+ if (!iterationComplete && hadToolCalls) {
11933
+ const feedback = await drainHostFeedback(options.drainFeedback, "turn", this.context.logger);
11934
+ this.messages.push({
11935
+ role: "user",
11936
+ content: buildToolResultsNudge(feedback)
11937
+ });
11938
+ }
11753
11939
  if (!iterationComplete && maxTotalTokens !== void 0 && this.totalTokens >= maxTotalTokens) {
11754
11940
  finalAnswer = currentText.trim() || `I stopped after reaching the cumulative token ceiling (${this.totalTokens}/${maxTotalTokens}) without arriving at a final answer.`;
11755
11941
  const ceilingStep = {
@@ -16069,10 +16255,10 @@ var ServerLlmBackend = class {
16069
16255
  } else if (typeof stream === "string") responseText = stream;
16070
16256
  logger.debug(`[ServerLlmBackend] Response preview: ${responseText.substring(0, 200)}`);
16071
16257
  if (responseText.includes("<!DOCTYPE") || responseText.includes("<html")) {
16072
- const titleMatch = responseText.match(/<title>(.*?)<\/title>/i);
16073
- const h1Match = responseText.match(/<h1>(.*?)<\/h1>/i);
16074
- if (titleMatch && titleMatch[1] !== "Error") errorDetails = titleMatch[1].trim();
16075
- else if (h1Match) errorDetails = h1Match[1].trim();
16258
+ const title = htmlErrorTitle(responseText);
16259
+ const h1 = htmlFirstH1(responseText);
16260
+ if (title !== null) errorDetails = title;
16261
+ else if (h1 !== null) errorDetails = h1.trim();
16076
16262
  } else if (responseText) errorDetails = responseText.substring(0, 100).trim();
16077
16263
  } catch (extractError) {
16078
16264
  logger.error("[ServerLlmBackend] Error extracting response:", extractError);