@descix/cli 1.0.10 → 1.0.11

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/bin/descix.js CHANGED
@@ -2187,6 +2187,7 @@ corpusCommand
2187
2187
  .option('--dry-run', 'Enumerate would-be-purged file_ids and would-be-upserted chunks without ANY Pinecone writes. Exit 0 if no drift, 1 if drift. Read-only.')
2188
2188
  .option('--show-walk', 'Print the resolved ref + the first 50 walked files BEFORE any Pinecone operations. Useful for verifying --ref / manifest source resolution.')
2189
2189
  .option('--yes', 'Skip the interactive purge confirmation in --rebuild mode. Use in scripting/CI.')
2190
+ .option('--skip-retrieval-canary', 'Skip the post-sync retrieval canary that confirms a synced chunk actually retrieves before reporting success (measured 2026-09-17: presence in Pinecone is not the same as searchable — the index can lag ~20 minutes after a large upsert/purge). Skipping costs nothing but SAVES one credit-metered query_knowledge_base call and up to ~25s; the sync then reports success on presence only, same as before this check existed. Prefer leaving this ON for anything user-facing; use it for CI/cost-sensitive automation that will verify separately.')
2190
2191
  .action(async (options) => {
2191
2192
  try {
2192
2193
  const apiClient = new DeSciXApiClient();
package/bin/mcp-server.js CHANGED
@@ -23,6 +23,7 @@ import { WalletFileManager } from '../lib/wallet-file.js';
23
23
  import { CLI_VERSION } from '../lib/cli-version.js';
24
24
  import { checkDevCert } from '@descix/app-sdk/dev';
25
25
  import { resolveGatewayCertContext } from '../lib/dev-cert-resolver.js';
26
+ import { originStampFor, stampToolResult } from '../lib/mcp-origin-stamp.js';
26
27
  import * as fs from 'fs/promises';
27
28
  import * as path from 'path';
28
29
  // WS-MCP-SSOT-TIER2 (audit §5.B-1): the curated HTTP-valid core tools are the SINGLE SOURCE
@@ -153,7 +154,7 @@ try {
153
154
  // descix_doctor — local diagnostic (no backend call)
154
155
  // -------------------------------------------------------------------------
155
156
 
156
- async function runDoctor(args) {
157
+ async function runDoctor(args, originStamp) {
157
158
  const verifyRemote = args?.verify_remote !== false;
158
159
  const report = {
159
160
  auth: { connected: false },
@@ -199,7 +200,12 @@ try {
199
200
  app_id: freshContext.appId,
200
201
  community_id: freshContext.communityId,
201
202
  kb_id: freshContext.kbId || 'General',
202
- api_url: freshConfig.apiUrl || apiClient.baseUrl,
203
+ // ONE OWNER: `freshConfig.apiUrl` is a legacy top-level key WorkspaceConfig never
204
+ // populates (the real value lives at `env.apiUrl`, read via `resolveOrigin()`) — the
205
+ // `||` here was always falling through to `apiClient.baseUrl`. Read the resolved
206
+ // origin stamp directly rather than keeping a dead alternative that looked like a
207
+ // second source.
208
+ api_url: originStamp?.origin ?? apiClient.baseUrl,
203
209
  local_apps: [
204
210
  platform.appId,
205
211
  ...products.map(p => p.appId),
@@ -361,6 +367,13 @@ try {
361
367
  report.status = 'warnings';
362
368
  }
363
369
 
370
+ // The same origin stamp every other tool result carries (see mcp-origin-stamp.js) — doctor
371
+ // is the diagnostic surface, so it states the fact explicitly rather than only via the
372
+ // appended content block every tool gets.
373
+ if (originStamp) {
374
+ report.origin_stamp = originStamp;
375
+ }
376
+
364
377
  return report;
365
378
  }
366
379
 
@@ -407,13 +420,35 @@ try {
407
420
  const { name, arguments: args } = request.params;
408
421
  console.error(`[MCP] tools/call — ${name}`);
409
422
 
423
+ // Resolve the origin stamp ONCE per call, from the one owner (api-client.js's own
424
+ // resolution — the same baseUrl/originSource the CLI's `env:` line reports), and carry it
425
+ // onto EVERY result this call returns, success or error. MEASURED 2026-09-17: two sessions
426
+ // spent hours reading DEV through this connector while believing they were reading PROD
427
+ // because no tool result said where it came from — see mcp-origin-stamp.js.
428
+ let originStamp = null;
429
+ let originStampError = null;
410
430
  try {
431
+ await apiClient.ensureInitialized();
432
+ originStamp = originStampFor(apiClient);
433
+ } catch (err) {
434
+ // The origin itself failed to resolve (e.g. a configured-but-invalid origin). There is
435
+ // nothing valid to stamp — the error's own message already names the fix — so this call
436
+ // fails loud below rather than stamping a guess.
437
+ originStampError = err;
438
+ }
439
+ const withStamp = (result) => (originStamp ? stampToolResult(result, originStamp) : result);
440
+
441
+ try {
442
+ if (originStampError) {
443
+ throw originStampError;
444
+ }
445
+
411
446
  // descix_doctor is handled locally
412
447
  if (name === 'descix_doctor') {
413
- const report = await runDoctor(args);
414
- return {
448
+ const report = await runDoctor(args, originStamp);
449
+ return withStamp({
415
450
  content: [{ type: 'text', text: JSON.stringify(report, null, 2) }],
416
- };
451
+ });
417
452
  }
418
453
 
419
454
  // Merge default context for convenience (caller can override). Only auto-fill when the
@@ -455,12 +490,12 @@ try {
455
490
  });
456
491
 
457
492
  const result = await apiClient.invoke(name, params);
458
- return {
493
+ return withStamp({
459
494
  content: [{
460
495
  type: 'text',
461
496
  text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
462
497
  }],
463
- };
498
+ });
464
499
  } catch (error) {
465
500
  console.error(`[MCP] Tool error: ${error.message}`);
466
501
  // WS-HEADLESS-MVP-A3: when the backend attached structured error fields (code +
@@ -471,10 +506,10 @@ try {
471
506
  const structured = (error.code || error.data)
472
507
  ? `\n${JSON.stringify({ code: error.code, data: error.data }, null, 2)}`
473
508
  : '';
474
- return {
509
+ return withStamp({
475
510
  content: [{ type: 'text', text: `Error: ${error.message}${structured}` }],
476
511
  isError: true,
477
- };
512
+ });
478
513
  }
479
514
  });
480
515
 
@@ -32,6 +32,7 @@ import {
32
32
  isReportedCount, accumulateReportedCount, UNREPORTED_COUNT
33
33
  } from '../core/Syncer.js';
34
34
  import { lintManifestForDenyClasses, assertNoViolations } from '../core/CorpusDenyLint.js';
35
+ import { pickCanaryChunk, runRetrievalCanary, DEFAULT_CANARY_DELAYS_MS } from '../core/RetrievalCanary.js';
35
36
 
36
37
  /**
37
38
  * Resolve community_id for corpus sync — same precedence as site upload / update kb:
@@ -467,6 +468,11 @@ export async function runCorpusSync(apiClient, options) {
467
468
  let totalChunksCreated = 0;
468
469
  let totalFilesSkipped = 0;
469
470
  let totalFilesDeleted = 0;
471
+ // The retrieval canary's verdict per KB — a sync/rebuild does not get to report a bare
472
+ // success while any of these are unverified (see RetrievalCanary.js). Collected across
473
+ // manifests so ONE loud failure at the end names every KB still unconfirmed, rather than
474
+ // failing fast on the first and leaving later manifests unsynced.
475
+ const canaryResults = [];
470
476
 
471
477
  for (const rawManifest of manifests) {
472
478
  // ── Deliverable B: resolve --ref override (CLI > manifest > default) ──
@@ -877,6 +883,49 @@ export async function runCorpusSync(apiClient, options) {
877
883
  }
878
884
  }
879
885
 
886
+ // 3f-2. RETRIEVAL CANARY (measured 2026-09-17): presence in Pinecone (reconciled above)
887
+ // is NOT searchability — the PROD index can take ~20 minutes to catch up after a
888
+ // purge+re-upsert, during which every status surface still says healthy. A sync does not
889
+ // get to report bare success until a chunk it just wrote actually RETRIEVES. Bounded
890
+ // (RetrievalCanary.js) rather than a loop: query_knowledge_base is credit-metered.
891
+ let retrievalCanary = null;
892
+ if (allChunks.length > 0 && options.skipRetrievalCanary) {
893
+ console.log(chalk.gray(' [canary] Skipped (--skip-retrieval-canary): searchability NOT confirmed.'));
894
+ } else if (allChunks.length > 0) {
895
+ const canaryChunk = pickCanaryChunk(allChunks, syncFailures);
896
+ if (canaryChunk) {
897
+ const canarySpinner = ora(' [canary] Confirming a synced chunk actually retrieves...').start();
898
+ // TEST SEAM ONLY: options._canaryDelaysMs / options._canarySleep let tests exercise
899
+ // this path without real wall-clock waits or a live query_knowledge_base backend.
900
+ // Production callers never set these — runRetrievalCanary defaults to the real
901
+ // bounded backoff and real timers when they are undefined.
902
+ const result = await runRetrievalCanary(apiClient, {
903
+ appId, kbName, chunk: canaryChunk,
904
+ ...(options._canaryDelaysMs ? { delaysMs: options._canaryDelaysMs } : {}),
905
+ ...(options._canarySleep ? { sleep: options._canarySleep } : {}),
906
+ });
907
+ retrievalCanary = { ...result, checked_at: new Date().toISOString() };
908
+ if (result.ok) {
909
+ canarySpinner.succeed(
910
+ ` [canary] Confirmed searchable after ${(result.elapsedMs / 1000).toFixed(1)}s ` +
911
+ `(attempt ${result.attempts}/${DEFAULT_CANARY_DELAYS_MS.length})`
912
+ );
913
+ } else {
914
+ canarySpinner.warn(
915
+ ` [canary] NOT YET SEARCHABLE after ${(result.elapsedMs / 1000).toFixed(1)}s and ` +
916
+ `${result.attempts} attempt(s)${result.lastError ? ` (last error: ${result.lastError})` : ''}. ` +
917
+ `The vectors ARE present in Pinecone (reconciled above) but the index has not caught up yet — ` +
918
+ `this is a known ~20-minute propagation delay after a large upsert/purge. ` +
919
+ `Re-run 'descix kb corpus sync -a ${appId} -k ${kbName}' later to re-check ` +
920
+ `(unchanged files are cheap; this canary re-runs).`
921
+ );
922
+ }
923
+ canaryResults.push({ kbName, ...retrievalCanary });
924
+ } else {
925
+ console.log(chalk.yellow(' [canary] Skipped: every upsert batch failed, nothing new to confirm.'));
926
+ }
927
+ }
928
+
880
929
  // 3g. Save sync state with blob SHAs for future delta computation
881
930
  const allSyncedBlobShas = [...localBlobShas]; // All current files
882
931
  await saveSyncState(appRoot, kbName, {
@@ -894,7 +943,10 @@ export async function runCorpusSync(apiClient, options) {
894
943
  files_unchanged: unchangedCount,
895
944
  chunks_upserted: isReportedCount(upserted) ? upserted : null,
896
945
  chunks_deleted: isReportedCount(deleted) ? deleted : null,
897
- synced_blob_shas: allSyncedBlobShas
946
+ synced_blob_shas: allSyncedBlobShas,
947
+ // The retrieval canary's verdict — read this, not `total_chunks`, to know whether the
948
+ // last sync's content was CONFIRMED searchable or only confirmed present.
949
+ retrieval_canary: retrievalCanary
898
950
  });
899
951
 
900
952
  totalFilesProcessed += newOrChangedFiles.length;
@@ -935,6 +987,26 @@ export async function runCorpusSync(apiClient, options) {
935
987
  const deletedLine = ` Chunks deleted: ${renderCount(totalFilesDeleted)}`;
936
988
  console.log(totalFilesDeleted === 0 ? chalk.gray(deletedLine) : chalk.white(deletedLine));
937
989
  console.log('');
990
+
991
+ // NEVER A BARE SUCCESS: if any KB's retrieval canary did not confirm searchability, this
992
+ // command fails loud here rather than letting the green summary above stand as the last
993
+ // word. The summary itself is left intact (it is a true report of what was written) —
994
+ // this is a SEPARATE fact ("can it be found yet") that gets its own loud verdict.
995
+ const unverified = canaryResults.filter((r) => !r.ok);
996
+ if (unverified.length > 0) {
997
+ throw new Error(
998
+ `Sync wrote vectors successfully, but ${unverified.length} KB(s) did NOT confirm ` +
999
+ `searchable within the canary window:\n` +
1000
+ unverified.map((r) =>
1001
+ ` - ${r.kbName}: not found after ${(r.elapsedMs / 1000).toFixed(1)}s / ${r.attempts} attempt(s)` +
1002
+ (r.lastError ? ` (last error: ${r.lastError})` : '')
1003
+ ).join('\n') +
1004
+ `\nThe vectors ARE present in Pinecone (reconciled above) — this is the store's own ` +
1005
+ `~20-minute post-upsert propagation delay, not data loss. Re-run the same sync command ` +
1006
+ `later to re-check; unchanged files make the re-run cheap and it re-runs this canary.`
1007
+ );
1008
+ }
1009
+
938
1010
  return { dryRun: false };
939
1011
 
940
1012
  } catch (error) {
@@ -1017,11 +1089,32 @@ export async function runCorpusStatus(apiClient, options) {
1017
1089
  console.log(chalk.white(` Last sync: ${syncState.timestamp}`));
1018
1090
  console.log(chalk.white(` Commit: ${syncState.last_sync_commit?.substring(0, 8) || 'unknown'}`));
1019
1091
  console.log(chalk.white(` Files tracked: ${syncState.synced_files_count}`));
1092
+ // NAME THE SOURCE OF THIS NUMBER: this is a LOCAL RECORD of what the store reported
1093
+ // AT THE TIME OF THE LAST SYNC's reconcile — not a fresh read of the store now. It can be
1094
+ // stale (the store's contents can change from other syncs/purges) and, even when fresh,
1095
+ // "present" is not "searchable" (see the Retrieval line below). `descix kb doctor -a
1096
+ // <app> -k <kb> --live` reads Pinecone directly for the current truth.
1020
1097
  console.log(chalk.white(
1021
- ` Chunks in store: ${Number.isFinite(syncState.total_chunks)
1098
+ ` Chunks (local record, as of last sync): ${Number.isFinite(syncState.total_chunks)
1022
1099
  ? syncState.total_chunks
1023
1100
  : 'unknown (the last sync reported no live count)'}`
1024
1101
  ));
1102
+ // Retrieval canary verdict from the last sync (see RetrievalCanary.js) — the only field
1103
+ // in this file that speaks to SEARCHABILITY rather than presence.
1104
+ const canary = syncState.retrieval_canary;
1105
+ if (canary === undefined) {
1106
+ console.log(chalk.gray(' Retrieval: unknown (synced before the retrieval-canary check existed)'));
1107
+ } else if (canary === null) {
1108
+ console.log(chalk.gray(' Retrieval: not checked (last sync upserted nothing new)'));
1109
+ } else if (canary.ok) {
1110
+ console.log(chalk.green(` Retrieval: CONFIRMED searchable (as of last sync, ${canary.checked_at})`));
1111
+ } else {
1112
+ console.log(chalk.yellow(
1113
+ ` Retrieval: NOT CONFIRMED as of last sync (${canary.checked_at}) — vectors were ` +
1114
+ `present but did not retrieve within the canary window. Re-run 'descix kb corpus sync ` +
1115
+ `-a ${appId} -k ${kbName}' to re-check.`
1116
+ ));
1117
+ }
1025
1118
 
1026
1119
  // Quick change detection: walk and compare blob SHAs
1027
1120
  if (options.verbose) {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * RetrievalCanary — a corpus sync does not report success until a KNOWN chunk it just
3
+ * wrote actually RETRIEVES from the store.
4
+ *
5
+ * MEASURED 2026-09-17: `descix kb corpus sync --rebuild` purges and re-upserts, then
6
+ * reports success and a reconciled live count, while the PROD search index takes ~20
7
+ * minutes to catch up. Every status surface said healthy over a KB that returned zero
8
+ * results for hours. The reconcile step (`get_kb_rag_status {reconcile:true}`, driven from
9
+ * `corpus.js::runCorpusSync`) counts by Pinecone ID PREFIX — presence, not searchability —
10
+ * so a clean reconcile is not evidence the index can answer a query yet.
11
+ *
12
+ * THE CANARY: after upserting, pick ONE chunk that was actually upserted (never one from a
13
+ * failed batch), run `query_knowledge_base` scoped to that chunk's own document
14
+ * (`file_filter`) using the chunk's own text as the query, and check the result set for a
15
+ * row whose metadata identifies THIS chunk (blob_sha + chunk_idx — the two fields
16
+ * `corpus.js::chunkCorpusFile` always writes, so this needs no second identity scheme).
17
+ *
18
+ * BOUNDED, NOT A LOOP: `query_knowledge_base` is a real, credit-metered read (WS-HEADLESS-MVP-A3
19
+ * flat debit per call). A canary that polled every few seconds for the full ~20-minute
20
+ * propagation window would multiply that cost by hundreds of calls for a single sync. This
21
+ * canary makes a SMALL, FIXED number of attempts (`DEFAULT_CANARY_DELAYS_MS`, 3 calls across
22
+ * ~25s) — enough to catch the common case where the index catches up quickly — and REFUSES to
23
+ * claim success if it does not, rather than waiting out the full window synchronously inside a
24
+ * CLI command. See corpus.js's hand-back for the case this does not close and the remedy it
25
+ * names when it doesn't (re-run the sync; unchanged blob SHAs make the re-run cheap and it
26
+ * re-runs this same canary).
27
+ */
28
+
29
+ /** Fixed, small backoff schedule — see the module doc for why this is bounded, not a loop. */
30
+ export const DEFAULT_CANARY_DELAYS_MS = [3000, 7000, 15000];
31
+
32
+ /** Pinecone metadata budget note in chunkCorpusFile caps chunk text at 35000 bytes; a canary
33
+ * query only needs enough text to be a near-exact self-match, so it is truncated far below that
34
+ * — a long query costs more to embed for no better discrimination.
35
+ */
36
+ const MAX_CANARY_QUERY_CHARS = 300;
37
+
38
+ /**
39
+ * The query text for a canary check: the chunk's own text is guaranteed to retrieve itself
40
+ * with the highest similarity of anything in the index, so no second corpus of "known good"
41
+ * queries is needed.
42
+ *
43
+ * @param {{text: string}} chunk
44
+ * @returns {string}
45
+ */
46
+ export function canaryQueryText(chunk) {
47
+ return String(chunk.text || '').trim().slice(0, MAX_CANARY_QUERY_CHARS);
48
+ }
49
+
50
+ /**
51
+ * Does this `query_knowledge_base` result row identify the given chunk?
52
+ *
53
+ * Matches on `blob_sha` + `chunk_idx` — the two fields `chunkCorpusFile()` always writes as
54
+ * Pinecone metadata, content-addressed and stable regardless of any server-side text
55
+ * normalization. This is the SAME identity the sync's own delta computation uses, not a
56
+ * second one invented for this check.
57
+ *
58
+ * @param {{metadata?: Record<string, any>}} row - one row of `query_knowledge_base`'s `results`
59
+ * @param {{blob_sha: string, chunk_idx: number}} chunk
60
+ * @returns {boolean}
61
+ */
62
+ export function chunkMatchesCanary(row, chunk) {
63
+ const md = row?.metadata || {};
64
+ return md.blob_sha === chunk.blob_sha && md.chunk_idx === chunk.chunk_idx;
65
+ }
66
+
67
+ /**
68
+ * Pick one upserted chunk to canary-check: the first chunk that was NOT part of a batch the
69
+ * store reported as failed. Returns null when there is nothing safe to check (every batch
70
+ * failed) — the caller's existing batch-failure reporting already covers that case loudly;
71
+ * this module does not re-report it.
72
+ *
73
+ * @param {Array<{id: string}>} allChunks - every chunk this sync attempted to upsert
74
+ * @param {Array<{chunks: string[]}>} syncFailures - failed batches, each naming its chunk ids
75
+ * @returns {object|null}
76
+ */
77
+ export function pickCanaryChunk(allChunks, syncFailures) {
78
+ const failedIds = new Set((syncFailures || []).flatMap((f) => f.chunks || []));
79
+ return allChunks.find((c) => !failedIds.has(c.id)) || null;
80
+ }
81
+
82
+ /**
83
+ * Run the bounded-backoff retrieval canary against a freshly-upserted chunk.
84
+ *
85
+ * @param {object} apiClient - an initialized DeSciXApiClient
86
+ * @param {object} args
87
+ * @param {string} args.appId
88
+ * @param {string} args.kbName
89
+ * @param {object} args.chunk - one entry from `allChunks` (must carry text/file_id/blob_sha/chunk_idx)
90
+ * @param {number[]} [args.delaysMs] - injectable for tests; production default is
91
+ * DEFAULT_CANARY_DELAYS_MS
92
+ * @param {(ms: number) => Promise<void>} [args.sleep] - injectable for tests (real timers by default)
93
+ * @returns {Promise<{ok: boolean, attempts: number, elapsedMs: number, lastError: string|null}>}
94
+ */
95
+ export async function runRetrievalCanary(apiClient, {
96
+ appId, kbName, chunk,
97
+ delaysMs = DEFAULT_CANARY_DELAYS_MS,
98
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
99
+ }) {
100
+ const startedAt = Date.now();
101
+ let attempts = 0;
102
+ let lastError = null;
103
+
104
+ for (const delay of delaysMs) {
105
+ attempts++;
106
+ await sleep(delay);
107
+ try {
108
+ const resp = await apiClient.invoke('query_knowledge_base', {
109
+ app_id: appId,
110
+ kb_id: kbName,
111
+ query: canaryQueryText(chunk),
112
+ limit: 5,
113
+ file_filter: chunk.file_id,
114
+ }, { allowGuest: false });
115
+ const data = resp?.message || resp;
116
+ const results = data?.results || [];
117
+ if (results.some((row) => chunkMatchesCanary(row, chunk))) {
118
+ return { ok: true, attempts, elapsedMs: Date.now() - startedAt, lastError: null };
119
+ }
120
+ } catch (err) {
121
+ // A transient failure on ONE attempt does not end the canary early — the schedule is
122
+ // already bounded by delaysMs.length, so this is not an unbounded retry-on-error loop.
123
+ lastError = err.message;
124
+ }
125
+ }
126
+
127
+ return { ok: false, attempts, elapsedMs: Date.now() - startedAt, lastError };
128
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * THE ORIGIN STAMP EVERY MCP TOOL RESULT CARRIES.
3
+ *
4
+ * MEASURED 2026-09-17: two sessions spent hours investigating a knowledge base as broken.
5
+ * Both were reading DEV through the MCP connector while believing they were reading PROD,
6
+ * because tool results said nothing about where they came from. `bin/mcp-server.js` returned
7
+ * every result as a bare `JSON.stringify(result)` — no origin, no environment. The envelope
8
+ * carries `community_id`/`app_id`, which READ like provenance and are not: they say which app,
9
+ * never which deployment of the platform answered.
10
+ *
11
+ * By contrast the CLI prints `env: <name> (<source>) <origin>` on EVERY network-bound command,
12
+ * unprompted (`lib/environment-report.js` `reportEnvironment()`), sourced from the ONE owner of
13
+ * "which origin did this invocation resolve to" (`lib/origin.js` `resolveOrigin()`, consumed by
14
+ * `DeSciXApiClient.initialize()` and kept on the client as `baseUrl` + `originSource`).
15
+ *
16
+ * This module adds NO second derivation. It reads the SAME two facts the CLI already keeps on
17
+ * an initialized `DeSciXApiClient` and combines them with the SAME env-name owner
18
+ * (`environmentNameFor`) the CLI's own status line uses. There is exactly one answer to "what
19
+ * origin, what environment" for a given client, and every surface — the CLI's stderr line, the
20
+ * MCP stamp — reads it from here.
21
+ */
22
+ import { environmentNameFor } from './environment-report.js';
23
+
24
+ /**
25
+ * Build the origin stamp for an MCP tool result FROM the one owner: an already-initialized
26
+ * `DeSciXApiClient`'s `baseUrl` + `originSource` (set once, in `initialize()`, by
27
+ * `resolveOrigin()` — see `api-client.js`).
28
+ *
29
+ * @param {import('./api-client.js').DeSciXApiClient} apiClient - must already be initialized
30
+ * (`ensureInitialized()` resolved) so `baseUrl`/`originSource` are populated. Calling this
31
+ * before initialization is a caller defect, not a state to paper over with a guess.
32
+ * @returns {{origin: string, source: string, environment: string}}
33
+ */
34
+ export function originStampFor(apiClient) {
35
+ const origin = apiClient?.baseUrl;
36
+ const source = apiClient?.originSource;
37
+ if (!origin || !source) {
38
+ throw new Error(
39
+ 'originStampFor: apiClient has no resolved baseUrl/originSource — ' +
40
+ 'call ensureInitialized() before stamping a result.',
41
+ );
42
+ }
43
+ return { origin, source, environment: environmentNameFor(origin) };
44
+ }
45
+
46
+ /**
47
+ * The ONE spelling of the human-visible stamp line. Mirrors `formatEnvLine()`
48
+ * (`environment-report.js`) in shape so a reader who has seen the CLI's own `env:` line
49
+ * recognizes this as the same fact, not a second convention.
50
+ *
51
+ * @param {{origin: string, source: string, environment: string}} stamp
52
+ * @returns {string}
53
+ */
54
+ export function formatOriginStampLine({ origin, source, environment }) {
55
+ return `[DeSciX origin] env: ${environment} (${source}) ${origin}`;
56
+ }
57
+
58
+ /**
59
+ * Attach the origin stamp to an MCP `CallToolResult` — success or error alike.
60
+ *
61
+ * TWO PLACES, DELIBERATELY, per the same fact:
62
+ * 1. An appended `content` block (visible text). This is the fix for the actual measured
63
+ * failure: the reader is an LLM/agent consuming the tool's TEXT output, not a program
64
+ * inspecting `_meta` — a stamp only a machine can see would not have caught the two
65
+ * sessions this closes. The ORIGINAL content entries are left untouched and in place, so
66
+ * an existing caller's `JSON.parse(content[0].text)` keeps working exactly as before —
67
+ * this only ever APPENDS a new block, never rewrites or reorders the old one.
68
+ * 2. `_meta.descix_origin` — the MCP-spec extension point (`CallToolResult._meta`, a sibling
69
+ * of `content`, not nested inside it) for a programmatic caller that wants the fact as
70
+ * structured data without re-parsing the human-readable line.
71
+ *
72
+ * @param {{content?: Array<object>, isError?: boolean, [k: string]: any}} result
73
+ * @param {{origin: string, source: string, environment: string}} stamp
74
+ * @returns {object} a NEW result object; `result` is not mutated.
75
+ */
76
+ export function stampToolResult(result, stamp) {
77
+ const content = Array.isArray(result.content) ? [...result.content] : [];
78
+ content.push({ type: 'text', text: formatOriginStampLine(stamp) });
79
+ return {
80
+ ...result,
81
+ content,
82
+ _meta: { ...(result._meta || {}), descix_origin: stamp },
83
+ };
84
+ }
@@ -136,7 +136,8 @@
136
136
  "kb_delete_chunks",
137
137
  "kb_list_file_ids",
138
138
  "kb_sync_chunks",
139
- "list_knowledge_bases"
139
+ "list_knowledge_bases",
140
+ "query_knowledge_base"
140
141
  ],
141
142
  "kb delete": [
142
143
  "delete_knowledge_base"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@descix/cli",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "DeSciX CLI and MCP Server - Unified command-line interface for DeSciX platform",
5
5
  "repository": {
6
6
  "type": "git",