@gmickel/gno 1.12.2 → 1.12.4

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
@@ -197,7 +197,7 @@ Manage the detached process with `gno daemon --status` and `gno daemon --stop`.
197
197
 
198
198
  ### Install GNO
199
199
 
200
- Requires [Bun](https://bun.sh/) >= 1.0.0.
200
+ Requires [Bun](https://bun.sh/) >= 1.3.0.
201
201
 
202
202
  ```bash
203
203
  bun install -g @gmickel/gno
@@ -161,7 +161,7 @@ gno search "error handling" --json | jq -r '.results[].uri' | xargs gno multi-ge
161
161
  When using GNO through MCP, prefer this retrieval order:
162
162
 
163
163
  1. Check `gno_status` first when freshness, missing vectors, or stale results are plausible.
164
- 2. Use `gno_query` first for normal content questions. It returns snippets plus `uri`, `docid`, and often `line`; pass `graph: true` only when linked context is worth the extra latency.
164
+ 2. Use `gno_query` first for normal content questions. It returns snippets plus `uri`, `docid`, often `line`, and sometimes `context`. Treat `context` as user-configured guidance for interpreting that exact result; cite source content at the returned URI/lines, not the guidance itself. Pass `graph: true` only when linked context is worth the extra latency.
165
165
  3. Use graph/link expansion for relationship context: `gno_graph_query` for typed relationship traversal, `gno_graph_neighbors` for nearby documents, `gno_graph_path` for "how are X and Y connected?", `gno_links`/`gno_backlinks` for one-document link expansion, and `gno_similar` for semantic neighbors. Prefer explicit or typed edges over inferred, ambiguous, or similarity edges when confidence matters.
166
166
  4. Use `gno_query_diagnose` when a known target document should have appeared but did not; it reports BM25/vector/fusion/graph/rerank stage presence and filter state.
167
167
  5. Use `gno_get` with `fromLine`/`lineCount` for targeted reads, or `gno_multi_get` to batch top refs.
@@ -227,7 +227,9 @@ gno graph --from gno://notes/a.md --to gno://notes/b.md
227
227
  ```
228
228
 
229
229
  Non-default index search results may include `?index=<name>` on `gno://` URIs.
230
- Keep that query string when passing the URI to `gno get`.
230
+ Keep that query string when passing the URI to `gno get`, SDK `get()`, MCP
231
+ `gno_get`, or an MCP resource read: it selects the named database. Batch reads
232
+ must contain refs for one index; split mixed-index results before `multi-get`.
231
233
 
232
234
  ## Important: Embedding After Changes
233
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.12.2",
3
+ "version": "1.12.4",
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",
@@ -71,6 +71,7 @@
71
71
  "eval:hybrid:baseline": "bun scripts/hybrid-benchmark.ts --write",
72
72
  "eval:hybrid:delta": "bun scripts/hybrid-benchmark.ts --delta",
73
73
  "bench:ast-chunking": "bun scripts/ast-chunking-benchmark.ts",
74
+ "smoke:serve-shutdown": "bun scripts/serve-shutdown-smoke.ts",
74
75
  "bench:code-embeddings": "bun scripts/code-embedding-benchmark.ts",
75
76
  "bench:code-embeddings:write": "bun scripts/code-embedding-benchmark.ts --write",
76
77
  "bench:general-embeddings": "bun scripts/general-embedding-benchmark.ts",
@@ -0,0 +1,285 @@
1
+ import type { ContextRow, StorePort } from "../store/types";
2
+
3
+ import { parseUri } from "../app/constants";
4
+
5
+ const CARRIAGE_RETURN_PATTERN = /\r\n?/g;
6
+ const BYTE_ORDER_MARK_PATTERN = /^\uFEFF/u;
7
+
8
+ export interface ContextDocumentIdentity {
9
+ collection: string;
10
+ relPath: string;
11
+ }
12
+
13
+ export interface ContextProvenance {
14
+ scopeType: ContextRow["scopeType"];
15
+ scopeKey: string;
16
+ normalizedScopeKey: string;
17
+ text: string;
18
+ syncedAt: string;
19
+ }
20
+
21
+ export interface ResolvedContext {
22
+ /** Backward-compatible context value exposed on retrieval results. */
23
+ text: string;
24
+ /** Ordered source records used to assemble `text`. */
25
+ provenance: ContextProvenance[];
26
+ }
27
+
28
+ interface NormalizedIdentity {
29
+ collection: string;
30
+ relPath: string;
31
+ }
32
+
33
+ interface MatchingContext extends ContextProvenance {
34
+ depth: number;
35
+ }
36
+
37
+ interface ContextSnapshot {
38
+ generation: number;
39
+ contexts: ContextRow[];
40
+ }
41
+
42
+ function normalizeRelativePath(path: string): string | null {
43
+ if (path.includes("\0")) {
44
+ return null;
45
+ }
46
+
47
+ const normalizedSeparators = path.replaceAll("\\", "/");
48
+ if (normalizedSeparators.startsWith("/")) {
49
+ return null;
50
+ }
51
+
52
+ const segments: string[] = [];
53
+ for (const segment of normalizedSeparators.split("/")) {
54
+ if (!segment || segment === ".") {
55
+ continue;
56
+ }
57
+ if (segment === "..") {
58
+ return null;
59
+ }
60
+ segments.push(segment);
61
+ }
62
+ return segments.join("/");
63
+ }
64
+
65
+ function normalizeIdentity(
66
+ identity: ContextDocumentIdentity
67
+ ): NormalizedIdentity | null {
68
+ const collection = identity.collection.trim();
69
+ const relPath = normalizeRelativePath(identity.relPath);
70
+ if (!collection || collection.includes("/") || relPath === null) {
71
+ return null;
72
+ }
73
+ return { collection, relPath };
74
+ }
75
+
76
+ function normalizeText(text: string): string {
77
+ return text
78
+ .replace(BYTE_ORDER_MARK_PATTERN, "")
79
+ .replace(CARRIAGE_RETURN_PATTERN, "\n")
80
+ .normalize("NFC")
81
+ .trim();
82
+ }
83
+
84
+ function byteKey(text: string): string {
85
+ return [...new TextEncoder().encode(text)].join(",");
86
+ }
87
+
88
+ function matchesPathPrefix(relPath: string, prefix: string): boolean {
89
+ return (
90
+ prefix === "" || relPath === prefix || relPath.startsWith(`${prefix}/`)
91
+ );
92
+ }
93
+
94
+ function normalizeContext(
95
+ context: ContextRow,
96
+ identity: NormalizedIdentity
97
+ ): MatchingContext | null {
98
+ const text = normalizeText(context.text);
99
+ if (!text) {
100
+ return null;
101
+ }
102
+
103
+ if (context.scopeType === "global") {
104
+ if (context.scopeKey !== "/") {
105
+ return null;
106
+ }
107
+ return {
108
+ ...context,
109
+ normalizedScopeKey: "/",
110
+ text,
111
+ depth: 0,
112
+ };
113
+ }
114
+
115
+ if (context.scopeType === "collection") {
116
+ const collection = context.scopeKey.endsWith(":")
117
+ ? context.scopeKey.slice(0, -1)
118
+ : "";
119
+ if (!collection || collection !== identity.collection) {
120
+ return null;
121
+ }
122
+ return {
123
+ ...context,
124
+ normalizedScopeKey: `${collection}:`,
125
+ text,
126
+ depth: 0,
127
+ };
128
+ }
129
+
130
+ const parsed = parseUri(context.scopeKey);
131
+ if (!parsed || parsed.collection !== identity.collection) {
132
+ return null;
133
+ }
134
+ const prefix = normalizeRelativePath(parsed.path);
135
+ if (prefix === null || !matchesPathPrefix(identity.relPath, prefix)) {
136
+ return null;
137
+ }
138
+
139
+ return {
140
+ ...context,
141
+ normalizedScopeKey: `gno://${parsed.collection}/${prefix}`,
142
+ text,
143
+ depth: prefix ? prefix.split("/").length : 0,
144
+ };
145
+ }
146
+
147
+ function compareMatchingContexts(
148
+ left: MatchingContext,
149
+ right: MatchingContext
150
+ ): number {
151
+ const typeOrder = { global: 0, collection: 1, prefix: 2 } as const;
152
+ const typeDifference = typeOrder[left.scopeType] - typeOrder[right.scopeType];
153
+ if (typeDifference !== 0) {
154
+ return typeDifference;
155
+ }
156
+ if (left.depth !== right.depth) {
157
+ return left.depth - right.depth;
158
+ }
159
+ const scopeDifference = left.normalizedScopeKey.localeCompare(
160
+ right.normalizedScopeKey
161
+ );
162
+ if (scopeDifference !== 0) {
163
+ return scopeDifference;
164
+ }
165
+ const sourceDifference = left.scopeKey.localeCompare(right.scopeKey);
166
+ return sourceDifference !== 0
167
+ ? sourceDifference
168
+ : byteKey(left.text).localeCompare(byteKey(right.text));
169
+ }
170
+
171
+ /** Resolve a context snapshot against one canonical collection-relative identity. */
172
+ export function resolveContextSnapshot(
173
+ contexts: ContextRow[],
174
+ identity: ContextDocumentIdentity
175
+ ): ResolvedContext | undefined {
176
+ const normalizedIdentity = normalizeIdentity(identity);
177
+ if (!normalizedIdentity) {
178
+ return;
179
+ }
180
+
181
+ const matching = contexts
182
+ .map((context) => normalizeContext(context, normalizedIdentity))
183
+ .filter((context): context is MatchingContext => context !== null)
184
+ .sort(compareMatchingContexts);
185
+
186
+ const seenRecords = new Set<string>();
187
+ const seenTexts = new Set<string>();
188
+ const provenance: ContextProvenance[] = [];
189
+ const joinedTexts: string[] = [];
190
+
191
+ for (const context of matching) {
192
+ const textKey = byteKey(context.text);
193
+ const recordKey = `${context.scopeType}\0${context.normalizedScopeKey}\0${textKey}`;
194
+ if (seenRecords.has(recordKey)) {
195
+ continue;
196
+ }
197
+ seenRecords.add(recordKey);
198
+ provenance.push({
199
+ scopeType: context.scopeType,
200
+ scopeKey: context.scopeKey,
201
+ normalizedScopeKey: context.normalizedScopeKey,
202
+ text: context.text,
203
+ syncedAt: context.syncedAt,
204
+ });
205
+ if (!seenTexts.has(textKey)) {
206
+ seenTexts.add(textKey);
207
+ joinedTexts.push(context.text);
208
+ }
209
+ }
210
+
211
+ if (provenance.length === 0) {
212
+ return;
213
+ }
214
+ return { text: joinedTexts.join("\n\n"), provenance };
215
+ }
216
+
217
+ export function contextIdentityFromUri(
218
+ uri: string
219
+ ): ContextDocumentIdentity | null {
220
+ const parsed = parseUri(uri);
221
+ if (!parsed) {
222
+ return null;
223
+ }
224
+ const identity = normalizeIdentity({
225
+ collection: parsed.collection,
226
+ relPath: parsed.path,
227
+ });
228
+ return identity ? { ...identity } : null;
229
+ }
230
+
231
+ /**
232
+ * Request-local resolver backed by one store snapshot per context generation.
233
+ * Failed context reads degrade to no context and are retried without retaining
234
+ * the previous generation, so retrieval never receives stale guidance.
235
+ */
236
+ export class ContextResolver {
237
+ private snapshot?: ContextSnapshot;
238
+
239
+ constructor(private readonly store: StorePort) {}
240
+
241
+ async resolve(
242
+ identity: ContextDocumentIdentity
243
+ ): Promise<ResolvedContext | undefined> {
244
+ const [resolved] = await this.resolveMany([identity]);
245
+ return resolved;
246
+ }
247
+
248
+ async resolveUri(uri: string): Promise<ResolvedContext | undefined> {
249
+ const identity = contextIdentityFromUri(uri);
250
+ return identity ? this.resolve(identity) : undefined;
251
+ }
252
+
253
+ async resolveMany(
254
+ identities: ContextDocumentIdentity[]
255
+ ): Promise<Array<ResolvedContext | undefined>> {
256
+ if (identities.length === 0) {
257
+ return [];
258
+ }
259
+ const contexts = await this.loadCurrentContexts();
260
+ return identities.map((identity) =>
261
+ resolveContextSnapshot(contexts, identity)
262
+ );
263
+ }
264
+
265
+ private async loadCurrentContexts(): Promise<ContextRow[]> {
266
+ for (let attempt = 0; attempt < 2; attempt += 1) {
267
+ const generation = this.store.getContextGeneration();
268
+ if (this.snapshot?.generation === generation) {
269
+ return this.snapshot.contexts;
270
+ }
271
+
272
+ this.snapshot = undefined;
273
+ const contextsResult = await this.store.getContexts();
274
+ if (!contextsResult.ok) {
275
+ return [];
276
+ }
277
+
278
+ if (this.store.getContextGeneration() === generation) {
279
+ this.snapshot = { generation, contexts: contextsResult.value };
280
+ return this.snapshot.contexts;
281
+ }
282
+ }
283
+ return [];
284
+ }
285
+ }
@@ -0,0 +1,68 @@
1
+ import { DEFAULT_INDEX_NAME, parseUri } from "../app/constants";
2
+ import { parseRef } from "./ref-parser";
3
+
4
+ export interface EffectiveIndexResolution {
5
+ indexName?: string;
6
+ }
7
+
8
+ function normalizeIndexName(indexName?: string): string {
9
+ const normalized = indexName?.trim();
10
+ return normalized || DEFAULT_INDEX_NAME;
11
+ }
12
+
13
+ export function indexesMatch(left?: string, right?: string): boolean {
14
+ return normalizeIndexName(left) === normalizeIndexName(right);
15
+ }
16
+
17
+ export function getExplicitRefIndex(ref: string): string | undefined {
18
+ const parsed = parseRef(ref);
19
+ if ("error" in parsed || parsed.type !== "uri") {
20
+ return;
21
+ }
22
+ return parseUri(parsed.value)?.indexName;
23
+ }
24
+
25
+ export function resolveEffectiveIndex(
26
+ refs: string[],
27
+ activeIndexName?: string
28
+ ):
29
+ | { ok: true; value: EffectiveIndexResolution }
30
+ | { ok: false; error: string } {
31
+ const explicitIndexes = new Set<string>();
32
+ let hasUnindexedRef = false;
33
+
34
+ for (const ref of refs) {
35
+ const explicitIndex = getExplicitRefIndex(ref);
36
+ if (explicitIndex) {
37
+ explicitIndexes.add(explicitIndex);
38
+ } else {
39
+ hasUnindexedRef = true;
40
+ }
41
+ }
42
+
43
+ if (explicitIndexes.size > 1) {
44
+ return {
45
+ ok: false,
46
+ error: `References cannot mix explicit indexes: ${[...explicitIndexes]
47
+ .sort()
48
+ .join(", ")}`,
49
+ };
50
+ }
51
+
52
+ const explicitIndex = [...explicitIndexes][0];
53
+ if (
54
+ explicitIndex &&
55
+ hasUnindexedRef &&
56
+ !indexesMatch(explicitIndex, activeIndexName)
57
+ ) {
58
+ return {
59
+ ok: false,
60
+ error: `References cannot mix indexed refs (${explicitIndex}) with unindexed refs while the active index is ${normalizeIndexName(activeIndexName)}`,
61
+ };
62
+ }
63
+
64
+ return {
65
+ ok: true,
66
+ value: { indexName: explicitIndex ?? activeIndexName },
67
+ };
68
+ }
@@ -141,5 +141,10 @@ export function splitRefs(refs: string[]): string[] {
141
141
  * Check if a ref contains glob characters.
142
142
  */
143
143
  export function isGlobPattern(ref: string): boolean {
144
- return GLOB_PATTERN.test(ref);
144
+ const queryIndex = ref.indexOf("?");
145
+ const globCandidate =
146
+ ref.startsWith("gno://") && queryIndex >= 0
147
+ ? ref.slice(0, queryIndex)
148
+ : ref;
149
+ return GLOB_PATTERN.test(globCandidate);
145
150
  }
package/src/index.ts CHANGED
@@ -20,8 +20,17 @@ async function cleanupAndExit(code: number): Promise<never> {
20
20
  process.exit(code);
21
21
  }
22
22
 
23
- // SIGINT handler for graceful shutdown
23
+ let interruptExitCode: 0 | 130 = 0;
24
+
25
+ // Long-running commands install their own SIGINT handler and must finish their
26
+ // resource teardown before this bootstrap exits. Short-lived commands have no
27
+ // owner, so retain the immediate interrupt behavior for them.
24
28
  process.on("SIGINT", () => {
29
+ if (process.listenerCount("SIGINT") > 1) {
30
+ return;
31
+ }
32
+
33
+ interruptExitCode = 130;
25
34
  process.stderr.write("\nInterrupted\n");
26
35
  cleanupAndExit(130).catch(() => {
27
36
  // Ignore cleanup errors on exit
@@ -30,7 +39,7 @@ process.on("SIGINT", () => {
30
39
 
31
40
  // Run CLI and exit
32
41
  runCli(process.argv)
33
- .then((code) => cleanupAndExit(code))
42
+ .then((code) => cleanupAndExit(interruptExitCode || code))
34
43
  .catch((err) => {
35
44
  process.stderr.write(
36
45
  `Fatal error: ${err instanceof Error ? err.message : String(err)}\n`