@gmickel/gno 1.29.4 → 1.29.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Search finds. GNO proves.**
4
4
 
5
- A local knowledge engine for your notes, code, PDFs, and Office docs that hands agents evidence with exact line spans and content hashes, and stops when the evidence stops.
5
+ A local knowledge engine for your notes, code, PDFs, and Office docs. Hybrid search, a browsable workspace with graph and editor, CLI, SDK, REST API, and MCP for ten AI clients — plus retrieval that can show its work.
6
6
 
7
7
  [![npm](./assets/badges/npm.svg)](https://www.npmjs.com/package/@gmickel/gno)
8
8
  [![MIT License](./assets/badges/license.svg)](./LICENSE)
@@ -18,6 +18,12 @@ gno setup ~/notes --name notes # returns only after retrieval actually works
18
18
  gno mcp install --target cursor # or claude-code, claude-desktop, zed, ...
19
19
  ```
20
20
 
21
+ ## What you get
22
+
23
+ BM25 and vector retrieval fused and reranked over Markdown, code, PDFs, Office files, and exported mail, calendar and transcripts. A web workspace with cross-collection browse, a knowledge graph, a markdown editor and provenance-carrying capture. A fast CLI, a TypeScript SDK, a REST API, a headless daemon, and one-command install into ten agent clients. Optional hosted publishing when a slice needs a URL.
24
+
25
+ That is the table stakes. Here is the part that is harder to find elsewhere.
26
+
21
27
  ## Why not just another local RAG tool
22
28
 
23
29
  Most retrieval tools return a ranked list and leave the rest to optimism. Four things here are different, and each one is measurable rather than adjectival:
@@ -101,7 +107,7 @@ gno daemon --detach # headless indexing + resident MCP gateway
101
107
 
102
108
  <!-- public-truth:current-version -->
103
109
 
104
- > Current release: **v1.29.4** — see [CHANGELOG.md](./CHANGELOG.md)
110
+ > Current release: **v1.29.6** — see [CHANGELOG.md](./CHANGELOG.md)
105
111
 
106
112
  <!-- /public-truth -->
107
113
 
@@ -0,0 +1 @@
1
+ c1fc560d5f77022b3123d36c0f488b0e662883b0b67e6e40fe7476674d70e085 gno-browser-clipper-v1.29.6.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.29.4"
24
+ "version": "1.29.6"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.29.4",
3
+ "version": "1.29.6",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -31,4 +31,4 @@ export type {
31
31
  } from "./types";
32
32
  export { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
33
33
  // Walker
34
- export { defaultWalker, FileWalker } from "./walker";
34
+ export { defaultWalker, FileWalker, matchesWalkPath } from "./walker";
@@ -174,6 +174,50 @@ function matchesInclude(
174
174
  });
175
175
  }
176
176
 
177
+ type PathEligibilityConfig = Pick<
178
+ WalkConfig,
179
+ "additionalDefaultExtensions" | "exclude" | "include" | "pattern"
180
+ >;
181
+
182
+ /**
183
+ * Check collection-relative path eligibility without touching the filesystem.
184
+ * This intentionally remains usable for deleted paths so incremental sync can
185
+ * mark previously indexed documents inactive.
186
+ */
187
+ export function matchesWalkPath(
188
+ relPath: string,
189
+ config: PathEligibilityConfig
190
+ ): boolean {
191
+ const normalizedPath = relPath.replaceAll("\\", "/");
192
+ if (
193
+ isAbsolute(normalizedPath) ||
194
+ DANGEROUS_PATTERN_REGEX.test(normalizedPath) ||
195
+ isRecordVirtualPath(normalizedPath)
196
+ ) {
197
+ return false;
198
+ }
199
+
200
+ let matchesPattern = false;
201
+ try {
202
+ matchesPattern = new Bun.Glob(config.pattern).match(normalizedPath);
203
+ } catch {
204
+ return false;
205
+ }
206
+ if (!matchesPattern) {
207
+ return false;
208
+ }
209
+
210
+ if (matchesCollectionExclusion(normalizedPath, config.exclude)) {
211
+ return false;
212
+ }
213
+
214
+ return matchesInclude(
215
+ normalizedPath,
216
+ config.include,
217
+ config.additionalDefaultExtensions ?? []
218
+ );
219
+ }
220
+
177
221
  /**
178
222
  * File walker implementation using Bun.Glob.
179
223
  *
@@ -228,29 +272,7 @@ export class FileWalker implements WalkerPort {
228
272
  }
229
273
  const { absPath, relPath } = safePath;
230
274
 
231
- if (isRecordVirtualPath(relPath)) {
232
- skipped.push({ absPath, relPath, reason: "EXCLUDED" });
233
- continue;
234
- }
235
-
236
- // Check exclude patterns
237
- if (matchesCollectionExclusion(relPath, config.exclude)) {
238
- skipped.push({
239
- absPath,
240
- relPath,
241
- reason: "EXCLUDED",
242
- });
243
- continue;
244
- }
245
-
246
- // Check include extensions
247
- if (
248
- !matchesInclude(
249
- relPath,
250
- config.include,
251
- config.additionalDefaultExtensions ?? []
252
- )
253
- ) {
275
+ if (!matchesWalkPath(relPath, config)) {
254
276
  skipped.push({
255
277
  absPath,
256
278
  relPath,
@@ -7,7 +7,11 @@ import type { SqliteAdapter } from "../store/sqlite/adapter";
7
7
  import type { DocumentEvent, DocumentEventBus } from "./doc-events";
8
8
  import type { EmbedScheduler } from "./embed-scheduler";
9
9
 
10
- import { defaultSyncService } from "../ingestion";
10
+ import {
11
+ collectionToWalkConfig,
12
+ defaultSyncService,
13
+ matchesWalkPath,
14
+ } from "../ingestion";
11
15
 
12
16
  export interface CollectionWatchState {
13
17
  expectedCollections: string[];
@@ -45,6 +49,41 @@ interface CollectionWatchServiceOptions {
45
49
  watchFactory?: typeof watch;
46
50
  }
47
51
 
52
+ function watcherCollectionFingerprint(
53
+ collection: Collection,
54
+ syncOptions: SyncOptions
55
+ ): string {
56
+ return JSON.stringify({
57
+ path: normalize(collection.path),
58
+ pattern: collection.pattern,
59
+ include: collection.include,
60
+ exclude: collection.exclude,
61
+ languageHint: collection.languageHint ?? null,
62
+ recordAdapters: collection.recordAdapters ?? null,
63
+ limits: syncOptions.limits ?? null,
64
+ concurrency: syncOptions.concurrency ?? null,
65
+ contentTypeRules: syncOptions.contentTypeRules ?? null,
66
+ contentTypeRulesFingerprint:
67
+ syncOptions.contentTypeRulesFingerprint ?? null,
68
+ projectTypedEdges: syncOptions.projectTypedEdges ?? null,
69
+ });
70
+ }
71
+
72
+ function changedPaths(
73
+ result: CollectionSyncResult,
74
+ fallbackPaths: string[] = []
75
+ ): string[] {
76
+ if (result.files) {
77
+ return result.files
78
+ .filter((file) => file.status === "added" || file.status === "updated")
79
+ .map((file) => file.relPath);
80
+ }
81
+ return result.filesAdded + result.filesUpdated + result.filesMarkedInactive >
82
+ 0
83
+ ? fallbackPaths
84
+ : [];
85
+ }
86
+
48
87
  export class CollectionWatchService {
49
88
  #collections: Collection[];
50
89
  readonly #store: SqliteAdapter;
@@ -53,12 +92,18 @@ export class CollectionWatchService {
53
92
  readonly #callbacks: CollectionWatchCallbacks | null;
54
93
  #syncOptions: SyncOptions;
55
94
  readonly #watchers = new Map<string, FSWatcher>();
95
+ readonly #watchRoots = new Map<string, string>();
96
+ readonly #collectionGenerations = new Map<string, number>();
97
+ readonly #collectionFingerprints = new Map<string, string>();
56
98
  readonly #pendingByCollection = new Map<string, Set<string>>();
57
99
  readonly #timers = new Map<string, ReturnType<typeof setTimeout>>();
58
100
  readonly #syncing = new Set<string>();
101
+ readonly #inFlightSyncs = new Set<Promise<void>>();
59
102
  readonly #suppressedPaths = new Map<string, number>();
60
103
  readonly #watchFactory: typeof watch;
61
104
  readonly #failedCollections = new Map<string, string>();
105
+ #nextCollectionGeneration = 0;
106
+ #disposed = false;
62
107
  #lastEventAt: string | null = null;
63
108
  #lastSyncAt: string | null = null;
64
109
 
@@ -73,6 +118,9 @@ export class CollectionWatchService {
73
118
  }
74
119
 
75
120
  start(): void {
121
+ if (this.#disposed) {
122
+ return;
123
+ }
76
124
  this.updateCollections(this.#collections);
77
125
  }
78
126
 
@@ -80,16 +128,28 @@ export class CollectionWatchService {
80
128
  collections: Collection[],
81
129
  syncOptions?: SyncOptions
82
130
  ): void {
83
- this.#collections = collections;
131
+ if (this.#disposed) {
132
+ return;
133
+ }
84
134
  if (syncOptions) {
85
135
  this.#syncOptions = syncOptions;
86
136
  }
87
- const nextNames = new Set(collections.map((collection) => collection.name));
137
+ const nextByName = new Map(
138
+ collections.map((collection) => [collection.name, collection])
139
+ );
88
140
 
89
141
  for (const [collectionName, watcher] of this.#watchers) {
90
- if (!nextNames.has(collectionName)) {
142
+ const nextCollection = nextByName.get(collectionName);
143
+ const nextRoot = nextCollection
144
+ ? normalize(nextCollection.path)
145
+ : undefined;
146
+ if (
147
+ nextRoot === undefined ||
148
+ nextRoot !== this.#watchRoots.get(collectionName)
149
+ ) {
91
150
  watcher.close();
92
151
  this.#watchers.delete(collectionName);
152
+ this.#watchRoots.delete(collectionName);
93
153
  this.#failedCollections.delete(collectionName);
94
154
  this.#pendingByCollection.delete(collectionName);
95
155
  const timer = this.#timers.get(collectionName);
@@ -97,7 +157,31 @@ export class CollectionWatchService {
97
157
  clearTimeout(timer);
98
158
  this.#timers.delete(collectionName);
99
159
  }
100
- this.#syncing.delete(collectionName);
160
+ }
161
+ }
162
+
163
+ for (const collectionName of this.#collectionFingerprints.keys()) {
164
+ if (!nextByName.has(collectionName)) {
165
+ this.#collectionFingerprints.delete(collectionName);
166
+ this.#collectionGenerations.set(
167
+ collectionName,
168
+ ++this.#nextCollectionGeneration
169
+ );
170
+ }
171
+ }
172
+
173
+ this.#collections = collections;
174
+ for (const collection of collections) {
175
+ const fingerprint = watcherCollectionFingerprint(
176
+ collection,
177
+ this.#syncOptions
178
+ );
179
+ if (this.#collectionFingerprints.get(collection.name) !== fingerprint) {
180
+ this.#collectionFingerprints.set(collection.name, fingerprint);
181
+ this.#collectionGenerations.set(
182
+ collection.name,
183
+ ++this.#nextCollectionGeneration
184
+ );
101
185
  }
102
186
  }
103
187
 
@@ -106,13 +190,27 @@ export class CollectionWatchService {
106
190
  continue;
107
191
  }
108
192
  try {
193
+ const watchedRoot = normalize(collection.path);
109
194
  const watcher = this.#watchFactory(
110
195
  collection.path,
111
196
  { recursive: true },
112
197
  (_eventType, filename) => {
113
- if (!filename) return;
198
+ if (this.#disposed || !filename) return;
114
199
  const relPath = filename.toString().replaceAll("\\", "/");
115
- const fullPath = normalize(join(collection.path, relPath));
200
+ const currentCollection = this.#collections.find(
201
+ (entry) => entry.name === collection.name
202
+ );
203
+ if (
204
+ !currentCollection ||
205
+ normalize(currentCollection.path) !== watchedRoot ||
206
+ !matchesWalkPath(
207
+ relPath,
208
+ collectionToWalkConfig(currentCollection, 0)
209
+ )
210
+ ) {
211
+ return;
212
+ }
213
+ const fullPath = normalize(join(watchedRoot, relPath));
116
214
  const suppressedUntil = this.#suppressedPaths.get(fullPath);
117
215
  if (suppressedUntil && suppressedUntil > Date.now()) {
118
216
  return;
@@ -122,6 +220,7 @@ export class CollectionWatchService {
122
220
  }
123
221
  );
124
222
  this.#watchers.set(collection.name, watcher);
223
+ this.#watchRoots.set(collection.name, watchedRoot);
125
224
  this.#failedCollections.delete(collection.name);
126
225
  } catch (error) {
127
226
  this.#failedCollections.set(
@@ -136,7 +235,11 @@ export class CollectionWatchService {
136
235
  this.#suppressedPaths.set(normalize(absPath), Date.now() + ms);
137
236
  }
138
237
 
139
- dispose(): void {
238
+ async dispose(): Promise<void> {
239
+ if (this.#disposed) {
240
+ return;
241
+ }
242
+ this.#disposed = true;
140
243
  for (const timer of this.#timers.values()) {
141
244
  clearTimeout(timer);
142
245
  }
@@ -145,7 +248,12 @@ export class CollectionWatchService {
145
248
  }
146
249
  this.#timers.clear();
147
250
  this.#watchers.clear();
251
+ this.#watchRoots.clear();
252
+ this.#collectionGenerations.clear();
253
+ this.#collectionFingerprints.clear();
254
+ this.#collections = [];
148
255
  this.#pendingByCollection.clear();
256
+ await Promise.allSettled(this.#inFlightSyncs);
149
257
  this.#syncing.clear();
150
258
  }
151
259
 
@@ -168,6 +276,9 @@ export class CollectionWatchService {
168
276
  }
169
277
 
170
278
  #queueChange(collectionName: string, relPath: string): void {
279
+ if (this.#disposed) {
280
+ return;
281
+ }
171
282
  const pending =
172
283
  this.#pendingByCollection.get(collectionName) ?? new Set<string>();
173
284
  pending.add(relPath);
@@ -180,12 +291,28 @@ export class CollectionWatchService {
180
291
  this.#timers.set(
181
292
  collectionName,
182
293
  setTimeout(() => {
183
- void this.#flushCollection(collectionName);
294
+ this.#startFlush(collectionName);
184
295
  }, 300)
185
296
  );
186
297
  }
187
298
 
299
+ #startFlush(collectionName: string): void {
300
+ if (this.#disposed) {
301
+ return;
302
+ }
303
+ const sync = this.#flushCollection(collectionName);
304
+ this.#inFlightSyncs.add(sync);
305
+ void sync
306
+ .finally(() => {
307
+ this.#inFlightSyncs.delete(sync);
308
+ })
309
+ .catch(() => undefined);
310
+ }
311
+
188
312
  async #flushCollection(collectionName: string): Promise<void> {
313
+ if (this.#disposed) {
314
+ return;
315
+ }
189
316
  const pending = this.#pendingByCollection.get(collectionName);
190
317
  if (!pending || pending.size === 0) {
191
318
  return;
@@ -202,8 +329,15 @@ export class CollectionWatchService {
202
329
  return;
203
330
  }
204
331
 
205
- const relPaths = [...pending];
332
+ const relPaths = [...pending].filter((relPath) =>
333
+ matchesWalkPath(relPath, collectionToWalkConfig(collection, 0))
334
+ );
335
+ let syncGeneration = this.#collectionGenerations.get(collectionName) ?? 0;
206
336
  this.#pendingByCollection.set(collectionName, new Set<string>());
337
+ if (relPaths.length === 0) {
338
+ this.#notifySettledIfIdle();
339
+ return;
340
+ }
207
341
  this.#syncing.add(collectionName);
208
342
 
209
343
  try {
@@ -211,7 +345,7 @@ export class CollectionWatchService {
211
345
  collection: collection.name,
212
346
  relPaths,
213
347
  });
214
- const result = await defaultSyncService.syncPaths(
348
+ let result = await defaultSyncService.syncPaths(
215
349
  collection,
216
350
  this.#store,
217
351
  relPaths,
@@ -220,13 +354,67 @@ export class CollectionWatchService {
220
354
  runUpdateCmd: false,
221
355
  }
222
356
  );
357
+ if (this.#disposed) {
358
+ return;
359
+ }
223
360
  this.#callbacks?.onSyncComplete?.({
224
361
  collection: collection.name,
225
362
  relPaths,
226
363
  result,
227
364
  });
228
- this.#afterSync(collection, relPaths);
365
+
366
+ let completionCollection = collection;
367
+ let completionPaths = changedPaths(result, relPaths);
368
+ while (true) {
369
+ const currentCollection = this.#collections.find(
370
+ (entry) => entry.name === collectionName
371
+ );
372
+ if (!currentCollection) {
373
+ break;
374
+ }
375
+ const currentGeneration =
376
+ this.#collectionGenerations.get(collectionName) ?? 0;
377
+ if (currentGeneration === syncGeneration) {
378
+ const currentRelPaths =
379
+ normalize(currentCollection.path) ===
380
+ normalize(completionCollection.path)
381
+ ? completionPaths.filter((relPath) =>
382
+ matchesWalkPath(
383
+ relPath,
384
+ collectionToWalkConfig(currentCollection, 0)
385
+ )
386
+ )
387
+ : [];
388
+ if (currentRelPaths.length > 0) {
389
+ this.#afterSync(currentCollection, currentRelPaths);
390
+ }
391
+ break;
392
+ }
393
+
394
+ result = await defaultSyncService.syncCollection(
395
+ currentCollection,
396
+ this.#store,
397
+ {
398
+ ...this.#syncOptions,
399
+ runUpdateCmd: false,
400
+ }
401
+ );
402
+ if (this.#disposed) {
403
+ return;
404
+ }
405
+ completionCollection = currentCollection;
406
+ completionPaths = changedPaths(result);
407
+ syncGeneration = currentGeneration;
408
+ this.#callbacks?.onSyncComplete?.({
409
+ collection: currentCollection.name,
410
+ relPaths: completionPaths,
411
+ result,
412
+ });
413
+ }
229
414
  } catch (error) {
415
+ if (this.#disposed) {
416
+ return;
417
+ }
230
418
  this.#callbacks?.onSyncError?.({
231
419
  collection: collection.name,
232
420
  relPaths,
@@ -235,22 +423,30 @@ export class CollectionWatchService {
235
423
  throw error;
236
424
  } finally {
237
425
  this.#syncing.delete(collectionName);
238
- const remaining = this.#pendingByCollection.get(collectionName);
239
- if (remaining && remaining.size > 0) {
240
- void this.#flushCollection(collectionName);
241
- } else if (
242
- this.#syncing.size === 0 &&
243
- ![...this.#pendingByCollection.values()].some(
244
- (relPaths) => relPaths.size > 0
245
- )
246
- ) {
247
- this.#callbacks?.onSettled?.();
426
+ if (!this.#disposed) {
427
+ const remaining = this.#pendingByCollection.get(collectionName);
428
+ if (remaining && remaining.size > 0) {
429
+ this.#startFlush(collectionName);
430
+ } else {
431
+ this.#notifySettledIfIdle();
432
+ }
248
433
  }
249
434
  }
250
435
  }
251
436
 
437
+ #notifySettledIfIdle(): void {
438
+ if (
439
+ this.#syncing.size === 0 &&
440
+ ![...this.#pendingByCollection.values()].some(
441
+ (relPaths) => relPaths.size > 0
442
+ )
443
+ ) {
444
+ this.#callbacks?.onSettled?.();
445
+ }
446
+ }
447
+
252
448
  #afterSync(collection: Collection, relPaths: string[]): void {
253
- if (relPaths.length === 0) {
449
+ if (this.#disposed || relPaths.length === 0) {
254
450
  return;
255
451
  }
256
452
 
@@ -1 +0,0 @@
1
- b639ecf7ff5ebd7aeacf1bad0fe3fe2f471a60c96bdf047d873b6bf932995161 gno-browser-clipper-v1.29.4.zip