@gmickel/gno 1.34.6 → 1.36.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 (51) hide show
  1. package/README.md +12 -1
  2. package/assets/skill/SKILL.md +26 -3
  3. package/assets/skill/cli-reference.md +9 -0
  4. package/assets/skill/mcp-reference.md +3 -2
  5. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.6.zip → gno-browser-clipper-v1.36.0.zip} +0 -0
  6. package/browser-extension/artifacts/gno-browser-clipper-v1.36.0.zip.sha256 +1 -0
  7. package/browser-extension/dist/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/spec/cli.md +111 -1
  10. package/spec/mcp.md +60 -1
  11. package/spec/output-schemas/mcp-job-status.schema.json +6 -2
  12. package/spec/output-schemas/peek.schema.json +212 -0
  13. package/spec/output-schemas/search-results.schema.json +1 -1
  14. package/src/cli/commands/peek.ts +66 -0
  15. package/src/cli/options.ts +2 -0
  16. package/src/cli/program.ts +20 -0
  17. package/src/config/index.ts +4 -0
  18. package/src/config/types.ts +14 -0
  19. package/src/core/path-rules.ts +34 -0
  20. package/src/core/peek.ts +202 -0
  21. package/src/ingestion/index.ts +21 -0
  22. package/src/ingestion/record-container.ts +23 -1
  23. package/src/ingestion/source-availability/darwin-io.ts +295 -0
  24. package/src/ingestion/source-availability/darwin-path.ts +58 -0
  25. package/src/ingestion/source-availability/directory.ts +402 -0
  26. package/src/ingestion/source-availability/index.ts +74 -0
  27. package/src/ingestion/source-availability/readers.ts +360 -0
  28. package/src/ingestion/source-availability/resolve.ts +28 -0
  29. package/src/ingestion/source-availability/types.ts +170 -0
  30. package/src/ingestion/sync.ts +197 -24
  31. package/src/ingestion/types.ts +45 -3
  32. package/src/ingestion/walker.ts +263 -5
  33. package/src/mcp/http-egress.ts +1 -0
  34. package/src/mcp/tools/index.ts +14 -0
  35. package/src/mcp/tools/peek.ts +78 -0
  36. package/src/serve/public/globals.built.css +1 -1
  37. package/src/serve/watch-reconciliation-fallback-disk.ts +239 -100
  38. package/src/serve/watch-reconciliation-fallback.ts +35 -5
  39. package/src/serve/watch-reconciliation-shared.ts +8 -3
  40. package/src/serve/watch-reconciliation.ts +7 -0
  41. package/src/serve/watch-service-flush.ts +10 -0
  42. package/src/serve/watch-service-lifecycle.ts +2 -0
  43. package/src/serve/watch-service-snapshot.ts +27 -3
  44. package/src/serve/watch-service.ts +1 -0
  45. package/src/serve/watch-snapshot-availability.ts +51 -0
  46. package/src/serve/watch-snapshot-handles.ts +117 -37
  47. package/src/serve/watch-snapshot-libc.ts +141 -22
  48. package/src/serve/watch-snapshot-ops.ts +151 -9
  49. package/src/serve/watch-snapshot-scan.ts +3 -0
  50. package/src/serve/watch-snapshot-types.ts +45 -3
  51. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +0 -1
@@ -69,6 +69,19 @@ import {
69
69
  } from "./frontmatter";
70
70
  import { buildLineOffsets } from "./position";
71
71
  import { processRecordContainer } from "./record-container";
72
+ import {
73
+ createDirectoryAvailability,
74
+ createSourceContentReader,
75
+ findUnprovenAvailabilityPrefix,
76
+ isSourceAvailabilitySkip,
77
+ isUnprovenAbsenceCode,
78
+ memoizeDirectoryAvailability,
79
+ relPathUnderAnyPrefix,
80
+ resolveSourceAvailability,
81
+ type DirectoryAvailabilityPort,
82
+ type SourceContentReaderPort,
83
+ type SourceReadFailure,
84
+ } from "./source-availability";
72
85
  import { getExcludedRanges } from "./strip";
73
86
  import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
74
87
  import { defaultWalker } from "./walker";
@@ -649,17 +662,32 @@ export class SyncService {
649
662
  private readonly chunker: ChunkerPort;
650
663
  private readonly mimeDetector: MimeDetector;
651
664
  private readonly pipeline: ConversionPipeline;
665
+ private readonly sourceReaderFactory: (
666
+ mode: "any" | "local"
667
+ ) => SourceContentReaderPort;
668
+ private readonly directoryAvailabilityFactory: (
669
+ mode: "any" | "local"
670
+ ) => DirectoryAvailabilityPort;
652
671
 
653
672
  constructor(
654
673
  walker?: WalkerPort,
655
674
  chunker?: ChunkerPort,
656
675
  mimeDetector?: MimeDetector,
657
- pipeline?: ConversionPipeline
676
+ pipeline?: ConversionPipeline,
677
+ sourceReaderFactory?: (mode: "any" | "local") => SourceContentReaderPort,
678
+ directoryAvailabilityFactory?: (
679
+ mode: "any" | "local"
680
+ ) => DirectoryAvailabilityPort
658
681
  ) {
659
682
  this.walker = walker ?? defaultWalker;
660
683
  this.chunker = chunker ?? defaultChunker;
661
684
  this.mimeDetector = mimeDetector ?? getDefaultMimeDetector();
662
685
  this.pipeline = pipeline ?? getDefaultPipeline();
686
+ this.sourceReaderFactory =
687
+ sourceReaderFactory ?? ((mode) => createSourceContentReader(mode));
688
+ this.directoryAvailabilityFactory =
689
+ directoryAvailabilityFactory ??
690
+ ((mode) => createDirectoryAvailability(mode));
663
691
  }
664
692
 
665
693
  private async selectRecordAdapter(
@@ -766,6 +794,28 @@ export class SyncService {
766
794
  const contentTypeRulesFingerprint =
767
795
  options.contentTypeRulesFingerprint ??
768
796
  fingerprintContentTypeMetadataRules(contentTypeRules);
797
+
798
+ // 2. Local mode performs one guarded content-boundary read. `any` keeps
799
+ // the legacy record-stream and sniff/read paths byte-for-byte unchanged.
800
+ // No availability syscall is added during discovery.
801
+ const availabilityMode = resolveSourceAvailability(collection, options);
802
+ let guardedBytes: Uint8Array | undefined;
803
+ if (availabilityMode === "local") {
804
+ const sourceRead = await this.sourceReaderFactory("local").readAll(
805
+ entry.absPath,
806
+ sourceSize
807
+ );
808
+ if (!sourceRead.ok) {
809
+ return await this.finishSourceAvailabilityFailure(
810
+ collection,
811
+ entry,
812
+ store,
813
+ sourceRead
814
+ );
815
+ }
816
+ guardedBytes = sourceRead.bytes;
817
+ }
818
+
769
819
  if (recordAdapter) {
770
820
  return await processRecordContainer({
771
821
  adapter: recordAdapter,
@@ -782,13 +832,16 @@ export class SyncService {
782
832
  sourceCtime,
783
833
  sourceMtime,
784
834
  sourceSize,
835
+ sourceBytes: guardedBytes,
785
836
  store,
786
837
  });
787
838
  }
788
839
 
789
- const sniffBytes = new Uint8Array(
790
- await Bun.file(entry.absPath).slice(0, 512).arrayBuffer()
791
- );
840
+ const sniffBytes = guardedBytes
841
+ ? guardedBytes.subarray(0, Math.min(512, guardedBytes.byteLength))
842
+ : new Uint8Array(
843
+ await Bun.file(entry.absPath).slice(0, 512).arrayBuffer()
844
+ );
792
845
  const mime = this.mimeDetector.detect(entry.relPath, sniffBytes);
793
846
 
794
847
  const priorRecordDocuments = mustOk(
@@ -797,10 +850,9 @@ export class SyncService {
797
850
  { collection: collection.name, relPath: entry.relPath }
798
851
  );
799
852
 
800
- // 2. Read byte-oriented files only after record-adapter selection.
801
- const bytes = await Bun.file(entry.absPath).bytes();
853
+ const bytes = guardedBytes ?? (await Bun.file(entry.absPath).bytes());
802
854
 
803
- // 3. Compute sourceHash
855
+ // 3. Compute sourceHash from source bytes. Local mode cannot reopen.
804
856
  const hasher = new Bun.CryptoHasher("sha256");
805
857
  hasher.update(bytes);
806
858
  const sourceHash = hasher.digest("hex");
@@ -1152,6 +1204,38 @@ export class SyncService {
1152
1204
  }
1153
1205
  }
1154
1206
 
1207
+ /**
1208
+ * Translate guarded-read failures into skip (cloud placeholder/partial) or
1209
+ * fail-closed error outcomes. Never surfaces materialization refusal as a
1210
+ * conversion error.
1211
+ */
1212
+ private async finishSourceAvailabilityFailure(
1213
+ collection: Collection,
1214
+ entry: WalkEntry,
1215
+ store: StorePort,
1216
+ failure: SourceReadFailure
1217
+ ): Promise<FileSyncResult> {
1218
+ const status = isSourceAvailabilitySkip(failure.code) ? "skipped" : "error";
1219
+ await store
1220
+ .recordError({
1221
+ collection: collection.name,
1222
+ relPath: entry.relPath,
1223
+ code: failure.code,
1224
+ message: failure.message,
1225
+ details:
1226
+ failure.errno === undefined || failure.errno === null
1227
+ ? undefined
1228
+ : { errno: failure.errno },
1229
+ })
1230
+ .catch(() => undefined);
1231
+ return {
1232
+ relPath: entry.relPath,
1233
+ status,
1234
+ errorCode: failure.code,
1235
+ errorMessage: failure.message,
1236
+ };
1237
+ }
1238
+
1155
1239
  private async readPreviousStructure(
1156
1240
  store: StorePort,
1157
1241
  existing: DocumentRow | null
@@ -1251,6 +1335,10 @@ export class SyncService {
1251
1335
  const results: FileSyncResult[] = [];
1252
1336
  const projectionSourceIds = new Set<number>();
1253
1337
  let markedInactive = 0;
1338
+ const availabilityMode = resolveSourceAvailability(collection, syncOptions);
1339
+ const directoryAvailability = memoizeDirectoryAvailability(
1340
+ this.directoryAvailabilityFactory(availabilityMode)
1341
+ );
1254
1342
 
1255
1343
  for (const relPath of relPaths) {
1256
1344
  const recordDocumentsResult = await store.listRecordDocuments(
@@ -1316,6 +1404,33 @@ export class SyncService {
1316
1404
  continue;
1317
1405
  }
1318
1406
 
1407
+ // Direct syncPaths must not bypass directory-availability guards.
1408
+ const unprovenPrefix = await findUnprovenAvailabilityPrefix(
1409
+ collection.path,
1410
+ relPath,
1411
+ directoryAvailability
1412
+ );
1413
+ if (unprovenPrefix) {
1414
+ const status = isSourceAvailabilitySkip(unprovenPrefix.code)
1415
+ ? "skipped"
1416
+ : "error";
1417
+ await store
1418
+ .recordError({
1419
+ collection: collection.name,
1420
+ relPath: unprovenPrefix.relPath || relPath,
1421
+ code: unprovenPrefix.code,
1422
+ message: unprovenPrefix.message,
1423
+ })
1424
+ .catch(() => undefined);
1425
+ results.push({
1426
+ relPath,
1427
+ status,
1428
+ errorCode: unprovenPrefix.code,
1429
+ errorMessage: unprovenPrefix.message,
1430
+ });
1431
+ continue;
1432
+ }
1433
+
1319
1434
  const absPath = join(collection.path, relPath);
1320
1435
  let stats: Awaited<ReturnType<typeof stat>>;
1321
1436
  try {
@@ -1881,23 +1996,44 @@ export class SyncService {
1881
1996
  await gitPull(collection.path);
1882
1997
  }
1883
1998
 
1884
- // 2. Walk collection
1999
+ // 2. Walk collection (local mode refuses dataless/unproven directory descent)
1885
2000
  const maxBytes = options.limits?.maxBytes ?? DEFAULT_LIMITS.maxBytes;
1886
- const walkConfig = collectionToWalkConfig(collection, maxBytes);
2001
+ const availabilityMode = resolveSourceAvailability(collection, syncOptions);
2002
+ const directoryAvailability = memoizeDirectoryAvailability(
2003
+ this.directoryAvailabilityFactory(availabilityMode)
2004
+ );
2005
+ const walkConfig = {
2006
+ ...collectionToWalkConfig(collection, maxBytes, syncOptions),
2007
+ sourceAvailability: availabilityMode,
2008
+ directoryAvailability,
2009
+ };
1887
2010
  const { entries, skipped } = await this.walker.walk(walkConfig);
1888
2011
 
1889
2012
  // Track seen paths for marking inactive
1890
2013
  // Only include TOO_LARGE files (they exist but are unprocessable)
1891
2014
  // EXCLUDED files should NOT be in seenPaths - if config changes to exclude
1892
2015
  // a previously-included file, that doc SHOULD be marked inactive
2016
+ // Unproven prefixes (dataless / availability-unknown dirs) preserve
2017
+ // previously indexed descendants — never treat them as proven deletions.
1893
2018
  const seenPaths = new Set<string>();
2019
+ const unprovenPrefixes: string[] = [];
1894
2020
  for (const skip of skipped) {
1895
2021
  if (skip.reason === "TOO_LARGE") {
1896
2022
  seenPaths.add(skip.relPath);
1897
2023
  }
2024
+ if (skip.unprovenPrefix || isUnprovenAbsenceCode(skip.reason)) {
2025
+ unprovenPrefixes.push(skip.relPath);
2026
+ }
1898
2027
  }
1899
2028
 
1900
- // 3. Record TOO_LARGE errors and track in seenPaths
2029
+ let added = 0;
2030
+ let updated = 0;
2031
+ let unchanged = 0;
2032
+ let errored = 0;
2033
+ let dynamicSkipped = 0;
2034
+ const fileResults: FileSyncResult[] = [];
2035
+
2036
+ // 3. Record TOO_LARGE / availability-prefix outcomes for receipts
1901
2037
  for (const skip of skipped) {
1902
2038
  if (skip.reason === "TOO_LARGE") {
1903
2039
  const recordResult = await store.recordError({
@@ -1919,6 +2055,35 @@ export class SyncService {
1919
2055
  code: "TOO_LARGE",
1920
2056
  message: `File size ${skip.size} exceeds limit ${maxBytes}`,
1921
2057
  });
2058
+ continue;
2059
+ }
2060
+ if (skip.unprovenPrefix || isUnprovenAbsenceCode(skip.reason)) {
2061
+ const message =
2062
+ skip.message ??
2063
+ `Directory availability refused descent (${skip.reason})`;
2064
+ await store
2065
+ .recordError({
2066
+ collection: collection.name,
2067
+ relPath: skip.relPath,
2068
+ code: skip.reason,
2069
+ message,
2070
+ })
2071
+ .catch(() => undefined);
2072
+ errors.push({
2073
+ relPath: skip.relPath,
2074
+ code: skip.reason,
2075
+ message,
2076
+ });
2077
+ const isSkip = isSourceAvailabilitySkip(skip.reason);
2078
+ fileResults.push({
2079
+ relPath: skip.relPath === "" ? "." : skip.relPath,
2080
+ status: isSkip ? "skipped" : "error",
2081
+ errorCode: skip.reason,
2082
+ errorMessage: message,
2083
+ });
2084
+ if (!isSkip) {
2085
+ errored += 1;
2086
+ }
1922
2087
  }
1923
2088
  }
1924
2089
 
@@ -1927,14 +2092,6 @@ export class SyncService {
1927
2092
  1,
1928
2093
  Math.min(MAX_CONCURRENCY, options.concurrency ?? DEFAULT_CONCURRENCY)
1929
2094
  );
1930
-
1931
- let added = 0;
1932
- let updated = 0;
1933
- let unchanged = 0;
1934
- let errored = 0;
1935
- let dynamicSkipped = 0;
1936
- const fileResults: FileSyncResult[] = [];
1937
-
1938
2095
  if (concurrency === 1) {
1939
2096
  // Sequential processing with batched transactions (Windows perf)
1940
2097
  for (let i = 0; i < entries.length; i += TX_BATCH_SIZE) {
@@ -2072,11 +2229,20 @@ export class SyncService {
2072
2229
  });
2073
2230
  } else {
2074
2231
  const missingPaths = existingDocsResult.value
2075
- .filter(
2076
- (document) =>
2077
- document.active &&
2078
- !seenPaths.has(document.recordSourcePath ?? document.relPath)
2079
- )
2232
+ .filter((document) => {
2233
+ if (!document.active) {
2234
+ return false;
2235
+ }
2236
+ const sourcePath = document.recordSourcePath ?? document.relPath;
2237
+ if (seenPaths.has(sourcePath)) {
2238
+ return false;
2239
+ }
2240
+ // Absence under an unenumerated/unproven prefix is not proven deletion.
2241
+ if (relPathUnderAnyPrefix(sourcePath, unprovenPrefixes)) {
2242
+ return false;
2243
+ }
2244
+ return true;
2245
+ })
2080
2246
  .map((d) => d.relPath);
2081
2247
 
2082
2248
  if (missingPaths.length > 0) {
@@ -2109,6 +2275,13 @@ export class SyncService {
2109
2275
  errors.push(...(await this.projectTypedEdges(store, syncOptions)));
2110
2276
  }
2111
2277
 
2278
+ const walkerSkippedCount = skipped.filter(
2279
+ (skip) =>
2280
+ skip.reason === "TOO_LARGE" ||
2281
+ skip.reason === "EXCLUDED" ||
2282
+ isSourceAvailabilitySkip(skip.reason)
2283
+ ).length;
2284
+
2112
2285
  return {
2113
2286
  collection: collection.name,
2114
2287
  filesProcessed: entries.length + inventoryErrored,
@@ -2116,7 +2289,7 @@ export class SyncService {
2116
2289
  filesUpdated: updated,
2117
2290
  filesUnchanged: unchanged,
2118
2291
  filesErrored: errored + inventoryErrored,
2119
- filesSkipped: skipped.length + dynamicSkipped,
2292
+ filesSkipped: walkerSkippedCount + dynamicSkipped,
2120
2293
  filesMarkedInactive: markedInactive,
2121
2294
  durationMs: Date.now() - startTime,
2122
2295
  files: fileResults,
@@ -12,6 +12,7 @@ import type {
12
12
  RecordAttachmentInventoryItem,
13
13
  } from "../converters/types";
14
14
  import type { EgressLineage } from "../core/egress-provenance";
15
+ import type { DirectoryAvailabilityPort } from "./source-availability/types";
15
16
 
16
17
  // ─────────────────────────────────────────────────────────────────────────────
17
18
  // Walker Types
@@ -45,14 +46,46 @@ export interface WalkConfig {
45
46
  exclude: string[];
46
47
  /** Max file size in bytes (files larger are skipped) */
47
48
  maxBytes: number;
49
+ /**
50
+ * Source availability mode for this walk.
51
+ * `any` (default) keeps Bun.Glob traversal unchanged.
52
+ * `local` refuses descent into unproven/dataless directories.
53
+ */
54
+ sourceAvailability?: "any" | "local";
55
+ /**
56
+ * Optional injectable directory classifier (tests / SyncService wiring).
57
+ * When omitted, FileWalker builds one from `sourceAvailability`.
58
+ */
59
+ directoryAvailability?: DirectoryAvailabilityPort;
48
60
  }
49
61
 
50
- /** Skipped file entry (for error tracking) */
62
+ /** Skip reasons emitted by the walker (and mirrored into sync receipts). */
63
+ export type WalkSkipReason =
64
+ | "TOO_LARGE"
65
+ | "EXCLUDED"
66
+ | "DATALESS_DIRECTORY"
67
+ | "CLOUD_PLACEHOLDER"
68
+ | "CLOUD_PARTIAL"
69
+ | "SOURCE_AVAILABILITY_UNSUPPORTED"
70
+ | "SOURCE_AVAILABILITY_POLICY_FAILED"
71
+ | "SOURCE_AVAILABILITY_UNKNOWN"
72
+ | "PERMISSION"
73
+ | "NOT_FOUND"
74
+ | "NOT_FILE"
75
+ | "IO_ERROR";
76
+
77
+ /** Skipped file or directory-prefix entry (for error tracking / reconciliation) */
51
78
  export interface SkippedEntry {
52
79
  absPath: string;
53
80
  relPath: string;
54
- reason: "TOO_LARGE" | "EXCLUDED";
81
+ reason: WalkSkipReason;
55
82
  size?: number;
83
+ /**
84
+ * When true, absence of descendants under this prefix is unproven —
85
+ * reconciliation must preserve previously indexed sources.
86
+ */
87
+ unprovenPrefix?: boolean;
88
+ message?: string;
56
89
  }
57
90
 
58
91
  /** Walker port interface */
@@ -146,6 +179,11 @@ export interface SyncOptions {
146
179
  contentTypeRulesFingerprint?: string;
147
180
  /** Internal orchestration flag: defer graph projection to an outer sync. */
148
181
  projectTypedEdges?: boolean;
182
+ /**
183
+ * Optional run-level override for source availability (`any` | `local`).
184
+ * Wins over collection config when set. Distinct from egress policy.
185
+ */
186
+ sourceAvailability?: "any" | "local";
149
187
  }
150
188
 
151
189
  export type ContentTypeSource =
@@ -292,9 +330,12 @@ const TRANSCRIPT_EXTENSION_BY_FORMAT = {
292
330
  */
293
331
  export function collectionToWalkConfig(
294
332
  collection: Collection,
295
- maxBytes: number
333
+ maxBytes: number,
334
+ options?: Pick<SyncOptions, "sourceAvailability">
296
335
  ): WalkConfig {
297
336
  const transcriptFormat = collection.recordAdapters?.transcript?.format;
337
+ const sourceAvailability =
338
+ options?.sourceAvailability ?? collection.sourceAvailability ?? undefined;
298
339
  return {
299
340
  root: collection.path,
300
341
  pattern: collection.pattern,
@@ -304,5 +345,6 @@ export function collectionToWalkConfig(
304
345
  : [],
305
346
  exclude: collection.exclude,
306
347
  maxBytes,
348
+ ...(sourceAvailability ? { sourceAvailability } : {}),
307
349
  };
308
350
  }