memtrace 0.8.9 → 0.8.11

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.
@@ -0,0 +1,764 @@
1
+ // Memtrace Pi extension entry point.
2
+ //
3
+ // Makes Memtrace a first-class citizen of the Pi coding agent:
4
+ // • spawns `memtrace mcp` over stdio and speaks MCP JSON-RPC itself
5
+ // • registers every Memtrace tool as a NATIVE pi tool (memtrace_<name>)
6
+ // with real Typebox schemas discovered live from the server
7
+ // • ships /memtrace slash commands and a live status footer
8
+ // • injects a concise memtrace-first directive when the skill isn't loaded
9
+ //
10
+ // No build step — pi loads this via jiti. Only Node built-ins at runtime.
11
+
12
+ import { writeFileSync, mkdirSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join, resolve, dirname, sep } from "node:path";
15
+
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { Type } from "typebox";
18
+ import {
19
+ truncateHead,
20
+ formatSize,
21
+ DEFAULT_MAX_BYTES,
22
+ DEFAULT_MAX_LINES,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import type { TextContent, ImageContent } from "@earendil-works/pi-ai";
25
+
26
+ import { MemtraceClient, type McpToolDef, type McpContentBlock } from "./mcp-client.ts";
27
+ import { jsonSchemaToTypebox, TOOL_OVERRIDES, piToolName, memtraceName } from "./schema.ts";
28
+ import { loadConfig, type MemtraceConfig } from "./config.ts";
29
+
30
+ interface State {
31
+ cfg: MemtraceConfig;
32
+ client: MemtraceClient | null;
33
+ connecting: Promise<MemtraceClient> | null;
34
+ installed: boolean | null;
35
+ installedVersion: string | null;
36
+ registered: Set<string>;
37
+ cachedTools: McpToolDef[];
38
+ indexedRepos: Array<{ repo_id: string; path?: string; [k: string]: unknown }>;
39
+ cwdRepoId: string | null;
40
+ toolsEverRegistered: boolean;
41
+ }
42
+
43
+ const NO_TOOLS: McpToolDef[] = [];
44
+
45
+ function createState(cwd: string): State {
46
+ return {
47
+ cfg: loadConfig(cwd),
48
+ client: null,
49
+ connecting: null,
50
+ installed: null,
51
+ installedVersion: null,
52
+ registered: new Set(),
53
+ cachedTools: NO_TOOLS,
54
+ indexedRepos: [],
55
+ cwdRepoId: null,
56
+ toolsEverRegistered: false,
57
+ };
58
+ }
59
+
60
+ let state: State = createState(process.cwd());
61
+
62
+ // ---- helpers --------------------------------------------------------------
63
+
64
+ function cachePath(): string {
65
+ return join(process.env.HOME ?? tmpdir(), ".pi/agent/memtrace-tools-cache.json");
66
+ }
67
+
68
+ function writeToolCache(tools: McpToolDef[]): void {
69
+ try {
70
+ const slim = tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
71
+ mkdirSync(dirname(cachePath()), { recursive: true });
72
+ writeFileSync(cachePath(), JSON.stringify(slim, null, 2));
73
+ } catch {
74
+ /* cache is best-effort */
75
+ }
76
+ }
77
+
78
+ function readToolCache(): McpToolDef[] {
79
+ try {
80
+ // Lazily require to avoid import cost when unused.
81
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
82
+ const { readFileSync } = require("node:fs") as typeof import("node:fs");
83
+ const raw = readFileSync(cachePath(), "utf8");
84
+ const parsed = JSON.parse(raw);
85
+ return Array.isArray(parsed) ? (parsed as McpToolDef[]) : [];
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ function looksLikeUnknownCommand(err: unknown, client: MemtraceClient | null): boolean {
92
+ const hay = `${(err as Error)?.message ?? ""}\n${client?.lastStderr ?? ""}`.toLowerCase();
93
+ return /unrecognized|unknown (sub)?command|no such|not found|invalid|unsubcommand|tip: a similar|usage: memtrace/.test(
94
+ hay,
95
+ );
96
+ }
97
+
98
+ async function checkInstalled(pi: ExtensionAPI): Promise<boolean> {
99
+ if (state.installed !== null) return state.installed;
100
+ try {
101
+ const res = await pi.exec(state.cfg.command, ["--version"], { timeout: 5000 });
102
+ state.installed = res.code === 0;
103
+ state.installedVersion = (res.stdout || res.stderr || "").trim().split("\n")[0] || null;
104
+ } catch {
105
+ state.installed = false;
106
+ state.installedVersion = null;
107
+ }
108
+ return state.installed;
109
+ }
110
+
111
+ /** Ensure the MCP server is connected. Idempotent. Throws if it cannot connect. */
112
+ function ensureConnected(opts: { timeoutMs?: number; force?: boolean } = {}): Promise<MemtraceClient> {
113
+ if (state.connecting) return state.connecting;
114
+ if (state.client && state.client.connected && !opts.force) return Promise.resolve(state.client);
115
+ if (opts.force && state.client) {
116
+ state.client.dispose();
117
+ state.client = null;
118
+ }
119
+
120
+ const connect = async (): Promise<MemtraceClient> => {
121
+ const installed = state.installed ?? (await checkInstalled(getPi()));
122
+ if (!installed) {
123
+ throw new Error(
124
+ `memtrace binary "${state.cfg.command}" not found. Install it with: npm install -g memtrace`,
125
+ );
126
+ }
127
+ const make = (args: string[]) =>
128
+ new MemtraceClient({
129
+ command: state.cfg.command,
130
+ args,
131
+ env: state.cfg.env,
132
+ cwd: state.cfg.cwd,
133
+ requestTimeoutMs: state.cfg.requestTimeoutMs,
134
+ debug: process.env.MEMTRACE_PI_DEBUG === "1",
135
+ });
136
+
137
+ let client = make(state.cfg.args);
138
+ try {
139
+ await raceTimeout(client.start(), state.cfg.startupTimeoutMs, "connect");
140
+ } catch (e) {
141
+ client.dispose();
142
+ // Legacy fallback: a `serve` subcommand never shipped in memtrace, but
143
+ // older docs/configs may still reference it. Retry with the real `mcp`
144
+ // stdio subcommand before giving up.
145
+ if (state.cfg.args.join(" ") === "serve" && looksLikeUnknownCommand(e, client)) {
146
+ client = make(["mcp"]);
147
+ await raceTimeout(client.start(), state.cfg.startupTimeoutMs, "connect");
148
+ } else {
149
+ throw e;
150
+ }
151
+ }
152
+ state.client = client;
153
+ await refreshToolsAndCoverage(client);
154
+ return client;
155
+ };
156
+
157
+ const timeoutMs = opts.timeoutMs ?? state.cfg.requestTimeoutMs;
158
+ state.connecting = raceTimeout(connect(), timeoutMs, "ensureConnected").finally(() => {
159
+ state.connecting = null;
160
+ });
161
+ return state.connecting;
162
+ }
163
+
164
+ async function refreshToolsAndCoverage(client: MemtraceClient): Promise<void> {
165
+ try {
166
+ const tools = await client.listTools();
167
+ registerAllTools(tools);
168
+ writeToolCache(tools);
169
+ } catch (e) {
170
+ debug(`tools/list failed: ${(e as Error).message}`);
171
+ }
172
+ await refreshIndexCoverage();
173
+ }
174
+
175
+ function debug(msg: string): void {
176
+ if (process.env.MEMTRACE_PI_DEBUG === "1") console.error(`[memtrace-pi] ${msg}`);
177
+ }
178
+
179
+ function raceTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
180
+ return new Promise<T>((resolveP, rejectP) => {
181
+ let done = false;
182
+ const t = setTimeout(() => {
183
+ if (!done) {
184
+ done = true;
185
+ rejectP(new Error(`memtrace ${label} timed out after ${ms}ms`));
186
+ }
187
+ }, ms);
188
+ p.then(
189
+ (v) => {
190
+ if (!done) {
191
+ done = true;
192
+ clearTimeout(t);
193
+ resolveP(v);
194
+ }
195
+ },
196
+ (e) => {
197
+ if (!done) {
198
+ done = true;
199
+ clearTimeout(t);
200
+ rejectP(e);
201
+ }
202
+ },
203
+ );
204
+ });
205
+ }
206
+
207
+ // ---- tool registration ----------------------------------------------------
208
+
209
+ function registerAllTools(tools: McpToolDef[]): void {
210
+ const pi = getPi();
211
+ for (const tool of tools) {
212
+ const piName = piToolName(tool.name);
213
+ const override = TOOL_OVERRIDES[tool.name] ?? {};
214
+ const parameters =
215
+ tool.inputSchema && Object.keys(tool.inputSchema).length > 0
216
+ ? jsonSchemaToTypebox(tool.inputSchema)
217
+ : Type.Object({});
218
+
219
+ pi.registerTool({
220
+ name: piName,
221
+ label: override.label ?? `Memtrace: ${tool.name}`,
222
+ description: override.description ?? tool.description ?? `Memtrace ${tool.name} tool.`,
223
+ ...(override.promptSnippet ? { promptSnippet: override.promptSnippet } : {}),
224
+ ...(override.promptGuidelines ? { promptGuidelines: override.promptGuidelines } : {}),
225
+ parameters,
226
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
227
+ return runMemtraceTool(tool.name, (params as Record<string, unknown> | undefined) ?? {}, signal);
228
+ },
229
+ });
230
+ state.registered.add(piName);
231
+ }
232
+ state.toolsEverRegistered = true;
233
+ debug(`registered ${tools.length} memtrace tools`);
234
+ }
235
+
236
+ async function runMemtraceTool(
237
+ name: string,
238
+ args: Record<string, unknown>,
239
+ signal: AbortSignal | undefined,
240
+ ): Promise<{ content: (TextContent | ImageContent)[]; details: Record<string, unknown> }> {
241
+ let client: MemtraceClient;
242
+ try {
243
+ client = await ensureConnected({ timeoutMs: state.cfg.requestTimeoutMs });
244
+ } catch (e) {
245
+ const msg = (e as Error).message;
246
+ return {
247
+ content: [{ type: "text", text: `memtrace is not available: ${msg}` }],
248
+ details: { error: msg, connected: false },
249
+ };
250
+ }
251
+
252
+ if (signal?.aborted) {
253
+ return { content: [{ type: "text", text: "cancelled" }], details: { aborted: true } };
254
+ }
255
+
256
+ let result;
257
+ try {
258
+ result = await client.callTool(name, args);
259
+ } catch (e) {
260
+ // Server died mid-call — try one reconnect before giving up.
261
+ debug(`callTool ${name} failed: ${(e as Error).message}; reconnecting`);
262
+ try {
263
+ state.client?.dispose();
264
+ state.client = null;
265
+ client = await ensureConnected({ timeoutMs: state.cfg.requestTimeoutMs, force: true });
266
+ result = await client.callTool(name, args);
267
+ } catch (e2) {
268
+ const msg = (e2 as Error).message;
269
+ return {
270
+ content: [{ type: "text", text: `memtrace call "${name}" failed: ${msg}` }],
271
+ details: { error: msg, connected: false },
272
+ };
273
+ }
274
+ }
275
+
276
+ const mapped = mapContent(result.content);
277
+ // Surface MCP soft errors to the model via content (do not throw if there is text).
278
+ if (result.isError && mapped.length === 0) {
279
+ throw new Error(`memtrace "${name}" returned an error with no content`);
280
+ }
281
+ return {
282
+ content: mapped,
283
+ details: { isError: !!result.isError, tool: name },
284
+ };
285
+ }
286
+
287
+ function mapContent(blocks: McpContentBlock[]): (TextContent | ImageContent)[] {
288
+ const out: (TextContent | ImageContent)[] = [];
289
+ let textBuf = "";
290
+ const flush = () => {
291
+ if (textBuf) {
292
+ const { text, fullFile } = truncateForContext(textBuf);
293
+ out.push({ type: "text", text });
294
+ if (fullFile) out.push({ type: "text", text: `Full output saved to: ${fullFile}` });
295
+ textBuf = "";
296
+ }
297
+ };
298
+ for (const b of blocks) {
299
+ if (b.type === "text" && typeof b.text === "string") {
300
+ textBuf += (textBuf ? "\n" : "") + b.text;
301
+ } else if (b.type === "image" && typeof b.data === "string") {
302
+ flush();
303
+ out.push({ type: "image", data: b.data, mimeType: b.mimeType ?? "image/png" });
304
+ } else if (b.type === "resource") {
305
+ flush();
306
+ const text = (b as { resource?: { text?: string; uri?: string } }).resource?.text;
307
+ if (text) out.push({ type: "text", text });
308
+ } else {
309
+ flush();
310
+ out.push({ type: "text", text: JSON.stringify(b) });
311
+ }
312
+ }
313
+ flush();
314
+ return out;
315
+ }
316
+
317
+ function truncateForContext(text: string): { text: string; fullFile?: string } {
318
+ const tr = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
319
+ if (!tr.truncated) return { text };
320
+ const fullFile = writeTemp(text);
321
+ return {
322
+ text:
323
+ tr.content +
324
+ `\n\n[Output truncated: ${tr.outputLines}/${tr.totalLines} lines ` +
325
+ `(${formatSize(tr.outputBytes)}/${formatSize(tr.totalBytes)}). Full output: ${fullFile}]`,
326
+ fullFile,
327
+ };
328
+ }
329
+
330
+ function writeTemp(text: string): string {
331
+ const path = join(tmpdir(), `memtrace-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`);
332
+ try {
333
+ writeFileSync(path, text, { mode: 0o600 });
334
+ } catch {
335
+ return "(failed to save full output)";
336
+ }
337
+ return path;
338
+ }
339
+
340
+ // ---- index coverage -------------------------------------------------------
341
+
342
+ async function refreshIndexCoverage(): Promise<void> {
343
+ if (!state.client || !state.client.connected) return;
344
+ try {
345
+ const res = await state.client.callTool("list_indexed_repositories", {});
346
+ const text = (res.content?.[0] as { text?: string } | undefined)?.text ?? "";
347
+ const parsed = tryJson(text);
348
+ if (Array.isArray(parsed)) {
349
+ state.indexedRepos = parsed as State["indexedRepos"];
350
+ }
351
+ state.cwdRepoId = resolveCwdRepo();
352
+ updateStatus(getPi());
353
+ } catch (e) {
354
+ debug(`list_indexed_repositories failed: ${(e as Error).message}`);
355
+ }
356
+ }
357
+
358
+ function tryJson(text: string): unknown {
359
+ try {
360
+ return JSON.parse(text);
361
+ } catch {
362
+ // Some servers wrap JSON in markdown fences; try to extract.
363
+ const m = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
364
+ if (m) {
365
+ try {
366
+ return JSON.parse(m[1]);
367
+ } catch { /* noop */ }
368
+ }
369
+ return null;
370
+ }
371
+ }
372
+
373
+ function resolveCwdRepo(): string | null {
374
+ const cwd = process.cwd();
375
+ let best: { repo_id: string; depth: number } | null = null;
376
+ for (const repo of state.indexedRepos) {
377
+ const rp = repo.path as string | undefined;
378
+ if (!rp) continue;
379
+ const abs = resolve(rp);
380
+ if (cwd === abs || cwd.startsWith(abs + sep)) {
381
+ const depth = abs.split(sep).length;
382
+ if (!best || depth > best.depth) best = { repo_id: repo.repo_id, depth };
383
+ }
384
+ }
385
+ return best?.repo_id ?? null;
386
+ }
387
+
388
+ // ---- status footer --------------------------------------------------------
389
+
390
+ function statusText(): string {
391
+ if (state.installed === false) return `memtrace: not installed — npm i -g ${state.cfg.command}`;
392
+ if (!state.client || !state.client.connected) {
393
+ return state.installed === true ? "memtrace: idle — /memtrace connect" : "memtrace: connecting…";
394
+ }
395
+ const n = state.indexedRepos.length;
396
+ if (n === 0) return "memtrace: connected · 0 repos indexed — /memtrace index";
397
+ if (state.cwdRepoId) return `memtrace: connected · repo "${state.cwdRepoId}" indexed`;
398
+ return `memtrace: connected · ${n} repo${n === 1 ? "" : "s"} (cwd not indexed — /memtrace index)`;
399
+ }
400
+
401
+ function updateStatus(pi: ExtensionAPI): void {
402
+ try {
403
+ pi.getActiveTools; // ensure pi is bound
404
+ // setStatus works in TUI + RPC; no-op in print/json.
405
+ // Access ctx via a session-scoped capture instead.
406
+ } catch { /* noop */ }
407
+ statusCtx?.ui.setStatus("memtrace", statusText());
408
+ }
409
+
410
+ let statusCtx: { ui: { setStatus: (k: string, v: string | undefined) => void } } | null = null;
411
+
412
+ // ---- the pi handle (captured for async work outside handlers) -------------
413
+
414
+ let piHandle: ExtensionAPI | null = null;
415
+ function getPi(): ExtensionAPI {
416
+ if (!piHandle) throw new Error("extension not initialized");
417
+ return piHandle;
418
+ }
419
+
420
+ // ---- commands -------------------------------------------------------------
421
+
422
+ function registerCommands(pi: ExtensionAPI): void {
423
+ pi.registerCommand("memtrace", {
424
+ description: "Memtrace status (connection, indexed repos, cwd coverage)",
425
+ handler: async (_args, ctx) => {
426
+ statusCtx = ctx;
427
+ const lines = buildStatusLines();
428
+ if (ctx.hasUI) ctx.ui.setWidget("memtrace:status", lines);
429
+ ctx.ui.notify(lines[0], state.installed === false ? "warning" : "info");
430
+ },
431
+ });
432
+
433
+ pi.registerCommand("memtrace:connect", {
434
+ description: "Connect (or reconnect) to the Memtrace MCP server and load tools",
435
+ handler: async (_args, ctx) => {
436
+ statusCtx = ctx;
437
+ ctx.ui.notify("Connecting to memtrace…", "info");
438
+ try {
439
+ await ensureConnected({ timeoutMs: state.cfg.requestTimeoutMs, force: true });
440
+ ctx.ui.notify(
441
+ `memtrace connected · ${state.registered.size} tools · ${state.indexedRepos.length} repo(s)`,
442
+ "info",
443
+ );
444
+ } catch (e) {
445
+ ctx.ui.notify(`memtrace connect failed: ${(e as Error).message}`, "error");
446
+ }
447
+ updateStatus(pi);
448
+ },
449
+ });
450
+
451
+ pi.registerCommand("memtrace:disconnect", {
452
+ description: "Disconnect the Memtrace MCP server",
453
+ handler: async (_args, ctx) => {
454
+ state.client?.dispose();
455
+ state.client = null;
456
+ state.connecting = null;
457
+ ctx.ui.notify("memtrace disconnected", "info");
458
+ statusCtx = ctx;
459
+ updateStatus(pi);
460
+ },
461
+ });
462
+
463
+ pi.registerCommand("memtrace:tools", {
464
+ description: "List registered Memtrace tools",
465
+ handler: async (_args, ctx) => {
466
+ const tools = pi.getAllTools().filter((t) => t.name.startsWith("memtrace_"));
467
+ if (tools.length === 0) {
468
+ ctx.ui.notify("No memtrace tools registered. Try /memtrace:connect.", "warning");
469
+ return;
470
+ }
471
+ const lines = tools.map((t) => `• ${t.name} — ${oneLine(t.description)}`).slice(0, 60);
472
+ if (ctx.hasUI) ctx.ui.setWidget("memtrace:tools", [`Memtrace tools (${tools.length}):`, ...lines]);
473
+ ctx.ui.notify(`${tools.length} memtrace tools`, "info");
474
+ },
475
+ });
476
+
477
+ pi.registerCommand("memtrace:index", {
478
+ description: "Index the current directory (or a path) into Memtrace",
479
+ getArgumentCompletions: (prefix: string) => {
480
+ const item = { value: ".", label: ". (current directory)" };
481
+ return item.value.startsWith(prefix) ? [item] : null;
482
+ },
483
+ handler: async (args, ctx) => {
484
+ statusCtx = ctx;
485
+ const target = args.trim() || ctx.cwd || process.cwd();
486
+ const abs = resolve(target);
487
+ await indexFlow(pi, ctx, abs, { incremental: false, clearExisting: false });
488
+ },
489
+ });
490
+
491
+ pi.registerCommand("memtrace:reindex", {
492
+ description: "Incrementally re-index the current repo in Memtrace",
493
+ handler: async (_args, ctx) => {
494
+ statusCtx = ctx;
495
+ const repo = state.cwdRepoId;
496
+ if (!repo) {
497
+ ctx.ui.notify("cwd is not under an indexed repo — use /memtrace:index first", "warning");
498
+ return;
499
+ }
500
+ const abs = process.cwd();
501
+ await indexFlow(pi, ctx, abs, { incremental: true, clearExisting: false });
502
+ },
503
+ });
504
+
505
+ pi.registerCommand("memtrace:setup", {
506
+ description: "Check the memtrace install and show next steps",
507
+ handler: async (_args, ctx) => {
508
+ statusCtx = ctx;
509
+ const installed = await checkInstalled(pi);
510
+ if (!installed) {
511
+ ctx.ui.notify(`memtrace not found. Install: npm install -g memtrace`, "warning");
512
+ if (ctx.hasUI) {
513
+ ctx.ui.setWidget("memtrace:setup", [
514
+ "Memtrace setup",
515
+ "",
516
+ "1) Install the binary: npm install -g memtrace",
517
+ "2) (optional) Start the workspace daemon: memtrace start",
518
+ " (indexer + file watcher + HTTP API on :3030 — `memtrace mcp`",
519
+ " attaches to it automatically, or self-starts an embedded",
520
+ " MemDB sidecar if it isn't running)",
521
+ "3) Reload pi: /reload",
522
+ "",
523
+ "Optional env: MEMTRACE_EMBED_KEY (embeddings API key).",
524
+ ]);
525
+ }
526
+ return;
527
+ }
528
+ ctx.ui.notify(`memtrace ${state.installedVersion ?? "installed"} ✓`, "info");
529
+ if (ctx.hasUI) {
530
+ ctx.ui.setWidget("memtrace:setup", [
531
+ `Memtrace ${state.installedVersion ?? ""} is installed.`,
532
+ "",
533
+ "If tools aren't connecting:",
534
+ " • run: memtrace start (workspace daemon: indexer + watcher + HTTP API)",
535
+ " • then: /memtrace:connect",
536
+ "",
537
+ "Server command in use:",
538
+ ` ${state.cfg.command} ${state.cfg.args.join(" ")}`,
539
+ ]);
540
+ }
541
+ },
542
+ });
543
+ }
544
+
545
+ function oneLine(s: string | undefined): string {
546
+ if (!s) return "";
547
+ return s.replace(/\s+/g, " ").trim().slice(0, 160);
548
+ }
549
+
550
+ function buildStatusLines(): string[] {
551
+ const lines: string[] = [];
552
+ if (state.installed === false) {
553
+ lines.push(`memtrace: NOT installed — run: npm install -g memtrace`);
554
+ return lines;
555
+ }
556
+ lines.push(`memtrace ${state.installedVersion ?? ""}`.trim());
557
+ lines.push(
558
+ state.client?.connected
559
+ ? `connection: connected (${serverInfoString()})`
560
+ : "connection: idle — /memtrace:connect",
561
+ );
562
+ lines.push(`tools registered: ${state.registered.size}`);
563
+ lines.push(`indexed repos: ${state.indexedRepos.length}`);
564
+ for (const r of state.indexedRepos.slice(0, 8)) {
565
+ lines.push(` • ${r.repo_id}${r.path ? ` — ${r.path}` : ""}`);
566
+ }
567
+ lines.push(
568
+ state.cwdRepoId
569
+ ? `cwd coverage: repo "${state.cwdRepoId}"`
570
+ : `cwd coverage: not indexed — /memtrace:index`,
571
+ );
572
+ return lines;
573
+ }
574
+
575
+ function serverInfoString(): string {
576
+ const s = state.client?.server ?? {};
577
+ return [s.name, s.version].filter(Boolean).join(" ") || "memtrace mcp";
578
+ }
579
+
580
+ // ---- indexing flow (used by /memtrace:index and :reindex) -----------------
581
+
582
+ async function indexFlow(
583
+ pi: ExtensionAPI,
584
+ ctx: { ui: { notify: (m: string, l: "info" | "warning" | "error") => void }; cwd: string },
585
+ absPath: string,
586
+ opts: { incremental: boolean; clearExisting: boolean },
587
+ ): Promise<void> {
588
+ let client: MemtraceClient;
589
+ try {
590
+ client = await ensureConnected({ timeoutMs: state.cfg.requestTimeoutMs });
591
+ } catch (e) {
592
+ ctx.ui.notify(`Cannot connect to memtrace: ${(e as Error).message}`, "error");
593
+ return;
594
+ }
595
+
596
+ ctx.ui.notify(`Indexing ${absPath} (incremental=${opts.incremental})…`, "info");
597
+ let job: { job_id?: string };
598
+ try {
599
+ job = (await client.callTool("index_directory", {
600
+ path: absPath,
601
+ incremental: opts.incremental,
602
+ clear_existing: opts.clearExisting,
603
+ })) as { job_id?: string };
604
+ } catch (e) {
605
+ ctx.ui.notify(`index_directory failed: ${(e as Error).message}`, "error");
606
+ return;
607
+ }
608
+
609
+ const jobId = job.job_id ?? (tryJson(((job as unknown as { content?: { text?: string }[] }).content?.[0]?.text) ?? "") as { job_id?: string })?.job_id;
610
+ if (!jobId) {
611
+ // Some servers index synchronously; just refresh coverage.
612
+ await refreshIndexCoverage();
613
+ ctx.ui.notify("Indexing submitted (no job_id returned) — refreshed repo list.", "info");
614
+ return;
615
+ }
616
+
617
+ const start = Date.now();
618
+ for (let i = 0; i < 100; i++) {
619
+ await sleep(2500);
620
+ let st: { status?: string; error?: string };
621
+ try {
622
+ st = (await client.callTool("check_job_status", { job_id: jobId })) as { status?: string; error?: string };
623
+ } catch (e) {
624
+ ctx.ui.notify(`check_job_status failed: ${(e as Error).message}`, "error");
625
+ return;
626
+ }
627
+ const status = st.status ?? "running";
628
+ if (status === "completed") {
629
+ await refreshIndexCoverage();
630
+ const secs = ((Date.now() - start) / 1000).toFixed(1);
631
+ ctx.ui.notify(`Indexing complete in ${secs}s · repo "${state.cwdRepoId ?? "?"}"`, "info");
632
+ return;
633
+ }
634
+ if (status === "failed") {
635
+ ctx.ui.notify(`Indexing failed: ${st.error ?? "unknown error"}`, "error");
636
+ return;
637
+ }
638
+ if (i % 4 === 0) ctx.ui.notify(`Indexing… [${status}] ${((Date.now() - start) / 1000).toFixed(0)}s`, "info");
639
+ }
640
+ ctx.ui.notify(
641
+ `Indexing still running after ~5 min (job_id=${jobId}). It continues server-side — re-check with /memtrace later.`,
642
+ "warning",
643
+ );
644
+ }
645
+
646
+ function sleep(ms: number): Promise<void> {
647
+ return new Promise((r) => setTimeout(r, ms));
648
+ }
649
+
650
+ // ---- the always-on status tool -------------------------------------------
651
+
652
+ function registerStatusTool(pi: ExtensionAPI): void {
653
+ pi.registerTool({
654
+ name: "memtrace_status",
655
+ label: "Memtrace: Status",
656
+ description:
657
+ "Report Memtrace connection state, whether the memtrace binary is installed, indexed repositories, and whether the current working directory is covered. Call this before any other memtrace_* tool if you are unsure whether Memtrace is available.",
658
+ promptSnippet: "Check if Memtrace is connected and the cwd is indexed",
659
+ promptGuidelines: [
660
+ "Call memtrace_status first if you are unsure whether Memtrace is connected or the repo is indexed. It returns repo_id values you need for other memtrace_* tools.",
661
+ ],
662
+ parameters: Type.Object({}),
663
+ async execute() {
664
+ const installed = state.installed ?? (await checkInstalled(pi).catch(() => false));
665
+ const connected = !!state.client?.connected;
666
+ const payload = {
667
+ installed,
668
+ installedVersion: state.installedVersion,
669
+ connected,
670
+ server: state.client?.server ?? null,
671
+ toolsRegistered: state.registered.size,
672
+ indexedRepos: state.indexedRepos.map((r) => ({ repo_id: r.repo_id, path: r.path })),
673
+ cwd: process.cwd(),
674
+ cwdCoveredRepoId: state.cwdRepoId,
675
+ hint: !installed
676
+ ? "Run: npm install -g memtrace"
677
+ : !connected
678
+ ? "Run /memtrace:connect (or ask the user to run `memtrace start` then /memtrace:connect)"
679
+ : !state.cwdRepoId
680
+ ? "Index the current repo with memtrace_index_directory (absolute path), then call memtrace_list_indexed_repositories."
681
+ : "Memtrace is ready — use memtrace_* tools for code discovery, impact, and change history.",
682
+ };
683
+ return {
684
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
685
+ details: payload,
686
+ };
687
+ },
688
+ });
689
+ state.registered.add("memtrace_status");
690
+ }
691
+
692
+ // ---- system-prompt injection ---------------------------------------------
693
+
694
+ const FIRST_DIRECTIVE = [
695
+ "",
696
+ "## Memtrace code-graph tools (memtrace_*)",
697
+ "Memtrace is the memory layer of this codebase: a typed knowledge graph of every symbol, call, community, process, and API, with a time dimension. For the indexed repo:",
698
+ "- Code discovery / 'where is X' / 'how does X work' → memtrace_find_symbol / memtrace_find_code → memtrace_get_symbol_context. Do not grep/glob to locate indexed source.",
699
+ "- 'What breaks if I change X?' → memtrace_get_impact (transitive blast radius), not a reference search.",
700
+ "- 'What changed since <date>?' → memtrace_get_evolution (requires repo_id + from; no `days` param), not git log.",
701
+ "- About to view source Memtrace pointed at → use memtrace_get_source_window or a bounded read (offset/limit), never a whole-file read.",
702
+ "- Call memtrace_status if unsure whether Memtrace is available, and memtrace_list_indexed_repositories to get repo_id values.",
703
+ "Prefer these over read/grep/find for anything inside the indexed repository. See the memtrace-first skill for the full routing rules.",
704
+ ].join("\n");
705
+
706
+ function hasMemtraceFirstSkill(skills: unknown): boolean {
707
+ if (!Array.isArray(skills)) return false;
708
+ return skills.some((s) => {
709
+ const name = (s as { name?: string; path?: string })?.name ?? (s as { path?: string })?.path ?? "";
710
+ return /memtrace[-_]first/i.test(String(name));
711
+ });
712
+ }
713
+
714
+ // ---- entry point ----------------------------------------------------------
715
+
716
+ export default async function memtraceExtension(pi: ExtensionAPI): Promise<void> {
717
+ piHandle = pi;
718
+ state = createState(process.cwd());
719
+
720
+ registerStatusTool(pi);
721
+ registerCommands(pi);
722
+
723
+ // Register tools from the on-disk cache so they appear in the very first
724
+ // prompt even when the server is slow to connect (warm start).
725
+ state.cachedTools = readToolCache();
726
+ if (state.cachedTools.length > 0) {
727
+ registerAllTools(state.cachedTools);
728
+ }
729
+
730
+ pi.on("session_start", async (_event, ctx) => {
731
+ statusCtx = ctx;
732
+ if (state.installed === null) void checkInstalled(pi).then(() => updateStatus(pi));
733
+ updateStatus(pi);
734
+ // Reconnect for this session in the background (non-blocking).
735
+ void ensureConnected({ timeoutMs: state.cfg.startupTimeoutMs })
736
+ .then(() => updateStatus(pi))
737
+ .catch((e) => debug(`startup connect skipped: ${(e as Error).message}`));
738
+ });
739
+
740
+ pi.on("session_shutdown", async () => {
741
+ state.client?.dispose();
742
+ state.client = null;
743
+ state.connecting = null;
744
+ });
745
+
746
+ pi.on("before_agent_start", async (event, _ctx) => {
747
+ if (!state.toolsEverRegistered && state.cachedTools.length === 0) return;
748
+ if (hasMemtraceFirstSkill((event.systemPromptOptions as { skills?: unknown })?.skills)) return;
749
+ return { systemPrompt: event.systemPrompt + "\n" + FIRST_DIRECTIVE };
750
+ });
751
+
752
+ // Eager connect with a short timeout so live tools (and their real schemas)
753
+ // replace the cached ones before the first prompt whenever possible.
754
+ try {
755
+ await raceTimeout(
756
+ ensureConnected({ timeoutMs: state.cfg.startupTimeoutMs }),
757
+ state.cfg.startupTimeoutMs,
758
+ "startup",
759
+ );
760
+ } catch (e) {
761
+ debug(`startup connect deferred: ${(e as Error).message}`);
762
+ }
763
+ updateStatus(pi);
764
+ }