@lucas-bur/pix 0.8.0 → 0.9.1

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 (2) hide show
  1. package/dist/index.mjs +249 -108
  2. package/package.json +2 -1
package/dist/index.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { NodeContext, NodeRuntime } from "@effect/platform-node";
4
- import { Clock, Console, Context, Data, Effect, Layer, Option } from "effect";
5
3
  import { Args, CliConfig, Command, Options } from "@effect/cli";
4
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
5
+ import { Clock, Context, Data, Effect, Layer, Option, Ref } from "effect";
6
+ import { styleText } from "node:util";
7
+ import * as clack from "@clack/prompts";
6
8
  import crypto from "node:crypto";
7
9
  import { FileSystem } from "@effect/platform";
8
10
  import { env } from "@huggingface/transformers";
@@ -32,11 +34,124 @@ var GetStatus = class extends Effect.Service()("GetStatus", {
32
34
  return { getStatus };
33
35
  })
34
36
  }) {};
37
+ Data.taggedEnum();
38
+ /** Display context tag — commands use `yield* Display` to produce output */
39
+ var Display = class extends Context.Tag("Display")() {};
40
+ /** Maps severity to the corresponding @clack/prompts log function */
41
+ const severityToClack = {
42
+ info: clack.log.info,
43
+ success: clack.log.success,
44
+ warn: clack.log.warning,
45
+ error: clack.log.error
46
+ };
47
+ /** Styling helpers using node:util styleText (zero-deps, Node 21+) */
48
+ const terminalStyle = {
49
+ status: (message) => styleText("bold", message),
50
+ dim: (message) => styleText("dim", message)
51
+ };
52
+ /** Extract the message text from an UpdateInteractivePayload */
53
+ const payloadText = (p) => typeof p === "string" ? p : p.message;
54
+ /**
55
+ * Compute the delta for a progress bar from the payload + current state. Returns 0 if there is no
56
+ * numeric payload or if the active element is a spinner.
57
+ */
58
+ const computeDelta = (p, state) => {
59
+ if (typeof p === "string") return 0;
60
+ if ("advanceBy" in p && p.advanceBy !== void 0) return Math.max(-state.value, p.advanceBy);
61
+ if ("setTo" in p && p.setTo !== void 0) return Math.max(0, Math.min(state.max, p.setTo)) - state.value;
62
+ if ("setToPercent" in p && p.setToPercent !== void 0) {
63
+ const target = Math.floor(state.max * p.setToPercent / 100);
64
+ return Math.max(-state.value, Math.min(state.max - state.value, target - state.value));
65
+ }
66
+ return 0;
67
+ };
68
+ /**
69
+ * Extracts the "guarded interactive" pattern: skip if already active, otherwise
70
+ * acquire-use-release.
71
+ */
72
+ const withInteractive = (activeRef, acquire, setActive, release, effect) => Ref.get(activeRef).pipe(Effect.flatMap((current) => current !== null ? effect : Effect.acquireUseRelease(acquire.pipe(Effect.tap((h) => Ref.set(activeRef, setActive(h)))), () => effect, (h, exit) => Ref.set(activeRef, null).pipe(Effect.andThen(release(h, exit))))));
73
+ /** Display implementation using @clack/prompts for interactive terminal output */
74
+ const ClackDisplay = { layer: Layer.effect(Display, Effect.gen(function* () {
75
+ const activeRef = yield* Ref.make(null);
76
+ return {
77
+ intro: (title) => Effect.sync(() => clack.intro(styleText("inverse", ` ${title} `))),
78
+ outro: (message) => Effect.sync(() => clack.outro(message)),
79
+ log: (message, severity) => Effect.sync(() => severityToClack[severity](terminalStyle.status(message))),
80
+ note: (content, title) => Effect.sync(() => clack.note(content, title)),
81
+ text: (message) => Effect.sync(() => clack.log.message(message)),
82
+ spinner: (message, effect) => withInteractive(activeRef, Effect.sync(() => {
83
+ const s = clack.spinner();
84
+ s.start(message);
85
+ return s;
86
+ }), (s) => ({
87
+ type: "spinner",
88
+ handle: s
89
+ }), (s, exit) => Effect.sync(() => s.stop(exit._tag === "Success" ? message : `${message} (failed)`)), effect),
90
+ progress: (opts, effect) => withInteractive(activeRef, Effect.sync(() => {
91
+ const bar = clack.progress({
92
+ max: opts.max,
93
+ style: opts.style ?? "heavy",
94
+ size: opts.size ?? 40,
95
+ indicator: opts.indicator ?? "dots"
96
+ });
97
+ bar.start(opts.message);
98
+ return bar;
99
+ }), (bar) => ({
100
+ type: "progress",
101
+ handle: bar,
102
+ value: 0,
103
+ max: opts.max
104
+ }), (bar, exit) => Effect.sync(() => exit._tag === "Success" ? bar.stop(opts.message) : bar.error(opts.message)), effect),
105
+ updateInteractive: (payload) => Ref.get(activeRef).pipe(Effect.flatMap((active) => {
106
+ if (!active) return Effect.void;
107
+ if (active.type === "spinner") return Effect.sync(() => active.handle.message(payloadText(payload)));
108
+ const delta = computeDelta(payload, {
109
+ value: active.value,
110
+ max: active.max
111
+ });
112
+ const newValue = Math.max(0, Math.min(active.max, active.value + delta));
113
+ return Effect.sync(() => {
114
+ active.handle.advance(delta, payloadText(payload));
115
+ }).pipe(Effect.andThen(Ref.update(activeRef, (current) => current && current.type === "progress" ? {
116
+ ...current,
117
+ value: newValue
118
+ } : current)));
119
+ })),
120
+ json: () => Effect.void
121
+ };
122
+ })) };
123
+ /** Display implementation for --json mode — no-ops interactive methods, writes JSON to stdout */
124
+ const JsonDisplay = { layer: Layer.succeed(Display, {
125
+ intro: () => Effect.void,
126
+ outro: () => Effect.void,
127
+ log: () => Effect.void,
128
+ note: () => Effect.void,
129
+ text: () => Effect.void,
130
+ spinner: (_message, effect) => effect,
131
+ progress: (_opts, effect) => effect,
132
+ updateInteractive: () => Effect.void,
133
+ json: (data) => Effect.sync(() => process.stdout.write(`${JSON.stringify(data)}\n`))
134
+ }) };
135
+ //#endregion
136
+ //#region src/domain/config.ts
137
+ var ConfigError = class extends Data.TaggedError("ConfigError") {};
138
+ const DEFAULT_CONFIG = {
139
+ schema: "1",
140
+ chunkLines: 60,
141
+ overlapLines: 10,
142
+ chunkConcurrency: 8,
143
+ files: {},
144
+ embedder: {
145
+ model: "Xenova/all-MiniLM-L6-v2",
146
+ device: "auto",
147
+ dtype: "fp32"
148
+ }
149
+ };
35
150
  //#endregion
36
151
  //#region src/application/index-project.ts
37
152
  /**
38
153
  * Use case: index project files. Pipeline: scan → chunk → embed → store. Depends on ConfigStore,
39
- * Scanner, Chunker, Embedder, VectorStore via Effect tags.
154
+ * Scanner, Chunker, Embedder, VectorStore, Display via Effect tags.
40
155
  */
41
156
  var IndexProject = class extends Effect.Service()("IndexProject", {
42
157
  accessors: true,
@@ -46,7 +161,9 @@ var IndexProject = class extends Effect.Service()("IndexProject", {
46
161
  const chunker = yield* Chunker;
47
162
  const embedder = yield* Embedder;
48
163
  const vectorStore = yield* VectorStore;
164
+ const d = yield* Display;
49
165
  const index = () => Effect.gen(function* () {
166
+ if (!(yield* configStore.configExists())) yield* configStore.writeConfig(DEFAULT_CONFIG);
50
167
  const config = yield* configStore.readConfig();
51
168
  const extensions = Object.keys(config.files).length > 0 ? Object.keys(config.files) : [
52
169
  ".ts",
@@ -54,23 +171,23 @@ var IndexProject = class extends Effect.Service()("IndexProject", {
54
171
  ".js",
55
172
  ".jsx"
56
173
  ];
174
+ yield* d.updateInteractive("Scanning source files...");
57
175
  const scanResult = yield* scanner.scanFiles(extensions);
176
+ yield* d.updateInteractive(`Chunking ${scanResult.files.length} files...`);
58
177
  const allChunks = (yield* Effect.forEach(scanResult.files, (file) => chunker.chunkFile(file), { concurrency: Math.max(1, config.chunkConcurrency ?? 8) })).flat();
59
178
  const totalChunks = allChunks.length;
60
179
  const totalFiles = new Set(allChunks.map((c) => c.file)).size;
61
180
  const totalLines = allChunks.reduce((sum, c) => sum + (c.endLine - c.startLine + 1), 0);
62
- if (totalChunks === 0) {
63
- yield* Effect.logInfo("No chunks to index.");
64
- return {
65
- success: true,
66
- status: {
67
- chunks: 0,
68
- files: 0,
69
- totalLines: 0,
70
- byteSize: 0
71
- }
72
- };
73
- }
181
+ if (totalChunks === 0) return {
182
+ success: true,
183
+ status: {
184
+ chunks: 0,
185
+ files: 0,
186
+ totalLines: 0,
187
+ byteSize: 0
188
+ }
189
+ };
190
+ yield* d.updateInteractive(`Embedding ${totalChunks} chunks...`);
74
191
  const texts = allChunks.map((c) => c.text);
75
192
  const embeddings = yield* embedder.batch(texts);
76
193
  yield* vectorStore.store(allChunks, embeddings);
@@ -89,21 +206,6 @@ var IndexProject = class extends Effect.Service()("IndexProject", {
89
206
  })
90
207
  }) {};
91
208
  //#endregion
92
- //#region src/domain/config.ts
93
- var ConfigError = class extends Data.TaggedError("ConfigError") {};
94
- const DEFAULT_CONFIG = {
95
- schema: "1",
96
- chunkLines: 60,
97
- overlapLines: 10,
98
- chunkConcurrency: 8,
99
- files: {},
100
- embedder: {
101
- model: "Xenova/all-MiniLM-L6-v2",
102
- device: "auto",
103
- dtype: "fp32"
104
- }
105
- };
106
- //#endregion
107
209
  //#region src/application/init-project.ts
108
210
  /**
109
211
  * Use case: initialize a pix project by writing default config. Depends on ConfigStore via Effect
@@ -182,41 +284,41 @@ const formatError = (error) => JSON.stringify({
182
284
  message: messageFromError(error),
183
285
  cause: causeFromError(error)
184
286
  });
185
- /** Log the error as JSON to stdout, then re-fail to preserve non-zero exit code. */
186
- const reportError = (error) => Console.log(formatError(error)).pipe(Effect.flatMap(() => Effect.fail(error)));
287
+ /** Log the error to Display in human + agent format, then re-fail to preserve non-zero exit code. */
288
+ const reportError = (error) => Effect.gen(function* () {
289
+ const d = yield* Display;
290
+ yield* d.log(`${codeFromError(error)}: ${messageFromError(error)}`, "error");
291
+ yield* d.json(JSON.parse(formatError(error)));
292
+ return yield* Effect.fail(error);
293
+ });
187
294
  //#endregion
188
295
  //#region src/commands/index-cmd.ts
189
- const logFlagWarnings = (force, verbose, json) => {
190
- if (json) return Effect.void;
191
- const warnings = [force ? "--force is currently not implemented and only a placeholder." : void 0, verbose ? "--verbose is currently not implemented and only a placeholder." : void 0].filter((msg) => msg !== void 0);
192
- return Effect.forEach(warnings, (msg) => Effect.logInfo(msg), { discard: true });
193
- };
194
- const logHumanOutput = (chunks, files, duration) => Effect.logInfo(`Indexed ${chunks} chunks from ${files} files in ${duration}.`);
195
296
  /** CLI command: pix index [--force] [--verbose] [--json] */
196
297
  const indexCommand = Command.make("index", {
197
298
  force: Options.boolean("force").pipe(Options.withDefault(false)),
198
299
  verbose: Options.boolean("verbose").pipe(Options.withDefault(false)),
199
300
  json: Options.boolean("json").pipe(Options.withDefault(false))
200
- }, ({ force, verbose, json }) => Effect.gen(function* () {
201
- yield* logFlagWarnings(force, verbose, json);
202
- const startTime = Date.now();
203
- const result = yield* IndexProject.index();
204
- const duration = `${((Date.now() - startTime) / 1e3).toFixed(1)}s`;
205
- if (json) return yield* Console.log(JSON.stringify({
301
+ }, ({ force, verbose }) => Effect.gen(function* () {
302
+ const d = yield* Display;
303
+ if (force) yield* d.log("--force is currently not implemented and only a placeholder.", "warn");
304
+ if (verbose) yield* d.log("--verbose is currently not implemented and only a placeholder.", "warn");
305
+ const result = yield* d.spinner("Indexing project...", IndexProject.index());
306
+ yield* d.json({
206
307
  chunks: result.status.chunks,
207
- files: result.status.files,
208
- duration
209
- }));
210
- yield* logHumanOutput(result.status.chunks, result.status.files, duration);
308
+ files: result.status.files
309
+ });
310
+ if (result.status.chunks === 0) yield* d.log("No chunks to index.", "warn");
311
+ else yield* d.log(`Indexed ${result.status.chunks} chunks from ${result.status.files} files.`, "success");
211
312
  }).pipe(Effect.catchAll(reportError)));
212
313
  //#endregion
213
314
  //#region src/commands/init.ts
214
315
  /** CLI command: pix init [--json] */
215
- const initCommand = Command.make("init", { json: Options.boolean("json").pipe(Options.withDefault(false)) }, ({ json }) => Effect.gen(function* () {
216
- const result = yield* InitProject.init();
217
- if (json) return yield* Console.log(JSON.stringify(result, null, 2));
218
- yield* Effect.logInfo("Created .pix/config.json with default settings.");
219
- yield* Effect.logInfo("Reminder: Add `.pix` to your `.gitignore` file to avoid committing the index.");
316
+ const initCommand = Command.make("init", { json: Options.boolean("json").pipe(Options.withDefault(false)) }, () => Effect.gen(function* () {
317
+ const d = yield* Display;
318
+ const result = yield* d.spinner("Initializing...", InitProject.init());
319
+ yield* d.json(result);
320
+ yield* d.log("Created .pix/config.json with default settings.", "success");
321
+ yield* d.note("Add `.pix` to your `.gitignore` file to avoid committing the index.", "Reminder");
220
322
  }).pipe(Effect.catchTags({
221
323
  ConfigError: reportError,
222
324
  DiskFullError: reportError
@@ -257,30 +359,22 @@ const toJsonOutput = (results, ctxLines) => results.map((r) => ({
257
359
  ...ctxLines > 0 && r.contextBefore && { contextBefore: r.contextBefore },
258
360
  ...ctxLines > 0 && r.contextAfter && { contextAfter: r.contextAfter }
259
361
  }));
260
- const renderResults = (results) => Effect.gen(function* () {
261
- if (results.length === 0) {
262
- yield* Effect.logInfo("No results found");
263
- return;
264
- }
265
- for (const result of results) {
266
- yield* Console.log(formatResult(result));
267
- yield* Console.log("---");
268
- }
269
- });
270
362
  /** CLI command: pix query "<text>" [--top N] [--json] [--context-lines N] */
271
363
  const queryCommand = Command.make("query", {
272
364
  queryText: Args.text({ name: "query" }),
273
365
  top: Options.integer("top").pipe(Options.withDefault(DEFAULT_TOP_K), Options.optional),
274
366
  json: Options.boolean("json").pipe(Options.withDefault(false)),
275
367
  contextLines: Options.integer("context-lines").pipe(Options.withDefault(DEFAULT_CONTEXT_LINES), Options.optional)
276
- }, ({ queryText, top, json, contextLines }) => Effect.gen(function* () {
368
+ }, ({ queryText, top, contextLines }) => Effect.gen(function* () {
369
+ const d = yield* Display;
277
370
  const topK = Option.getOrElse(top, () => DEFAULT_TOP_K);
278
371
  const ctxLines = Option.getOrElse(contextLines, () => DEFAULT_CONTEXT_LINES);
279
372
  const clamped = clampTopK(topK);
280
- if (clamped.clamped && !json) yield* Effect.logDebug(`topK clamped from ${topK} to ${clamped.value}`);
281
- const results = yield* QueryProject.queryProject(queryText, clamped.value);
282
- if (json) return yield* Console.log(JSON.stringify(toJsonOutput(results, ctxLines), null, 2));
283
- yield* renderResults(results);
373
+ if (clamped.clamped) yield* d.log(`topK clamped from ${topK} to ${clamped.value}`, "warn");
374
+ const results = yield* d.spinner("Searching...", QueryProject.queryProject(queryText, clamped.value));
375
+ yield* d.json(toJsonOutput(results, ctxLines));
376
+ if (results.length === 0) yield* d.log("No results found", "warn");
377
+ else for (const result of results) yield* d.text(formatResult(result));
284
378
  }).pipe(Effect.catchTags({
285
379
  ModelLoadError: reportError,
286
380
  InferenceError: reportError,
@@ -304,30 +398,26 @@ const formatBytes = (bytes) => {
304
398
  };
305
399
  //#endregion
306
400
  //#region src/commands/reset.ts
307
- const logJsonResult = (result, elapsedMs) => Console.log(JSON.stringify({
308
- status: "ok",
309
- deletedChunks: result.deletedChunks,
310
- deletedVectors: result.deletedVectors,
311
- freedBytes: result.freedBytes,
312
- elapsedMs
313
- }));
314
- const logHumanResult = (result, elapsedMs) => Effect.gen(function* () {
315
- const deletedParts = [result.deletedChunks ? "chunks.jsonl" : null, result.deletedVectors ? "vectors.bin" : null].filter((part) => part !== null);
316
- if (deletedParts.length === 0) {
317
- yield* Effect.logInfo("Nothing to reset.");
318
- return;
319
- }
320
- yield* Effect.logInfo(`Deleted: ${deletedParts.join(", ")}`);
321
- yield* Effect.logInfo(`Freed: ${formatBytes(result.freedBytes)}`);
322
- yield* Effect.logInfo(`Time: ${elapsedMs}ms`);
323
- });
324
401
  /** CLI command: pix reset [--json] */
325
- const resetCommand = Command.make("reset", { json: Options.boolean("json").pipe(Options.withDefault(false)) }, ({ json }) => Effect.gen(function* () {
402
+ const resetCommand = Command.make("reset", { json: Options.boolean("json").pipe(Options.withDefault(false)) }, () => Effect.gen(function* () {
403
+ const d = yield* Display;
326
404
  const start = yield* Clock.currentTimeMillis;
327
- const result = yield* ResetIndex.reset();
405
+ const result = yield* d.spinner("Resetting index...", ResetIndex.reset());
328
406
  const elapsedMs = (yield* Clock.currentTimeMillis) - start;
329
- if (json) return yield* logJsonResult(result, elapsedMs);
330
- yield* logHumanResult(result, elapsedMs);
407
+ yield* d.json({
408
+ status: "ok",
409
+ deletedChunks: result.deletedChunks,
410
+ deletedVectors: result.deletedVectors,
411
+ freedBytes: result.freedBytes,
412
+ elapsedMs
413
+ });
414
+ const deletedParts = [result.deletedChunks ? "chunks.jsonl" : null, result.deletedVectors ? "vectors.bin" : null].filter((part) => part !== null);
415
+ if (deletedParts.length === 0) yield* d.log("Nothing to reset.", "info");
416
+ else {
417
+ yield* d.log(`Deleted: ${deletedParts.join(", ")}`, "success");
418
+ yield* d.log(`Freed: ${formatBytes(result.freedBytes)}`, "info");
419
+ yield* d.log(`Time: ${elapsedMs}ms`, "info");
420
+ }
331
421
  }).pipe(Effect.catchTags({
332
422
  DiskFullError: reportError,
333
423
  StoreError: reportError
@@ -335,22 +425,22 @@ const resetCommand = Command.make("reset", { json: Options.boolean("json").pipe(
335
425
  //#endregion
336
426
  //#region src/commands/status.ts
337
427
  /** CLI command: pix status [--json] */
338
- const statusCommand = Command.make("status", { json: Options.boolean("json").pipe(Options.withDefault(false)) }, ({ json }) => Effect.gen(function* () {
428
+ const statusCommand = Command.make("status", { json: Options.boolean("json").pipe(Options.withDefault(false)) }, () => Effect.gen(function* () {
429
+ const d = yield* Display;
339
430
  const result = yield* GetStatus.getStatus();
340
- if (json) return yield* Console.log(JSON.stringify(result, null, 2));
341
- const lastIndexStr = result.lastIndex > 0 ? new Date(result.lastIndex).toISOString() : "never";
342
- yield* Effect.logInfo(`Indexed: ${result.chunks} chunks across ${result.files} files`);
343
- yield* Effect.logInfo(`Model: ${result.model || "none"}`);
344
- yield* Effect.logInfo(`Total lines: ${result.totalLines.toLocaleString()}`);
345
- yield* Effect.logInfo(`Index size: ${formatBytes(result.byteSize)}`);
346
- yield* Effect.logInfo(`Last indexed: ${lastIndexStr}`);
431
+ yield* d.json(result);
432
+ const lastIndexStr = result.lastIndex > 0 ? new Date(result.lastIndex).toLocaleString() : "never";
433
+ yield* d.log(`Indexed: ${result.chunks} chunks across ${result.files} files`, "info");
434
+ yield* d.log(`Model: ${result.model || "none"}`, "info");
435
+ yield* d.log(`Total lines: ${result.totalLines.toLocaleString()}`, "info");
436
+ yield* d.log(`Index size: ${result.byteSize.toLocaleString()} bytes`, "info");
437
+ yield* d.log(`Last indexed: ${lastIndexStr}`, "info");
347
438
  }).pipe(Effect.catchTags({ StoreError: reportError })));
348
439
  //#endregion
349
440
  //#region src/cli.ts
350
441
  const VERSION = createRequire(import.meta.url)("../package.json").version;
351
442
  const pix = Command.make("pix", {}, () => Effect.gen(function* () {
352
- yield* Effect.logInfo("pix - Lightweight local semantic project indexer");
353
- yield* Effect.logInfo("Use `pix --help` to see available commands.");
443
+ yield* (yield* Display).log(`pix v${VERSION} - Lightweight local semantic project indexer`, "info");
354
444
  })).pipe(Command.withSubcommands([
355
445
  initCommand,
356
446
  statusCommand,
@@ -358,10 +448,50 @@ const pix = Command.make("pix", {}, () => Effect.gen(function* () {
358
448
  queryCommand,
359
449
  resetCommand
360
450
  ]));
361
- const cli = (args) => Command.run(pix, {
362
- name: "pix",
363
- version: VERSION
364
- })(args).pipe(Effect.provide(CliConfig.layer({ showTypes: false })));
451
+ const cli = (args) => {
452
+ const displayLayer = args.some((a) => a === "--json") ? JsonDisplay.layer : ClackDisplay.layer;
453
+ return {
454
+ effect: Command.run(pix, {
455
+ name: "pix",
456
+ version: VERSION
457
+ })(args),
458
+ displayLayer
459
+ };
460
+ };
461
+ //#endregion
462
+ //#region src/display/terminalCleanup.ts
463
+ /**
464
+ * Terminal cleanup for abrupt exits.
465
+ *
466
+ * @clack/prompts' spinner and taskLog call stdin.setRawMode(true) and hide
467
+ * the cursor via escape sequences. When the process is killed by a signal
468
+ * handler that calls process.exit() directly, clack's own cleanup is
469
+ * bypassed and the terminal is left in raw mode with the cursor hidden.
470
+ *
471
+ * Registering a process 'exit' listener that restores these guarantees the
472
+ * terminal is always left in a usable state.
473
+ */
474
+ /** Escape sequence to show the cursor (DECTCEM). */
475
+ const SHOW_CURSOR = "\x1B[?25h";
476
+ /**
477
+ * Creates a synchronous exit handler that restores terminal state. Extracted as a pure function so
478
+ * it can be unit-tested without side effects.
479
+ */
480
+ const makeTerminalCleanupHandler = (stdin, stdout) => () => {
481
+ if (stdin.isTTY && stdin.setRawMode) try {
482
+ stdin.setRawMode(false);
483
+ } catch {}
484
+ if (stdout.isTTY) try {
485
+ stdout.write(SHOW_CURSOR);
486
+ } catch {}
487
+ };
488
+ /**
489
+ * Registers the terminal cleanup handler on process 'exit'. Call once at program startup
490
+ * (index.ts).
491
+ */
492
+ const setupTerminalCleanup = () => {
493
+ process.on("exit", makeTerminalCleanupHandler(process.stdin, process.stdout));
494
+ };
365
495
  //#endregion
366
496
  //#region src/domain/errors.ts
367
497
  /** Config file or directory does not exist. Run pix init first. */
@@ -558,7 +688,13 @@ const createExtractor = (opts) => Effect.tryPromise(async () => {
558
688
  const createExtractorWithFallback = (opts) => {
559
689
  if (opts.device === "cpu") return createExtractor(opts);
560
690
  return createExtractor(opts).pipe(Effect.catchAll((originalError) => Effect.gen(function* () {
561
- yield* Effect.logWarning(`Embedding device "${opts.device}" failed, falling back to "cpu": ${originalError.message}`);
691
+ const d = yield* Display;
692
+ yield* d.log(`GPU (${opts.device}) failed, falling back to CPU...`, "warn");
693
+ yield* d.json({
694
+ event: "embedder_fallback",
695
+ originalDevice: opts.device,
696
+ reason: originalError.message
697
+ });
562
698
  return yield* createExtractor({
563
699
  ...opts,
564
700
  device: "cpu"
@@ -566,7 +702,9 @@ const createExtractorWithFallback = (opts) => {
566
702
  })));
567
703
  };
568
704
  const make$2 = Effect.gen(function* () {
569
- const cfg = yield* resolveEmbedderConfig(yield* ConfigStore);
705
+ const configStore = yield* ConfigStore;
706
+ const d = yield* Display;
707
+ const cfg = yield* resolveEmbedderConfig(configStore);
570
708
  const getExtractor = yield* Effect.cached(createExtractorWithFallback(cfg));
571
709
  const embed = (text) => Effect.gen(function* () {
572
710
  const extractor = yield* getExtractor;
@@ -581,7 +719,7 @@ const make$2 = Effect.gen(function* () {
581
719
  vector: normalize(data),
582
720
  dims: cfg.dims
583
721
  };
584
- });
722
+ }).pipe(Effect.provideService(Display, d));
585
723
  const batch = (texts) => Effect.gen(function* () {
586
724
  const extractor = yield* getExtractor;
587
725
  const results = [];
@@ -605,7 +743,7 @@ const make$2 = Effect.gen(function* () {
605
743
  vector,
606
744
  dims: cfg.dims
607
745
  }));
608
- });
746
+ }).pipe(Effect.provideService(Display, d));
609
747
  return {
610
748
  embed,
611
749
  batch
@@ -892,6 +1030,9 @@ const ChunkerLayer = ChunkerLive.pipe(Layer.provide(ServicesLayer));
892
1030
  const InfraLayer = Layer.mergeAll(ServicesLayer, ChunkerLayer).pipe(Layer.provide(NodeContext.layer));
893
1031
  const UseCaseLayer = Layer.mergeAll(InitProject.Default, GetStatus.Default, QueryProject.Default, IndexProject.Default, ResetIndex.Default);
894
1032
  const AppLayer = Layer.merge(UseCaseLayer.pipe(Layer.provide(InfraLayer)), NodeContext.layer);
895
- cli(process.argv).pipe(Effect.provide(AppLayer), NodeRuntime.runMain({ disableErrorReporting: true }));
1033
+ const { effect, displayLayer } = cli(process.argv);
1034
+ const cliLayer = Layer.mergeAll(displayLayer, CliConfig.layer({ showTypes: false }));
1035
+ setupTerminalCleanup();
1036
+ effect.pipe(Effect.provide(AppLayer.pipe(Layer.provideMerge(cliLayer))), NodeRuntime.runMain({ disableErrorReporting: true }));
896
1037
  //#endregion
897
1038
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucas-bur/pix",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Lightweight local semantic project indexer",
5
5
  "keywords": [
6
6
  "cli",
@@ -52,6 +52,7 @@
52
52
  "pix": ""
53
53
  },
54
54
  "dependencies": {
55
+ "@clack/prompts": "^1.4.0",
55
56
  "@effect/cli": "^0.75.1",
56
57
  "@effect/platform": "^0.96.1",
57
58
  "@effect/platform-node": "^0.106.0",