@gmickel/gno 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +26 -30
  2. package/assets/skill/SKILL.md +8 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +2 -1
  11. package/spec/cli.md +48 -6
  12. package/spec/db/schema.sql +0 -1
  13. package/spec/mcp.md +18 -3
  14. package/spec/output-schemas/audit-report.schema.json +18 -4
  15. package/spec/output-schemas/backlinks.schema.json +4 -0
  16. package/spec/output-schemas/collection-list.schema.json +15 -2
  17. package/spec/output-schemas/graph.schema.json +2 -0
  18. package/spec/output-schemas/links-list.schema.json +4 -0
  19. package/spec/output-schemas/status.schema.json +27 -16
  20. package/src/cli/commands/ask.ts +31 -12
  21. package/src/cli/commands/audit.ts +23 -4
  22. package/src/cli/commands/collection/list.ts +39 -5
  23. package/src/cli/commands/embed.ts +3 -3
  24. package/src/cli/commands/links.ts +34 -131
  25. package/src/cli/commands/ls.ts +6 -1
  26. package/src/cli/commands/shared.ts +7 -0
  27. package/src/cli/commands/status.ts +5 -0
  28. package/src/cli/program.ts +12 -2
  29. package/src/config/loader.ts +43 -0
  30. package/src/config/types.ts +8 -0
  31. package/src/core/audit-contract.ts +16 -4
  32. package/src/core/audit-freshness.ts +11 -1
  33. package/src/core/audit-links.ts +145 -25
  34. package/src/core/audit-provenance.ts +11 -4
  35. package/src/core/audit-workspace.ts +19 -4
  36. package/src/core/audit.ts +67 -15
  37. package/src/core/context-compiler.ts +3 -0
  38. package/src/core/context-evidence.ts +11 -0
  39. package/src/core/graph-edge-confidence.ts +23 -1
  40. package/src/core/host-paths.ts +24 -5
  41. package/src/core/knowledge-impact.ts +28 -0
  42. package/src/core/link-workspace.ts +324 -0
  43. package/src/core/request-receipts.ts +63 -9
  44. package/src/core/retrieval-replay-candidate.ts +6 -0
  45. package/src/core/retrieval-trace-request.ts +3 -0
  46. package/src/core/windows-private-path.ts +136 -1
  47. package/src/embed/backlog.ts +22 -10
  48. package/src/embed/variant-backlog.ts +34 -9
  49. package/src/index.ts +14 -1
  50. package/src/ingestion/graph-reconciliation.ts +77 -15
  51. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  52. package/src/ingestion/sync.ts +22 -1
  53. package/src/ingestion/types.ts +14 -0
  54. package/src/llm/inference-scope.ts +19 -0
  55. package/src/mcp/http-egress.ts +42 -3
  56. package/src/mcp/tools/audit.ts +11 -2
  57. package/src/mcp/tools/changes.ts +1 -0
  58. package/src/mcp/tools/links.ts +3 -0
  59. package/src/mcp/tools/sessions.ts +33 -4
  60. package/src/mcp/tools/status.ts +22 -6
  61. package/src/pipeline/expansion.ts +19 -31
  62. package/src/pipeline/graph-retrieval.ts +22 -2
  63. package/src/pipeline/hybrid.ts +1 -1
  64. package/src/pipeline/types.ts +6 -3
  65. package/src/serve/embed-scheduler.ts +2 -2
  66. package/src/serve/findings-pass.ts +1 -1
  67. package/src/serve/host-path-redaction.ts +51 -14
  68. package/src/serve/public/components/BootstrapStatus.tsx +5 -3
  69. package/src/serve/public/components/CaptureModal.tsx +1 -1
  70. package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
  71. package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
  72. package/src/serve/public/components/FirstRunWizard.tsx +4 -2
  73. package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
  74. package/src/serve/public/pages/Collections.tsx +16 -13
  75. package/src/serve/public/pages/Connectors.tsx +7 -4
  76. package/src/serve/public/pages/Dashboard.tsx +8 -6
  77. package/src/serve/public/pages/GraphView.tsx +2 -0
  78. package/src/serve/resident-runtime.ts +7 -0
  79. package/src/serve/routes/changes.ts +6 -1
  80. package/src/serve/routes/links.ts +13 -0
  81. package/src/serve/routes/sessions.ts +81 -37
  82. package/src/serve/server.ts +5 -3
  83. package/src/serve/status-model.ts +10 -6
  84. package/src/serve/status.ts +2 -7
  85. package/src/sessions/config-refresh.ts +111 -0
  86. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  87. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  88. package/src/store/migrations/index.ts +4 -0
  89. package/src/store/sqlite/adapter.ts +392 -233
  90. package/src/store/sqlite/change-journal-store.ts +1 -1
  91. package/src/store/sqlite/eligibility.ts +8 -2
  92. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  93. package/src/store/sqlite/graph-neighbors.ts +147 -40
  94. package/src/store/sqlite/graph-reference-state.ts +13 -2
  95. package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
  96. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  97. package/src/store/types.ts +49 -3
  98. package/src/store/vector/stats.ts +1 -1
  99. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
@@ -219,6 +219,8 @@ interface BacklinksInput {
219
219
  interface BacklinkOutput {
220
220
  sourceDocUri: string;
221
221
  sourceDocTitle?: string;
222
+ /** Collection of the linking document. */
223
+ sourceCollection?: string;
222
224
  linkText?: string;
223
225
  position: { startLine: number; startCol: number };
224
226
  }
@@ -308,6 +310,7 @@ export function handleBacklinks(
308
310
  (b: BacklinkRow) => ({
309
311
  sourceDocUri: b.sourceDocUri,
310
312
  ...(b.sourceDocTitle && { sourceDocTitle: b.sourceDocTitle }),
313
+ ...(b.sourceCollection && { sourceCollection: b.sourceCollection }),
311
314
  ...(b.linkText && { linkText: b.linkText }),
312
315
  position: { startLine: b.startLine, startCol: b.startCol },
313
316
  })
@@ -13,9 +13,11 @@
13
13
 
14
14
  import { z } from "zod";
15
15
 
16
+ import type { Config } from "../../config/types";
16
17
  import type { ToolContext } from "../server";
17
18
 
18
19
  import { runAutomationProfile } from "../../sessions/automation";
20
+ import { refreshServedConfig } from "../../sessions/config-refresh";
19
21
  import {
20
22
  formatAutomationRunText,
21
23
  formatImportReceiptText,
@@ -99,9 +101,36 @@ export const SESSIONS_IMPORT_MCP_ANNOTATIONS = {
99
101
  openWorldHint: false,
100
102
  } as const;
101
103
 
102
- function service(ctx: ToolContext): SessionsService {
103
- return new SessionsService({
104
+ /**
105
+ * The config file as it is now, adopted when the CLI changed it while this
106
+ * server runs (same binding checks and errors as the REST routes). Inside a
107
+ * request `ctx.config` stays the snapshot taken at its start, so callers use
108
+ * the returned config. An adoption moves the egress policy epoch; this
109
+ * request advances with it rather than voiding itself.
110
+ */
111
+ function currentConfig(ctx: ToolContext): Promise<Config> {
112
+ return refreshServedConfig({
113
+ configPath: ctx.actualConfigPath,
114
+ indexName: ctx.indexName,
115
+ store: ctx.store,
104
116
  config: ctx.config,
117
+ setConfig: (config) => {
118
+ ctx.config = config;
119
+ },
120
+ invalidateEgressPolicy: async () => {
121
+ const invalidation = await ctx.invalidateEgressPolicy?.();
122
+ if (invalidation) {
123
+ ctx.advanceRequestAuthorizationEpoch?.(invalidation.policyEpoch);
124
+ }
125
+ },
126
+ markContentMutation: () => ctx.markContentMutation?.(),
127
+ markIndexMutation: () => ctx.markIndexMutation?.(),
128
+ });
129
+ }
130
+
131
+ async function service(ctx: ToolContext): Promise<SessionsService> {
132
+ return new SessionsService({
133
+ config: await currentConfig(ctx),
105
134
  configPath: ctx.actualConfigPath,
106
135
  indexName: ctx.indexName,
107
136
  store: ctx.store,
@@ -120,7 +149,7 @@ export function handleSessionsStatus(ctx: ToolContext): Promise<ToolResult> {
120
149
  "gno_sessions_status",
121
150
  async () => {
122
151
  try {
123
- return await service(ctx).status();
152
+ return await (await service(ctx)).status();
124
153
  } catch (error) {
125
154
  return rethrowSessionsError(error);
126
155
  }
@@ -146,7 +175,7 @@ export function handleSessionsImport(
146
175
  try {
147
176
  // A child process keeps this server answering during a long import.
148
177
  receipt = await importInChildProcess({
149
- config: ctx.config,
178
+ config: await currentConfig(ctx),
150
179
  configPath: ctx.actualConfigPath,
151
180
  indexName: ctx.indexName,
152
181
  sourceId: args.sourceId,
@@ -4,27 +4,38 @@
4
4
  * @module src/mcp/tools/status
5
5
  */
6
6
 
7
- import type { IndexStatus } from "../../store/types";
7
+ import type { CollectionStatus, IndexStatus } from "../../store/types";
8
8
  import type { ToolContext } from "../server";
9
9
 
10
10
  import { buildContentTypeBoostStatus } from "../../config/content-types";
11
11
  import { formatChunkingStatus } from "../../core/chunking-status";
12
+ import { OWNER_CONFIG_PATH_FIELDS, withoutFields } from "../../core/host-paths";
13
+ import { formatLinkWorkspace } from "../../core/link-workspace";
12
14
  import { formatVectorPartitionLines } from "../../core/vector-partition-status";
13
15
  import { resolveModelUri } from "../../llm/registry";
14
16
  import { createStandaloneResidentStatus } from "../../serve/resident-status";
17
+ import { exposesHostPaths } from "../context";
15
18
  import { runTool, type ToolResult } from "./index";
16
19
 
17
20
  type StatusInput = Record<string, never>;
18
21
 
22
+ /** Status as the caller sees it: HTTP callers get no owner config paths. */
23
+ type StatusView = Omit<IndexStatus, "configPath" | "dbPath" | "collections"> &
24
+ Partial<Pick<IndexStatus, "configPath" | "dbPath">> & {
25
+ collections: Array<
26
+ Omit<CollectionStatus, "path"> & Partial<Pick<CollectionStatus, "path">>
27
+ >;
28
+ };
29
+
19
30
  /**
20
31
  * Format status as text for MCP content.
21
32
  */
22
- function formatStatus(status: IndexStatus): string {
33
+ function formatStatus(status: StatusView): string {
23
34
  const lines: string[] = [];
24
35
 
25
36
  lines.push(`Index: ${status.indexName}`);
26
- lines.push(`Config: ${status.configPath}`);
27
- lines.push(`Database: ${status.dbPath}`);
37
+ if (status.configPath) lines.push(`Config: ${status.configPath}`);
38
+ if (status.dbPath) lines.push(`Database: ${status.dbPath}`);
28
39
  lines.push(`Health: ${status.healthy ? "OK" : "DEGRADED"}`);
29
40
  if ("resident" in status) {
30
41
  const resident = status.resident as NonNullable<
@@ -45,6 +56,8 @@ function formatStatus(status: IndexStatus): string {
45
56
  ` ${c.name}: ${c.activeDocuments} docs, ${c.totalChunks} chunks` +
46
57
  (c.embeddedChunks > 0 ? `, ${c.embeddedChunks} embedded` : "")
47
58
  );
59
+ const workspace = formatLinkWorkspace(c);
60
+ if (workspace) lines.push(` Link workspace: ${workspace}`);
48
61
  }
49
62
  }
50
63
 
@@ -88,7 +101,7 @@ export function handleStatus(
88
101
  return runTool(
89
102
  ctx,
90
103
  "gno_status",
91
- async () => {
104
+ async (): Promise<StatusView> => {
92
105
  const result = await ctx.store.getStatus({
93
106
  embedModel: resolveModelUri(ctx.config, "embed"),
94
107
  chunking: ctx.config.chunking ?? {},
@@ -98,7 +111,7 @@ export function handleStatus(
98
111
  }
99
112
 
100
113
  // Override configPath with actual path from context
101
- return {
114
+ const status = {
102
115
  ...result.value,
103
116
  configPath: ctx.actualConfigPath,
104
117
  contentTypeBoost: buildContentTypeBoostStatus(
@@ -107,6 +120,9 @@ export function handleStatus(
107
120
  resident:
108
121
  ctx.getResidentStatus?.() ?? createStandaloneResidentStatus("stdio"),
109
122
  };
123
+ return exposesHostPaths(ctx)
124
+ ? status
125
+ : withoutFields(status, OWNER_CONFIG_PATH_FIELDS);
110
126
  },
111
127
  formatStatus
112
128
  );
@@ -15,6 +15,7 @@ import {
15
15
  assertInferenceActive,
16
16
  assertInferenceResult,
17
17
  inferenceOptions,
18
+ withInferencePage,
18
19
  } from "../llm/inference-scope";
19
20
  import { ok } from "../store/types";
20
21
 
@@ -23,7 +24,6 @@ import { ok } from "../store/types";
23
24
  // ─────────────────────────────────────────────────────────────────────────────
24
25
 
25
26
  const EXPANSION_PROMPT_VERSION = "v3";
26
- const DEFAULT_TIMEOUT_MS = 5000;
27
27
  // Non-greedy to avoid matching from first { to last } across multiple objects
28
28
  const JSON_EXTRACT_PATTERN = /\{[\s\S]*?\}/;
29
29
  const QUOTED_PHRASE_PATTERN = /"([^"]+)"/g;
@@ -457,8 +457,6 @@ export function parseExpansionOutput(
457
457
  export interface ExpansionOptions extends InferenceOptions {
458
458
  /** Language hint for prompt selection */
459
459
  lang?: string;
460
- /** Timeout in milliseconds */
461
- timeout?: number;
462
460
  /** Optional context that steers expansion for ambiguous queries */
463
461
  intent?: string;
464
462
  /** Optional bounded context size override for expansion generation */
@@ -467,59 +465,49 @@ export interface ExpansionOptions extends InferenceOptions {
467
465
 
468
466
  /**
469
467
  * Expand query using generation model.
470
- * Returns null on timeout or parse failure (graceful degradation).
468
+ * Returns null when generation fails or times out, or its output does not
469
+ * parse (graceful degradation).
471
470
  */
472
471
  export async function expandQuery(
473
472
  genPort: GenerationPort,
474
473
  query: string,
475
474
  options: ExpansionOptions = {}
476
475
  ): Promise<StoreResult<ExpansionResult | null>> {
477
- const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
478
-
479
476
  // Build prompt
480
477
  const prompt = buildExpansionPrompt(query, options);
481
478
 
482
479
  assertInferenceActive(options);
483
480
  const operational = inferenceOptions(options);
484
- const budget = new AbortController();
485
- const expiresAt = performance.now() + timeout;
486
- const timer = setTimeout(() => budget.abort(), timeout);
487
481
  try {
488
- const result = await genPort.generate(
489
- prompt,
490
- {
491
- temperature: 0,
492
- seed: 42,
493
- maxTokens: 512,
494
- contextSize: options.contextSize,
495
- },
496
- {
497
- ...operational,
498
- signal: operational.signal
499
- ? AbortSignal.any([operational.signal, budget.signal])
500
- : budget.signal,
501
- }
482
+ // Expansion is best effort but has no budget of its own: slow hardware
483
+ // (CPU generation takes 15-20s) and a cold model load still expand. A
484
+ // generation that hits models.inferenceTimeout fails only expansion.
485
+ const result = await withInferencePage(() =>
486
+ genPort.generate(
487
+ prompt,
488
+ {
489
+ temperature: 0,
490
+ seed: 42,
491
+ maxTokens: 512,
492
+ contextSize: options.contextSize,
493
+ },
494
+ operational
495
+ )
502
496
  );
503
- // The independent expansion budget preserves its existing fallback. Caller
504
- // cancellation/deadline always wins and cannot become a lexical success.
497
+ // Caller cancellation/deadline always wins and cannot become a lexical success.
505
498
  assertInferenceActive(options);
506
- if (budget.signal.aborted || performance.now() >= expiresAt)
507
- return ok(null);
499
+ if (!result) return ok(null);
508
500
  assertInferenceResult(result);
509
501
  if (!result.ok) return ok(null);
510
502
  return ok(parseExpansionOutput(result.value, query));
511
503
  } catch (cause) {
512
504
  assertInferenceActive(options);
513
- if (budget.signal.aborted || performance.now() >= expiresAt)
514
- return ok(null);
515
505
  if (
516
506
  cause instanceof Error &&
517
507
  ["AbortError", "TimeoutError"].includes(cause.name)
518
508
  )
519
509
  throw cause;
520
510
  return ok(null);
521
- } finally {
522
- clearTimeout(timer);
523
511
  }
524
512
  }
525
513
 
@@ -196,6 +196,7 @@ const loadGraphLinks = async (
196
196
  seedDocumentIds: number[],
197
197
  options: {
198
198
  collection?: string;
199
+ collections?: string[];
199
200
  includeSimilar?: boolean;
200
201
  }
201
202
  ): Promise<
@@ -205,6 +206,7 @@ const loadGraphLinks = async (
205
206
  const neighborsResult = await store.getGraphNeighborsForSeeds({
206
207
  seedDocumentIds,
207
208
  collection: options.collection,
209
+ collections: options.collections,
208
210
  limitEdges: GRAPH_EDGE_LIMIT,
209
211
  });
210
212
  if (!neighborsResult.ok) {
@@ -239,6 +241,11 @@ export async function expandGraphCandidates(
239
241
  fusedCandidates: FusionCandidate[],
240
242
  options: {
241
243
  collection?: string;
244
+ /**
245
+ * Graph allowlist (plural request scope). Neighbours must belong to one of
246
+ * these collections; defaults to `[collection]` when unset.
247
+ */
248
+ collections?: string[];
242
249
  includeSimilar?: boolean;
243
250
  eligibility?: DocumentEligibilityOptions;
244
251
  limit?: number;
@@ -353,8 +360,14 @@ export async function expandGraphCandidates(
353
360
  }
354
361
 
355
362
  const seedDocumentIds = [...seedByDocid.values()].map(({ doc }) => doc.id);
363
+ const graphAllowlist = options.collections
364
+ ? new Set(options.collections)
365
+ : options.collection
366
+ ? new Set([options.collection])
367
+ : undefined;
356
368
  const linksResult = await loadGraphLinks(store, seedDocumentIds, {
357
369
  collection: options.collection,
370
+ collections: options.collections,
358
371
  includeSimilar: options.includeSimilar,
359
372
  });
360
373
  if (!linksResult.ok) {
@@ -400,9 +413,15 @@ export async function expandGraphCandidates(
400
413
  return { candidates: [], meta };
401
414
  }
402
415
 
416
+ // A plural allowlist hydrates across its collections; scope is enforced on
417
+ // each neighbour's own collection below, never widened past the allowlist.
418
+ const plural = options.collections !== undefined;
403
419
  const docsResult = await store.getDocumentsByDocids(rankedNeighborDocids, {
404
- eligibility: options.eligibility,
405
- collection: options.collection,
420
+ eligibility:
421
+ plural && options.eligibility
422
+ ? { ...options.eligibility, collection: undefined }
423
+ : options.eligibility,
424
+ collection: plural ? undefined : options.collection,
406
425
  activeOnly: true,
407
426
  });
408
427
  if (!docsResult.ok) {
@@ -413,6 +432,7 @@ export async function expandGraphCandidates(
413
432
  const metadataFilteredDocs = docsResult.value.filter(
414
433
  (doc) =>
415
434
  doc.mirrorHash &&
435
+ (!graphAllowlist || graphAllowlist.has(doc.collection)) &&
416
436
  (options.relPathPrefix === undefined ||
417
437
  sourceRelPath(doc) === options.relPathPrefix ||
418
438
  sourceRelPath(doc).startsWith(`${options.relPathPrefix}/`)) &&
@@ -520,7 +520,6 @@ async function searchHybridWithHydration(
520
520
  const expandResult = await expandQuery(expandPort, query, {
521
521
  // Use queryLanguage for prompt selection, NOT options.lang (retrieval filter)
522
522
  lang: queryLanguage,
523
- timeout: pipelineConfig.expansionTimeout,
524
523
  intent: options.intent,
525
524
  contextSize: deps.config.models?.expandContextSize,
526
525
  });
@@ -804,6 +803,7 @@ async function searchHybridWithHydration(
804
803
  fusedCandidates,
805
804
  {
806
805
  collection: options.collection,
806
+ collections: options.graphCollections,
807
807
  includeSimilar: vectorAvailable,
808
808
  eligibility: vectorEligibility,
809
809
  limit,
@@ -191,6 +191,12 @@ export interface SearchOptions extends InferenceOptions {
191
191
  minScore?: number;
192
192
  /** Filter by collection */
193
193
  collection?: string;
194
+ /**
195
+ * Internal graph allowlist for a request partitioned into one retrieval per
196
+ * collection (Context Capsules, replay): graph neighbours may come from any
197
+ * of these collections, never from others. Defaults to `[collection]`.
198
+ */
199
+ graphCollections?: string[];
194
200
  /** Internal exact corpus scope used by deterministic retrieval replay. */
195
201
  retrievalScope?: {
196
202
  relPathPrefix?: string;
@@ -406,8 +412,6 @@ export type RerankedCandidate = FusionCandidate & {
406
412
 
407
413
  /** Search pipeline configuration */
408
414
  export interface PipelineConfig {
409
- /** Expansion timeout in ms */
410
- expansionTimeout: number;
411
415
  /** Max candidates to rerank */
412
416
  rerankCandidates: number;
413
417
  /** RRF configuration */
@@ -418,7 +422,6 @@ export interface PipelineConfig {
418
422
 
419
423
  /** Default pipeline configuration */
420
424
  export const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
421
- expansionTimeout: 5000,
422
425
  rerankCandidates: 20,
423
426
  rrf: DEFAULT_RRF_CONFIG,
424
427
  blendingSchedule: DEFAULT_BLENDING_SCHEDULE,
@@ -202,8 +202,8 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
202
202
  } catch (cause) {
203
203
  if (controller.signal.aborted)
204
204
  return { result: { embedded: 0, errors: 0 } };
205
- // An inference deadline or worker exit aborts the whole pass. It is a
206
- // failed pass like any other, never a silently dropped rejection.
205
+ // A worker exit aborts the whole pass (a deadline fails only its page).
206
+ // It is a failed pass like any other, never a silently dropped rejection.
207
207
  return {
208
208
  result: { embedded: 0, errors: 0 },
209
209
  failure: cause instanceof Error ? cause.message : String(cause),
@@ -30,7 +30,7 @@ import {
30
30
  } from "../core/findings-run-state";
31
31
  import { acquireCliWriteLease } from "../core/write-lease";
32
32
 
33
- /** Audit report cap; matches the audit schema ceiling. */
33
+ /** Audit report cap for the daemon findings pass. */
34
34
  const FINDINGS_AUDIT_MAX_FINDINGS = 1000;
35
35
  const LEASE_HOLDER_COMMAND = "gno daemon (findings pass)";
36
36
  const CONTROL_CHARS = /\p{Cc}/gu;
@@ -1,36 +1,72 @@
1
1
  /**
2
2
  * REST boundary for host paths: a caller the request-locality rule judges
3
- * remote never receives an `absPath` field from any `/api/*` JSON response.
4
- * Same-host callers (the loopback Web UI) keep them for Reveal and
5
- * "Open original". Routes that stream original source bytes are exempt:
6
- * their body is the owner's file, not an API envelope.
3
+ * remote never receives an `absPath` field from any `/api/*` JSON response,
4
+ * nor the host `path` fields of the status, collection, connector, and
5
+ * document-mutation routes (plus `configPath`/`dbPath` on status and the
6
+ * `file://` `uri` of a document save). Same-host callers (the loopback Web
7
+ * UI) keep them for Reveal, "Open original", and the Collections page.
8
+ * Routes that stream original source bytes are exempt: their body is the
9
+ * owner's file, not an API envelope.
7
10
  */
8
11
 
9
- import { withoutHostPaths } from "../core/host-paths";
12
+ import {
13
+ HOST_PATH_FIELDS,
14
+ OWNER_CONFIG_PATH_FIELDS,
15
+ withoutFields,
16
+ } from "../core/host-paths";
10
17
  import {
11
18
  isLocalClientRequest,
12
19
  type RequestPeerServer,
13
20
  } from "./request-locality";
14
21
 
15
- const HOST_PATH_KEY = '"absPath"';
16
22
  const SOURCE_BYTE_ROUTES = new Set(["/api/doc-asset"]);
23
+ const OWNER_CONFIG_FIELDS: ReadonlySet<string> = new Set([
24
+ ...HOST_PATH_FIELDS,
25
+ ...OWNER_CONFIG_PATH_FIELDS,
26
+ ]);
27
+ /** Document mutations answer with the file's host `path`. */
28
+ const DOC_PATH_FIELDS: ReadonlySet<string> = new Set([
29
+ ...HOST_PATH_FIELDS,
30
+ "path",
31
+ ]);
32
+ /** A document save also names the file by a `file://` `uri`. */
33
+ const DOC_SAVE_FIELDS: ReadonlySet<string> = new Set([
34
+ ...DOC_PATH_FIELDS,
35
+ "uri",
36
+ ]);
37
+ const ROUTE_FIELDS = new Map<string, ReadonlySet<string>>([
38
+ ["/api/status", OWNER_CONFIG_FIELDS],
39
+ ["/api/collections", OWNER_CONFIG_FIELDS],
40
+ ["/api/collections/:name", OWNER_CONFIG_FIELDS],
41
+ ["/api/connectors", OWNER_CONFIG_FIELDS],
42
+ ["/api/connectors/install", OWNER_CONFIG_FIELDS],
43
+ ["/api/docs", DOC_PATH_FIELDS],
44
+ ["/api/docs/:id", DOC_SAVE_FIELDS],
45
+ ["/api/docs/:id/rename", DOC_PATH_FIELDS],
46
+ ["/api/docs/:id/move", DOC_PATH_FIELDS],
47
+ ["/api/docs/:id/duplicate", DOC_PATH_FIELDS],
48
+ ["/api/docs/:id/trash", DOC_PATH_FIELDS],
49
+ ["/api/docs/:id/editable-copy", DOC_PATH_FIELDS],
50
+ ["/api/folders", DOC_PATH_FIELDS],
51
+ ]);
17
52
 
18
53
  type RouteHandler = (
19
54
  req: Request,
20
55
  server: RequestPeerServer
21
56
  ) => Response | Promise<Response>;
22
57
 
23
- /** The JSON response with every `absPath` field removed. */
58
+ /** The JSON response with every key in `fields` removed. */
24
59
  export async function redactResponseHostPaths(
25
- response: Response
60
+ response: Response,
61
+ fields: ReadonlySet<string>
26
62
  ): Promise<Response> {
27
63
  const contentType = response.headers.get("content-type") ?? "";
28
64
  if (!contentType.includes("application/json") || response.body === null) {
29
65
  return response;
30
66
  }
31
67
  const text = await response.text();
32
- const body = text.includes(HOST_PATH_KEY)
33
- ? JSON.stringify(withoutHostPaths(JSON.parse(text) as unknown))
68
+ const body = [...fields].some((field) => text.includes(`"${field}"`))
69
+ ? JSON.stringify(withoutFields(JSON.parse(text) as unknown, fields))
34
70
  : text;
35
71
  const headers = new Headers(response.headers);
36
72
  headers.delete("content-length");
@@ -42,12 +78,12 @@ export async function redactResponseHostPaths(
42
78
  }
43
79
 
44
80
  const redacting =
45
- (handler: RouteHandler): RouteHandler =>
81
+ (handler: RouteHandler, fields: ReadonlySet<string>): RouteHandler =>
46
82
  async (req, server) => {
47
83
  const response = await handler(req, server);
48
84
  return isLocalClientRequest(req, server)
49
85
  ? response
50
- : redactResponseHostPaths(response);
86
+ : redactResponseHostPaths(response, fields);
51
87
  };
52
88
 
53
89
  const isMethodTable = (route: unknown): route is Record<string, unknown> =>
@@ -62,14 +98,15 @@ export function withRemoteHostPathRedaction<T extends Record<string, unknown>>(
62
98
  const wrapped: Record<string, unknown> = { ...routes };
63
99
  for (const [path, route] of Object.entries(routes)) {
64
100
  if (!path.startsWith("/api/") || SOURCE_BYTE_ROUTES.has(path)) continue;
101
+ const fields = ROUTE_FIELDS.get(path) ?? HOST_PATH_FIELDS;
65
102
  if (typeof route === "function") {
66
- wrapped[path] = redacting(route as RouteHandler);
103
+ wrapped[path] = redacting(route as RouteHandler, fields);
67
104
  } else if (isMethodTable(route)) {
68
105
  wrapped[path] = Object.fromEntries(
69
106
  Object.entries(route).map(([method, handler]) => [
70
107
  method,
71
108
  typeof handler === "function"
72
- ? redacting(handler as RouteHandler)
109
+ ? redacting(handler as RouteHandler, fields)
73
110
  : handler,
74
111
  ])
75
112
  );
@@ -105,9 +105,11 @@ export function BootstrapStatus({
105
105
  </CardHeader>
106
106
  <CardContent className="space-y-2 text-sm">
107
107
  <p>Total cache: {bootstrap.cache.totalSizeLabel}</p>
108
- <p className="font-mono text-muted-foreground text-xs">
109
- {bootstrap.cache.path}
110
- </p>
108
+ {bootstrap.cache.path && (
109
+ <p className="font-mono text-muted-foreground text-xs">
110
+ {bootstrap.cache.path}
111
+ </p>
112
+ )}
111
113
  </CardContent>
112
114
  </Card>
113
115
 
@@ -75,7 +75,7 @@ export interface CaptureModalProps {
75
75
 
76
76
  interface Collection {
77
77
  name: string;
78
- path: string;
78
+ path?: string;
79
79
  }
80
80
 
81
81
  interface CreateDocResponse {
@@ -54,7 +54,8 @@ export interface CollectionModelDetails {
54
54
  modelSources?: Record<ModelRole, ModelSource>;
55
55
  models?: Partial<Record<ModelRole, string>>;
56
56
  name: string;
57
- path: string;
57
+ /** Host path; omitted for remote callers. */
58
+ path?: string;
58
59
  pattern?: string;
59
60
  egressPolicy?: {
60
61
  schemaVersion: "1.0";
@@ -110,7 +111,7 @@ const CODE_EXT_HINTS = [
110
111
  ] as const;
111
112
 
112
113
  function collectionLooksCodeHeavy(collection: CollectionModelDetails): boolean {
113
- const path = collection.path.toLowerCase();
114
+ const path = (collection.path ?? "").toLowerCase();
114
115
  if (
115
116
  CODE_PATH_HINTS.some(
116
117
  (hint) => path.endsWith(hint) || path.includes(`${hint}/`)
@@ -284,17 +285,19 @@ export function CollectionModelDialog({
284
285
  active preset for the rest of the workspace.
285
286
  </DialogDescription>
286
287
  </div>
287
- <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
288
- <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
289
- Path
290
- </p>
291
- <p
292
- className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
293
- title={collection?.path}
294
- >
295
- {collection?.path}
296
- </p>
297
- </div>
288
+ {collection?.path && (
289
+ <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
290
+ <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
291
+ Path
292
+ </p>
293
+ <p
294
+ className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
295
+ title={collection.path}
296
+ >
297
+ {collection.path}
298
+ </p>
299
+ </div>
300
+ )}
298
301
  </div>
299
302
  </DialogHeader>
300
303
 
@@ -13,6 +13,8 @@ export function CollectionsEmptyState({
13
13
  suggestedCollections,
14
14
  onAddCollection,
15
15
  }: CollectionsEmptyStateProps) {
16
+ // A remote caller's suggestions carry no host path to prefill.
17
+ const quickPicks = suggestedCollections.filter(({ path }) => path);
16
18
  return (
17
19
  <div className="mx-auto max-w-3xl py-16 text-center">
18
20
  <div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
@@ -29,7 +31,7 @@ export function CollectionsEmptyState({
29
31
  <FolderPlusIcon className="mr-2 size-4" />
30
32
  Add Folder
31
33
  </Button>
32
- {suggestedCollections.map((suggestion) => (
34
+ {quickPicks.map((suggestion) => (
33
35
  <Button
34
36
  key={suggestion.path}
35
37
  onClick={() => onAddCollection(suggestion.path)}
@@ -40,9 +42,9 @@ export function CollectionsEmptyState({
40
42
  ))}
41
43
  </div>
42
44
 
43
- {suggestedCollections.length > 0 && (
45
+ {quickPicks.length > 0 && (
44
46
  <div className="grid gap-3 text-left md:grid-cols-2">
45
- {suggestedCollections.map((suggestion) => (
47
+ {quickPicks.map((suggestion) => (
46
48
  <div
47
49
  className="rounded-xl border border-border/60 bg-background/70 p-4"
48
50
  key={`${suggestion.path}-detail`}
@@ -235,6 +235,8 @@ export function FirstRunWizard({
235
235
  ? steps[activeIndex + 1]
236
236
  : null;
237
237
  const progressValue = getStepProgress(onboarding);
238
+ // A remote caller's suggestions carry no host path to prefill.
239
+ const quickPicks = onboarding.suggestedCollections.filter(({ path }) => path);
238
240
  const isShowingRecommended = activeStep?.id === recommendedStepId;
239
241
  const runRecommendedAction = () => {
240
242
  if (!activeStep) {
@@ -277,7 +279,7 @@ export function FirstRunWizard({
277
279
  </Button>
278
280
  </div>
279
281
 
280
- {onboarding.suggestedCollections.length > 0 && (
282
+ {quickPicks.length > 0 && (
281
283
  <div className="space-y-3">
282
284
  <div className="flex items-center justify-between gap-3">
283
285
  <div>
@@ -295,7 +297,7 @@ export function FirstRunWizard({
295
297
  <Badge variant="outline">Quick picks</Badge>
296
298
  </div>
297
299
  <div className="grid gap-3 md:grid-cols-2">
298
- {onboarding.suggestedCollections.map((suggestion) => (
300
+ {quickPicks.map((suggestion) => (
299
301
  <button
300
302
  className="rounded-xl border border-border/70 bg-background/80 p-4 text-left shadow-sm transition-all hover:border-primary/35 hover:bg-background hover:shadow-md"
301
303
  key={suggestion.path}