@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 +1 -1
- package/assets/skill/SKILL.md +4 -2
- package/package.json +2 -1
- package/src/core/context-resolver.ts +285 -0
- package/src/core/indexed-reference.ts +68 -0
- package/src/core/ref-parser.ts +6 -1
- package/src/index.ts +11 -2
- package/src/ingestion/sync.ts +182 -15
- package/src/ingestion/types.ts +2 -0
- package/src/mcp/resources/index.ts +71 -47
- package/src/mcp/tools/get.ts +108 -93
- package/src/mcp/tools/index.ts +3 -3
- package/src/mcp/tools/multi-get.ts +116 -99
- package/src/pipeline/answer-prompt.ts +80 -0
- package/src/pipeline/answer.ts +12 -26
- package/src/pipeline/hybrid.ts +2 -0
- package/src/pipeline/result-context.ts +51 -0
- package/src/pipeline/search.ts +5 -1
- package/src/pipeline/vsearch.ts +2 -0
- package/src/sdk/client.ts +56 -31
- package/src/serve/public/components/AIModelSelector.tsx +22 -7
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Dashboard.tsx +1 -1
- package/src/serve/routes/api.ts +26 -1
- package/src/serve/server.ts +11 -2
- package/src/serve/status.ts +24 -0
- package/src/serve/watch-service.ts +2 -1
- package/src/store/sqlite/adapter.ts +106 -49
- package/src/store/sqlite/scoped-index.ts +68 -0
- package/src/store/types.ts +9 -1
package/README.md
CHANGED
package/assets/skill/SKILL.md
CHANGED
|
@@ -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`,
|
|
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.
|
|
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
|
+
}
|
package/src/core/ref-parser.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`
|