@memstack/cli 0.7.3 → 0.8.0

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/dist/cli.js CHANGED
@@ -1,58 +1,246 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'util';
3
3
  import { readFile, writeFile } from 'fs/promises';
4
- import { MemStack, AnthropicLLMAdapter, OpenAILLMAdapter, OpenAIEmbeddingAdapter, RedisStorageAdapter, SQLiteStorageAdapter, PostgresStorageAdapter, DiskStorageAdapter, MarkdownStorageAdapter, InMemoryStorageAdapter } from '@memstack/core';
4
+ import { MemStack, HarnessMemory, projectNamespace, defaultRecallNamespaces, AnthropicLLMAdapter, OpenAILLMAdapter, OpenAIEmbeddingAdapter, RedisStorageAdapter, SQLiteStorageAdapter, PostgresStorageAdapter, DiskStorageAdapter, MarkdownStorageAdapter, InMemoryStorageAdapter } from '@memstack/core';
5
+ import { existsSync, mkdirSync, realpathSync, readFileSync, writeFileSync, statSync, chmodSync, renameSync, rmSync, copyFileSync, constants } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join, resolve, delimiter, dirname, basename } from 'path';
8
+ import { execFile, spawn, execFileSync } from 'child_process';
9
+ import { createHash } from 'crypto';
10
+ import { createInterface } from 'readline/promises';
5
11
 
6
- function buildLLMAdapter() {
7
- if (!process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) {
12
+ var STORAGE_TYPES = ["memory", "markdown", "disk", "postgres", "sqlite", "redis"];
13
+ function memstackHome() {
14
+ return process.env.MEMSTACK_HOME ?? join(homedir(), ".memstack");
15
+ }
16
+ function configFilePath(home = memstackHome()) {
17
+ return join(home, "config.json");
18
+ }
19
+ function readConfigFile(path = configFilePath()) {
20
+ if (!existsSync(path)) return void 0;
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(readFileSync(path, "utf8"));
24
+ } catch (error) {
25
+ throw new Error(`${path} is not valid JSON: ${error.message}`);
26
+ }
27
+ return validateConfigFile(parsed, path);
28
+ }
29
+ function writeConfigFile(config, path = configFilePath()) {
30
+ validateConfigFile(config, path);
31
+ const dir = join(path, "..");
32
+ mkdirSync(dir, { recursive: true, mode: 448 });
33
+ chmodSync(dir, 448);
34
+ const tmp = `${path}.${process.pid}.tmp`;
35
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
36
+ `, { mode: 384 });
37
+ chmodSync(tmp, 384);
38
+ renameSync(tmp, path);
39
+ }
40
+ function checkConfigPermissions(path = configFilePath()) {
41
+ if (process.platform === "win32" || !existsSync(path)) return [];
42
+ const warnings = [];
43
+ const fileMode = statSync(path).mode & 511;
44
+ if (fileMode & 63) {
45
+ warnings.push(`${path} is readable by other users (mode ${fileMode.toString(8)}). Run: chmod 600 ${path}`);
46
+ }
47
+ const dir = join(path, "..");
48
+ const dirMode = statSync(dir).mode & 511;
49
+ if (dirMode & 63) {
50
+ warnings.push(`${dir} is accessible by other users (mode ${dirMode.toString(8)}). Run: chmod 700 ${dir}`);
51
+ }
52
+ return warnings;
53
+ }
54
+ function validateConfigFile(value, path) {
55
+ const fail2 = (message) => {
56
+ throw new Error(`Invalid ${path}: ${message}`);
57
+ };
58
+ if (!isObject(value)) fail2("expected a JSON object");
59
+ const config = value;
60
+ if (config.version !== 1) fail2(`unsupported version ${JSON.stringify(config.version)}, expected 1`);
61
+ if (config.llm !== void 0) {
62
+ if (!isObject(config.llm)) fail2("llm must be an object");
63
+ const llm = config.llm;
64
+ if (llm.provider !== "openai-compatible" && llm.provider !== "anthropic") {
65
+ fail2('llm.provider must be "openai-compatible" or "anthropic"');
66
+ }
67
+ if (typeof llm.apiKey !== "string" || !llm.apiKey) fail2("llm.apiKey must be a non-empty string");
68
+ for (const key of ["baseURL", "model"]) {
69
+ if (llm[key] !== void 0 && typeof llm[key] !== "string") fail2(`llm.${key} must be a string`);
70
+ }
71
+ }
72
+ if (config.storage !== void 0) {
73
+ if (!isObject(config.storage)) fail2("storage must be an object");
74
+ const storage = config.storage;
75
+ if (!STORAGE_TYPES.includes(storage.type)) {
76
+ fail2(`storage.type must be one of ${STORAGE_TYPES.join(", ")}`);
77
+ }
78
+ for (const key of ["path", "url"]) {
79
+ if (storage[key] !== void 0 && typeof storage[key] !== "string") fail2(`storage.${key} must be a string`);
80
+ }
81
+ }
82
+ return config;
83
+ }
84
+ function isObject(value) {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value);
86
+ }
87
+ var PIN_FILE = ".memstack.json";
88
+ var PIN_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
89
+ function resolveProject(cwd = process.cwd()) {
90
+ const dir = realpathSync(resolve(cwd));
91
+ const root = git(dir, ["rev-parse", "--show-toplevel"]);
92
+ const base = root ?? dir;
93
+ const pinned = readPin(base);
94
+ const fallbacks = transitionalIds(dir, root);
95
+ if (pinned) return { id: pinned, source: "pin", key: pinned, root: base, previousIds: [] };
96
+ if (!root) return { ...identity("directory", dir), root: dir, previousIds: [] };
97
+ const shallow = git(root, ["rev-parse", "--is-shallow-repository"]) === "true";
98
+ const rootCommit = shallow ? null : firstCommit(root);
99
+ if (rootCommit) return { ...identity("root-commit", rootCommit), root, previousIds: fallbacks };
100
+ const remote = git(root, ["config", "--get", "remote.origin.url"]);
101
+ const normalized = shallow && remote ? normalizeRemote(remote) : null;
102
+ if (normalized) return { ...identity("remote", normalized), root, previousIds: fallbacks };
103
+ const gitId = identity("git", gitRepoKey(root));
104
+ return { ...gitId, root, previousIds: fallbacks.filter((id) => id !== gitId.id) };
105
+ }
106
+ function pinProject(name, cwd = process.cwd()) {
107
+ if (!PIN_PATTERN.test(name)) {
108
+ throw new Error("A pinned project ID uses letters, digits, '.', '_', or '-', starts with a letter or digit, and is at most 64 characters.");
109
+ }
110
+ const dir = realpathSync(resolve(cwd));
111
+ const base = git(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
112
+ const path = join(base, PIN_FILE);
113
+ const existing = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
114
+ writeFileSync(path, `${JSON.stringify({ ...existing, project: name }, null, 2)}
115
+ `);
116
+ return path;
117
+ }
118
+ function normalizeRemote(url) {
119
+ const value = url.trim();
120
+ if (!value) return null;
121
+ let host;
122
+ let path;
123
+ const scp = value.match(/^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/);
124
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
125
+ let parsed;
126
+ try {
127
+ parsed = new URL(value);
128
+ } catch {
129
+ return null;
130
+ }
131
+ if (parsed.protocol === "file:" || !parsed.hostname) return null;
132
+ host = parsed.hostname;
133
+ path = decodeURIComponent(parsed.pathname);
134
+ } else if (scp) {
135
+ host = scp[1];
136
+ path = scp[2];
137
+ } else {
138
+ return null;
139
+ }
140
+ path = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
141
+ if (!path) return null;
142
+ return `${host.toLowerCase()}/${path}`;
143
+ }
144
+ function readPin(base) {
145
+ const path = join(base, PIN_FILE);
146
+ if (!existsSync(path)) return null;
147
+ let project;
148
+ try {
149
+ project = JSON.parse(readFileSync(path, "utf8")).project;
150
+ } catch (error) {
151
+ throw new Error(`${path} is not valid JSON: ${error.message}`);
152
+ }
153
+ if (project === void 0) return null;
154
+ if (typeof project !== "string" || !PIN_PATTERN.test(project)) {
155
+ throw new Error(`${path}: "project" must use letters, digits, '.', '_', or '-' and be at most 64 characters.`);
156
+ }
157
+ return project;
158
+ }
159
+ function firstCommit(root) {
160
+ const out2 = git(root, ["rev-list", "--max-parents=0", "--first-parent", "HEAD"]);
161
+ return out2 ? out2.split("\n").pop().trim() : null;
162
+ }
163
+ function gitRepoKey(root) {
164
+ const commonDir = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
165
+ return commonDir ? dirname(realpathSync(commonDir)) : root;
166
+ }
167
+ function transitionalIds(dir, root) {
168
+ const ids = /* @__PURE__ */ new Set();
169
+ if (root) ids.add(identity("git", gitRepoKey(root)).id);
170
+ ids.add(identity("directory", dir).id);
171
+ return [...ids];
172
+ }
173
+ function identity(source, key) {
174
+ const id = createHash("sha256").update(`${source}:${key}`).digest("hex").slice(0, 16);
175
+ return { id, source, key };
176
+ }
177
+ function git(cwd, args) {
178
+ try {
179
+ const out2 = execFileSync("git", ["-C", cwd, ...args], {
180
+ encoding: "utf8",
181
+ stdio: ["ignore", "pipe", "ignore"],
182
+ timeout: 5e3
183
+ }).trim();
184
+ return out2 || null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+ function isMissingModule(error) {
190
+ const code = error?.code;
191
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
192
+ }
193
+ function buildLLMAdapter(env) {
194
+ if (!env.ANTHROPIC_API_KEY && !env.OPENAI_API_KEY) {
8
195
  throw new Error("At least one of OPENAI_API_KEY or ANTHROPIC_API_KEY must be set");
9
196
  }
10
- if (process.env.ANTHROPIC_API_KEY) {
11
- return new AnthropicLLMAdapter({ apiKey: process.env.ANTHROPIC_API_KEY });
197
+ if (env.ANTHROPIC_API_KEY) {
198
+ return new AnthropicLLMAdapter({ apiKey: env.ANTHROPIC_API_KEY, defaultModel: env.MEMSTACK_LLM_MODEL });
12
199
  }
13
200
  return new OpenAILLMAdapter({
14
- apiKey: process.env.OPENAI_API_KEY ?? "",
15
- baseURL: process.env.MEMSTACK_OPENAI_BASE_URL,
16
- defaultModel: process.env.MEMSTACK_LLM_MODEL
201
+ apiKey: env.OPENAI_API_KEY ?? "",
202
+ baseURL: env.MEMSTACK_OPENAI_BASE_URL,
203
+ defaultModel: env.MEMSTACK_LLM_MODEL
17
204
  });
18
205
  }
19
- function buildEmbeddingAdapter() {
20
- if (process.env.MEMSTACK_OPENAI_BASE_URL) return void 0;
21
- if (process.env.OPENAI_API_KEY) {
22
- return new OpenAIEmbeddingAdapter({ apiKey: process.env.OPENAI_API_KEY });
206
+ function buildEmbeddingAdapter(env) {
207
+ if (env.MEMSTACK_OPENAI_BASE_URL) return void 0;
208
+ if (env.OPENAI_API_KEY) {
209
+ return new OpenAIEmbeddingAdapter({ apiKey: env.OPENAI_API_KEY });
23
210
  }
24
211
  return void 0;
25
212
  }
26
- async function buildStorageAdapter() {
27
- const storage = process.env.MEMSTACK_STORAGE ?? "memory";
213
+ async function buildStorageAdapter(env) {
214
+ const storage = env.MEMSTACK_STORAGE ?? "memory";
28
215
  switch (storage) {
29
216
  case "memory":
30
217
  return new InMemoryStorageAdapter();
31
218
  case "markdown":
32
219
  return new MarkdownStorageAdapter({
33
- dir: process.env.MEMSTACK_DIR ?? "./memories"
220
+ dir: env.MEMSTACK_DIR ?? "./memories"
34
221
  });
35
222
  case "disk":
36
223
  return new DiskStorageAdapter({
37
- storageDir: process.env.MEMSTACK_DIR ?? "./memstack-data"
224
+ storageDir: env.MEMSTACK_DIR ?? "./memstack-data"
38
225
  });
39
226
  case "postgres":
40
- if (!process.env.DATABASE_URL) {
227
+ if (!env.DATABASE_URL) {
41
228
  throw new Error("postgres storage requires DATABASE_URL");
42
229
  }
43
230
  return new PostgresStorageAdapter({
44
- connectionString: process.env.DATABASE_URL
231
+ connectionString: env.DATABASE_URL
45
232
  });
46
233
  case "sqlite": {
47
234
  try {
48
235
  const BetterSqlite3 = await import('better-sqlite3');
49
236
  const db = new BetterSqlite3.default(
50
- process.env.SQLITE_PATH ?? "./memstack.db"
237
+ env.SQLITE_PATH ?? "./memstack.db"
51
238
  );
52
239
  return new SQLiteStorageAdapter({ db });
53
- } catch {
240
+ } catch (error) {
241
+ if (!isMissingModule(error)) throw error;
54
242
  throw new Error(
55
- "SQLite requires better-sqlite3. Install: npm install better-sqlite3"
243
+ "SQLite requires better-sqlite3. Install: npm install better-sqlite3@^11.10.0"
56
244
  );
57
245
  }
58
246
  }
@@ -60,12 +248,13 @@ async function buildStorageAdapter() {
60
248
  try {
61
249
  const Redis = await import('ioredis');
62
250
  const client = new Redis.default(
63
- process.env.REDIS_URL ?? "redis://localhost:6379"
251
+ env.REDIS_URL ?? "redis://localhost:6379"
64
252
  );
65
253
  return new RedisStorageAdapter({ redis: client });
66
- } catch {
254
+ } catch (error) {
255
+ if (!isMissingModule(error)) throw error;
67
256
  throw new Error(
68
- "Redis requires ioredis. Install: npm install ioredis"
257
+ "Redis requires ioredis. Install: npm install ioredis@^5.11.1"
69
258
  );
70
259
  }
71
260
  }
@@ -74,11 +263,39 @@ async function buildStorageAdapter() {
74
263
  }
75
264
  }
76
265
  async function loadConfigFromEnv() {
77
- const llm = buildLLMAdapter();
78
- const embedding = buildEmbeddingAdapter();
79
- const storage = await buildStorageAdapter();
80
- const embedOnStore = process.env.MEMSTACK_EMBED_ON_STORE !== "false";
81
- const defaultActorId = process.env.MEMSTACK_ACTOR ?? "default";
266
+ return buildConfig(process.env);
267
+ }
268
+ async function loadConfig(options = {}) {
269
+ return buildConfig(mergeConfigFile(readConfigFile(options.configPath), process.env));
270
+ }
271
+ function mergeConfigFile(file, env) {
272
+ const merged = { ...env };
273
+ const envHasLLM = LLM_ENV.some((key) => env[key] !== void 0);
274
+ if (file?.llm && !envHasLLM) {
275
+ if (file.llm.provider === "anthropic") merged.ANTHROPIC_API_KEY = file.llm.apiKey;
276
+ else {
277
+ merged.OPENAI_API_KEY = file.llm.apiKey;
278
+ merged.MEMSTACK_OPENAI_BASE_URL = file.llm.baseURL;
279
+ }
280
+ merged.MEMSTACK_LLM_MODEL = file.llm.model;
281
+ }
282
+ if (file?.storage && env.MEMSTACK_STORAGE === void 0) {
283
+ const { type, path, url } = file.storage;
284
+ merged.MEMSTACK_STORAGE = type;
285
+ if (type === "sqlite") merged.SQLITE_PATH = path;
286
+ if (type === "disk" || type === "markdown") merged.MEMSTACK_DIR = path;
287
+ if (type === "postgres") merged.DATABASE_URL = url;
288
+ if (type === "redis") merged.REDIS_URL = url;
289
+ }
290
+ return merged;
291
+ }
292
+ var LLM_ENV = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "MEMSTACK_OPENAI_BASE_URL", "MEMSTACK_LLM_MODEL"];
293
+ async function buildConfig(env) {
294
+ const llm = buildLLMAdapter(env);
295
+ const embedding = buildEmbeddingAdapter(env);
296
+ const storage = await buildStorageAdapter(env);
297
+ const embedOnStore = env.MEMSTACK_EMBED_ON_STORE !== "false";
298
+ const defaultActorId = env.MEMSTACK_ACTOR ?? "default";
82
299
  return {
83
300
  config: { llm, embedding, storage, defaults: { embedOnStore } },
84
301
  defaultActorId
@@ -86,12 +303,935 @@ async function loadConfigFromEnv() {
86
303
  }
87
304
 
88
305
  // src/config.ts
89
- async function loadConfig() {
306
+ async function loadConfig2() {
90
307
  const { config } = await loadConfigFromEnv();
91
308
  return config;
92
309
  }
310
+ var createRunner = (env = process.env) => (command, args) => new Promise((resolve2) => {
311
+ execFile(command, args, { env, timeout: 6e4, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
312
+ if (!error) return resolve2({ code: 0, stdout, stderr });
313
+ const code = error.code === "ENOENT" ? 127 : typeof error.code === "number" ? error.code : 1;
314
+ resolve2({ code, stdout, stderr: stderr || error.message });
315
+ });
316
+ });
317
+ var run = createRunner();
318
+
319
+ // src/harness/types.ts
320
+ var SERVER_NAME = "memstack";
321
+ function sameLaunch(a, b) {
322
+ if (!a || !b) return a === b;
323
+ return a.command === b.command && JSON.stringify(a.args) === JSON.stringify(b.args) && JSON.stringify(a.env ?? {}) === JSON.stringify(b.env ?? {});
324
+ }
325
+
326
+ // src/harness/claude-code.ts
327
+ function claudeCodeAdapter(options = {}) {
328
+ const env = options.env ?? process.env;
329
+ const runner = options.runner ?? createRunner(env);
330
+ const binary = "claude";
331
+ const configPath = join(env.CLAUDE_CONFIG_DIR ?? homedir(), ".claude.json");
332
+ const settingsPath = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, "settings.json") : join(homedir(), ".claude", "settings.json");
333
+ return {
334
+ id: "claude-code",
335
+ displayName: "Claude Code",
336
+ binary,
337
+ // Also on clear and compact, so the memories survive /clear and context compaction.
338
+ sessionHook: { path: settingsPath, matcher: "startup|resume|clear|compact" },
339
+ async inspect() {
340
+ const version = await runner(binary, ["--version"]);
341
+ const state = { installed: version.code === 0, configPath };
342
+ if (state.installed) state.version = version.stdout.trim().split(/\s+/)[0];
343
+ state.entry = readEntry(configPath);
344
+ return state;
345
+ },
346
+ addSteps(launch) {
347
+ const json = JSON.stringify({ type: "stdio", command: launch.command, args: launch.args, ...launch.env ? { env: launch.env } : {} });
348
+ return [{ command: binary, args: ["mcp", "add-json", "-s", "user", SERVER_NAME, json] }];
349
+ },
350
+ removeSteps() {
351
+ return [{ command: binary, args: ["mcp", "remove", "-s", "user", SERVER_NAME] }];
352
+ }
353
+ };
354
+ }
355
+ function readEntry(configPath) {
356
+ if (!existsSync(configPath)) return void 0;
357
+ let config;
358
+ try {
359
+ config = JSON.parse(readFileSync(configPath, "utf8"));
360
+ } catch (error) {
361
+ throw new Error(`Could not read ${configPath}: ${error.message}`);
362
+ }
363
+ const entry = config.mcpServers?.[SERVER_NAME];
364
+ if (!entry || typeof entry.command !== "string") return void 0;
365
+ return {
366
+ command: entry.command,
367
+ args: Array.isArray(entry.args) ? entry.args.map(String) : [],
368
+ ...entry.env && typeof entry.env === "object" ? { env: entry.env } : {}
369
+ };
370
+ }
371
+ function writeFileAtomic(path, content) {
372
+ if (content === null) {
373
+ rmSync(path, { force: true });
374
+ return;
375
+ }
376
+ mkdirSync(dirname(path), { recursive: true });
377
+ const mode = existsSync(path) ? statSync(path).mode & 511 : 420;
378
+ const tmp = `${path}.memstack-${process.pid}.tmp`;
379
+ writeFileSync(tmp, content, { mode });
380
+ renameSync(tmp, path);
381
+ }
382
+ function backupFile(path, backupDir) {
383
+ mkdirSync(backupDir, { recursive: true, mode: 448 });
384
+ const base = join(backupDir, `${basename(path)}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
385
+ for (let n = 0; ; n++) {
386
+ try {
387
+ copyFileSync(path, n === 0 ? base : `${base}-${n}`, constants.COPYFILE_EXCL);
388
+ return;
389
+ } catch (error) {
390
+ if (error.code !== "EEXIST") throw error;
391
+ }
392
+ }
393
+ }
394
+ function snapshot(path) {
395
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
396
+ }
397
+
398
+ // src/harness/instructions.ts
399
+ var BEGIN = "<!-- memstack:begin";
400
+ var END = "<!-- memstack:end -->";
401
+ var BLOCK_PATTERN = /\n?<!-- memstack:begin[^\n]*-->\n[\s\S]*?<!-- memstack:end -->\n?/;
402
+ var CODEX_INSTRUCTIONS = [
403
+ "## Project memory (MemStack)",
404
+ "",
405
+ "When the user asks you to remember something, save it with the memstack `memory_store` tool. Do not only acknowledge it, and do not write it into project files unless the user asks for a file change.",
406
+ "Before answering questions about this project's conventions, decisions, or the user's preferences, check `memory_retrieve`."
407
+ ].join("\n");
408
+ function renderBlock(body, harness) {
409
+ return `${BEGIN} (managed by \`memstack connect ${harness}\`; removed by \`memstack disconnect ${harness}\`) -->
410
+ ${body}
411
+ ${END}
412
+ `;
413
+ }
414
+ function readBlock(path) {
415
+ if (!existsSync(path)) return null;
416
+ const match = readFileSync(path, "utf8").match(BLOCK_PATTERN);
417
+ return match ? match[0].replace(/^\n/, "") : null;
418
+ }
419
+ function upsertBlock(path, block, backupDir) {
420
+ const current = existsSync(path) ? readFileSync(path, "utf8") : null;
421
+ if (current !== null && readBlock(path) === block) return false;
422
+ let next;
423
+ if (current === null || current.trim() === "") next = block;
424
+ else if (BLOCK_PATTERN.test(current)) next = current.replace(BLOCK_PATTERN, (m) => `${m.startsWith("\n") ? "\n" : ""}${block}`);
425
+ else next = `${current}${current.endsWith("\n") ? "" : "\n"}
426
+ ${block}`;
427
+ write(path, next, current, backupDir);
428
+ return true;
429
+ }
430
+ function removeBlock(path, backupDir) {
431
+ if (!existsSync(path)) return false;
432
+ const current = readFileSync(path, "utf8");
433
+ if (!BLOCK_PATTERN.test(current)) return false;
434
+ const next = current.replace(BLOCK_PATTERN, "");
435
+ if (next.trim() === "") {
436
+ backupFile(path, backupDir);
437
+ rmSync(path);
438
+ } else {
439
+ write(path, next, current, backupDir);
440
+ }
441
+ return true;
442
+ }
443
+ function write(path, content, previous, backupDir) {
444
+ if (previous !== null) backupFile(path, backupDir);
445
+ writeFileAtomic(path, content);
446
+ }
447
+
448
+ // src/harness/codex.ts
449
+ function codexAdapter(options = {}) {
450
+ const env = options.env ?? process.env;
451
+ const runner = options.runner ?? createRunner(env);
452
+ const binary = "codex";
453
+ const home = env.CODEX_HOME ?? join(homedir(), ".codex");
454
+ const configPath = join(home, "config.toml");
455
+ return {
456
+ id: "codex",
457
+ displayName: "Codex",
458
+ binary,
459
+ // Codex gives MCP server instructions little weight, so without this it
460
+ // acknowledges "remember that..." instead of calling memory_store.
461
+ instructions: { path: join(home, "AGENTS.md"), body: CODEX_INSTRUCTIONS },
462
+ sessionHook: { path: join(home, "hooks.json"), matcher: "startup|resume" },
463
+ async inspect() {
464
+ const version = await runner(binary, ["--version"]);
465
+ const state = { installed: version.code === 0, configPath };
466
+ if (!state.installed) return state;
467
+ state.version = version.stdout.trim().split(/\s+/).pop();
468
+ const got = await runner(binary, ["mcp", "get", SERVER_NAME, "--json"]);
469
+ if (got.code !== 0) {
470
+ if (/No MCP server named/i.test(got.stderr + got.stdout)) return state;
471
+ throw new Error(`codex mcp get failed: ${(got.stderr || got.stdout).trim()}`);
472
+ }
473
+ const transport = JSON.parse(got.stdout).transport;
474
+ if (transport && typeof transport.command === "string") {
475
+ state.entry = {
476
+ command: transport.command,
477
+ args: Array.isArray(transport.args) ? transport.args.map(String) : [],
478
+ ...transport.env && typeof transport.env === "object" && Object.keys(transport.env).length > 0 ? { env: transport.env } : {}
479
+ };
480
+ }
481
+ return state;
482
+ },
483
+ addSteps(launch) {
484
+ const envFlags = Object.entries(launch.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
485
+ return [{ command: binary, args: ["mcp", "add", SERVER_NAME, ...envFlags, "--", launch.command, ...launch.args] }];
486
+ },
487
+ removeSteps() {
488
+ return [{ command: binary, args: ["mcp", "remove", SERVER_NAME] }];
489
+ }
490
+ };
491
+ }
492
+ var OURS = /\bhook session-start --harness (claude-code|codex)\b/;
493
+ function sessionStartHook(launch, harness) {
494
+ const script = launch.args[0]?.endsWith(".js") ? [launch.args[0]] : [];
495
+ const parts = [launch.command, ...script].map(shellQuote);
496
+ return {
497
+ type: "command",
498
+ command: `${parts.join(" ")} hook session-start --harness ${harness}`,
499
+ timeout: 10,
500
+ statusMessage: "Loading MemStack project memory"
501
+ };
502
+ }
503
+ function readHook(path) {
504
+ const file = read(path);
505
+ for (const group of file?.hooks?.SessionStart ?? []) {
506
+ for (const hook of group.hooks ?? []) {
507
+ if (typeof hook.command === "string" && OURS.test(hook.command)) return hook;
508
+ }
509
+ }
510
+ return null;
511
+ }
512
+ function upsertHook(path, hook, matcher, backupDir) {
513
+ const file = read(path) ?? {};
514
+ const current = readHook(path);
515
+ if (current && JSON.stringify(current) === JSON.stringify(hook) && ourGroups(file).every((g) => g.matcher === matcher)) return false;
516
+ const hooks = file.hooks ??= {};
517
+ hooks.SessionStart = [...withoutOurs(hooks.SessionStart ?? []), { matcher, hooks: [hook] }];
518
+ save(path, file, backupDir);
519
+ return true;
520
+ }
521
+ function removeHook(path, backupDir) {
522
+ const file = read(path);
523
+ if (!file || !readHook(path)) return false;
524
+ const remaining = withoutOurs(file.hooks.SessionStart ?? []);
525
+ if (remaining.length > 0) file.hooks.SessionStart = remaining;
526
+ else delete file.hooks.SessionStart;
527
+ if (Object.keys(file.hooks).length === 0) delete file.hooks;
528
+ save(path, file, backupDir);
529
+ return true;
530
+ }
531
+ function ourGroups(file) {
532
+ return (file.hooks?.SessionStart ?? []).filter((g) => (g.hooks ?? []).some((h) => typeof h.command === "string" && OURS.test(h.command)));
533
+ }
534
+ function withoutOurs(groups) {
535
+ return groups.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !(typeof h.command === "string" && OURS.test(h.command))) })).filter((g, i) => g.hooks.length > 0 || (groups[i].hooks ?? []).length === 0);
536
+ }
537
+ function read(path) {
538
+ if (!existsSync(path)) return null;
539
+ const text = readFileSync(path, "utf8");
540
+ if (!text.trim()) return {};
541
+ try {
542
+ const parsed = JSON.parse(text);
543
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected a JSON object");
544
+ return parsed;
545
+ } catch (error) {
546
+ throw new Error(`${path} is not valid JSON, so MemStack left it unchanged: ${error.message}`);
547
+ }
548
+ }
549
+ function save(path, file, backupDir) {
550
+ const previous = existsSync(path) ? readFileSync(path, "utf8") : null;
551
+ if (previous !== null) backupFile(path, backupDir);
552
+ const indent = previous?.match(/^[ \t]+(?=")/m)?.[0] ?? " ";
553
+ const newline = previous === null || previous.endsWith("\n") ? "\n" : "";
554
+ writeFileAtomic(path, Object.keys(file).length === 0 ? null : `${JSON.stringify(file, null, indent)}${newline}`);
555
+ }
556
+ function shellQuote(value) {
557
+ return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
558
+ }
559
+ var HARNESS_TOOLS = ["memory_store", "memory_retrieve", "memory_get", "memory_delete", "memory_stats"];
560
+ var verifyServer = (launch, options = {}) => new Promise((resolve2) => {
561
+ const child = spawn(launch.command, launch.args, {
562
+ cwd: options.cwd,
563
+ env: { ...options.env ?? process.env, ...launch.env },
564
+ stdio: ["pipe", "pipe", "pipe"]
565
+ });
566
+ let stdout = "";
567
+ let stderr = "";
568
+ let settled = false;
569
+ const pending = /* @__PURE__ */ new Map();
570
+ const finish = (result) => {
571
+ if (settled) return;
572
+ settled = true;
573
+ clearTimeout(timer);
574
+ child.kill();
575
+ resolve2(result);
576
+ };
577
+ const timer = setTimeout(() => finish({ ok: false, error: `No response within 20 s. ${lastLine(stderr)}`.trim() }), 2e4);
578
+ child.on("error", (error) => finish({ ok: false, error: `Could not start ${launch.command}: ${error.message}` }));
579
+ child.on("exit", (code) => finish({ ok: false, error: `Server exited with code ${code}. ${lastLine(stderr)}`.trim() }));
580
+ child.stderr.on("data", (chunk) => stderr += chunk);
581
+ child.stdout.on("data", (chunk) => {
582
+ stdout += chunk;
583
+ let newline;
584
+ while ((newline = stdout.indexOf("\n")) >= 0) {
585
+ const line = stdout.slice(0, newline).trim();
586
+ stdout = stdout.slice(newline + 1);
587
+ if (!line) continue;
588
+ try {
589
+ const message = JSON.parse(line);
590
+ pending.get(message.id)?.(message);
591
+ } catch {
592
+ }
593
+ }
594
+ });
595
+ let nextId = 1;
596
+ const request = (method, params) => new Promise((reply) => {
597
+ const id = nextId++;
598
+ pending.set(id, reply);
599
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
600
+ `);
601
+ });
602
+ void (async () => {
603
+ const init2 = await request("initialize", {
604
+ protocolVersion: "2025-06-18",
605
+ capabilities: {},
606
+ clientInfo: { name: "memstack-connect", version: "1" }
607
+ });
608
+ if (init2.error) return finish({ ok: false, error: init2.error.message });
609
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}
610
+ `);
611
+ const list = await request("tools/list", {});
612
+ const names = (list.result?.tools ?? []).map((t) => t.name);
613
+ const missing = HARNESS_TOOLS.filter((t) => !names.includes(t));
614
+ if (missing.length > 0) return finish({ ok: false, error: `Server is missing harness tools: ${missing.join(", ")}` });
615
+ const stats = await request("tools/call", { name: "memory_stats", arguments: {} });
616
+ const result = stats.result;
617
+ const text = result?.content?.map((c) => c.text).join("\n") ?? stats.error?.message ?? "";
618
+ if (!result || result.isError) return finish({ ok: false, error: text || "memory_stats failed" });
619
+ finish({ ok: true, stats: text });
620
+ })();
621
+ });
622
+ function lastLine(text) {
623
+ return text.trim().split("\n").filter(Boolean).pop() ?? "";
624
+ }
625
+
626
+ // src/harness/connect.ts
627
+ async function connectHarness({
628
+ adapter,
629
+ launch,
630
+ runner = run,
631
+ verify = verifyServer,
632
+ dryRun,
633
+ instructions = true,
634
+ hooks = true,
635
+ backupDir = join(memstackHome(), "backups")
636
+ }) {
637
+ const state = await adapter.inspect();
638
+ if (!state.installed) throw new Error(`${adapter.displayName} is not installed: \`${adapter.binary}\` is not on PATH.`);
639
+ const alreadyConnected = sameLaunch(state.entry, launch);
640
+ const plan = {
641
+ harness: adapter.id,
642
+ previous: state.entry,
643
+ target: launch,
644
+ steps: alreadyConnected ? [] : [...state.entry ? adapter.removeSteps() : [], ...adapter.addSteps(launch)]
645
+ };
646
+ const block = adapter.instructions && instructions ? renderBlock(adapter.instructions.body, adapter.id) : void 0;
647
+ if (adapter.instructions && block) {
648
+ const current = readBlock(adapter.instructions.path);
649
+ plan.instructions = { path: adapter.instructions.path, change: current === null ? "add" : current === block ? "none" : "update" };
650
+ }
651
+ const hook = adapter.sessionHook && hooks ? sessionStartHook(launch, adapter.id) : void 0;
652
+ if (adapter.sessionHook && hook) {
653
+ const current = readHook(adapter.sessionHook.path);
654
+ plan.hook = { path: adapter.sessionHook.path, change: current === null ? "add" : JSON.stringify(current) === JSON.stringify(hook) ? "none" : "update" };
655
+ }
656
+ const verification = await verify(launch);
657
+ if (!verification.ok) {
658
+ throw new Error(`MemStack's MCP server did not start correctly, so ${adapter.displayName} was not changed: ${verification.error}`);
659
+ }
660
+ if (dryRun) return { plan, changed: false, verification };
661
+ const undo = [];
662
+ let changed = false;
663
+ try {
664
+ if (!alreadyConnected) {
665
+ await applyWithRollback(adapter, plan, runner);
666
+ undo.push(() => restore(adapter, plan.previous, runner));
667
+ changed = true;
668
+ }
669
+ if (block && plan.instructions && plan.instructions.change !== "none") {
670
+ const path = plan.instructions.path;
671
+ const before = snapshot(path);
672
+ fileStep(path, () => upsertBlock(path, block, backupDir));
673
+ undo.push(async () => (writeFileAtomic(path, before), true));
674
+ changed = true;
675
+ }
676
+ if (hook && adapter.sessionHook && plan.hook && plan.hook.change !== "none") {
677
+ const path = plan.hook.path;
678
+ const before = snapshot(path);
679
+ const matcher = adapter.sessionHook.matcher;
680
+ fileStep(path, () => upsertHook(path, hook, matcher, backupDir));
681
+ undo.push(async () => (writeFileAtomic(path, before), true));
682
+ changed = true;
683
+ }
684
+ } catch (error) {
685
+ let restored = true;
686
+ for (const step of undo.reverse()) restored = await step().catch(() => false) && restored;
687
+ const message = error instanceof Error ? error.message : String(error);
688
+ if (/previous configuration/.test(message)) throw error;
689
+ throw new Error(`${message} ${restored ? "The previous configuration was restored." : "Restoring the previous configuration failed; run `memstack doctor`."}`);
690
+ }
691
+ return { plan, changed, verification };
692
+ }
693
+ async function disconnectHarness({
694
+ adapter,
695
+ runner = run,
696
+ dryRun,
697
+ backupDir = join(memstackHome(), "backups")
698
+ }) {
699
+ const state = await adapter.inspect();
700
+ if (!state.installed) throw new Error(`${adapter.displayName} is not installed: \`${adapter.binary}\` is not on PATH.`);
701
+ const plan = { harness: adapter.id, previous: state.entry, steps: state.entry ? adapter.removeSteps() : [] };
702
+ if (adapter.instructions) {
703
+ plan.instructions = { path: adapter.instructions.path, change: readBlock(adapter.instructions.path) === null ? "none" : "remove" };
704
+ }
705
+ if (adapter.sessionHook) {
706
+ plan.hook = { path: adapter.sessionHook.path, change: readHook(adapter.sessionHook.path) === null ? "none" : "remove" };
707
+ }
708
+ if (dryRun) return { plan, changed: false };
709
+ let changed = false;
710
+ if (plan.steps.length > 0) {
711
+ await applyWithRollback(adapter, plan, runner);
712
+ changed = true;
713
+ }
714
+ if (plan.instructions?.change === "remove") changed = removeBlock(plan.instructions.path, backupDir) || changed;
715
+ if (plan.hook?.change === "remove") changed = removeHook(plan.hook.path, backupDir) || changed;
716
+ return { plan, changed };
717
+ }
718
+ async function applyWithRollback(adapter, plan, runner) {
719
+ try {
720
+ for (const step of plan.steps) await runStep(step, runner);
721
+ const after = await adapter.inspect();
722
+ if (!sameLaunch(after.entry, plan.target)) {
723
+ throw new Error(`${adapter.displayName} does not show the expected MemStack entry after the change.`);
724
+ }
725
+ } catch (error) {
726
+ const restored = await restore(adapter, plan.previous, runner);
727
+ const message = error instanceof Error ? error.message : String(error);
728
+ throw new Error(`${message} ${restored ? "The previous configuration was restored." : "Restoring the previous configuration failed; run `memstack doctor`."}`);
729
+ }
730
+ }
731
+ async function restore(adapter, previous, runner) {
732
+ try {
733
+ const current = await adapter.inspect();
734
+ if (sameLaunch(current.entry, previous)) return true;
735
+ if (current.entry) for (const step of adapter.removeSteps()) await runStep(step, runner);
736
+ if (previous) for (const step of adapter.addSteps(previous)) await runStep(step, runner);
737
+ return sameLaunch((await adapter.inspect()).entry, previous);
738
+ } catch {
739
+ return false;
740
+ }
741
+ }
742
+ function fileStep(path, apply) {
743
+ try {
744
+ apply();
745
+ } catch (error) {
746
+ throw new Error(`Could not update ${path}: ${error.message}.`);
747
+ }
748
+ }
749
+ async function runStep(step, runner) {
750
+ const result = await runner(step.command, step.args);
751
+ if (result.code !== 0) {
752
+ throw new Error(`\`${step.command} ${step.args.slice(0, 3).join(" ")}\` failed: ${(result.stderr || result.stdout).trim()}`);
753
+ }
754
+ }
755
+ function describeStep(step) {
756
+ return [step.command, ...step.args.map((a) => /^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`)].join(" ");
757
+ }
758
+ var STORAGE_DRIVERS = {
759
+ sqlite: { module: "better-sqlite3", spec: "better-sqlite3@^11.10.0" },
760
+ postgres: { module: "postgres", spec: "postgres@^3.4.9" },
761
+ redis: { module: "ioredis", spec: "ioredis@^5.11.1" }
762
+ };
763
+ function installCommand(storage) {
764
+ const driver = storage ? STORAGE_DRIVERS[storage] : void 0;
765
+ return `npm install -g @memstack/mcp${driver ? ` ${driver.spec}` : ""}`;
766
+ }
767
+ function findMemstackMcp(env = process.env) {
768
+ const names = process.platform === "win32" ? ["memstack-mcp.cmd", "memstack-mcp"] : ["memstack-mcp"];
769
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
770
+ if (!dir) continue;
771
+ for (const name of names) {
772
+ const candidate = join(dir, name);
773
+ if (existsSync(candidate)) return realpathSync(candidate);
774
+ }
775
+ }
776
+ return null;
777
+ }
778
+ function canResolveFrom(scriptPath, module) {
779
+ let dir = dirname(scriptPath);
780
+ for (; ; ) {
781
+ if (existsSync(join(dir, "node_modules", module, "package.json"))) return true;
782
+ const parent = dirname(dir);
783
+ if (parent === dir) return false;
784
+ dir = parent;
785
+ }
786
+ }
787
+ function harnessLaunch(mcpScript, harness) {
788
+ const isScript = /\.(c|m)?js$/.test(mcpScript);
789
+ const args = ["--profile", "harness", "--harness", harness];
790
+ return isScript ? { command: process.execPath, args: [mcpScript, ...args] } : { command: mcpScript, args };
791
+ }
792
+
793
+ // src/harness/index.ts
794
+ var HARNESS_IDS = ["claude-code", "codex"];
795
+ function harnessAdapter(id, options = {}) {
796
+ if (id === "claude-code") return claudeCodeAdapter(options);
797
+ if (id === "codex") return codexAdapter(options);
798
+ throw new Error(`Unknown harness "${id}". Supported: ${HARNESS_IDS.join(", ")}.`);
799
+ }
800
+
801
+ // src/harness-commands.ts
802
+ var HARNESS_COMMANDS = ["init", "connect", "disconnect", "status", "doctor", "memories", "project"];
803
+ var out = (line = "") => process.stdout.write(`${line}
804
+ `);
805
+ async function runHarnessCommand(command, args, flags) {
806
+ switch (command) {
807
+ case "init":
808
+ return init(flags);
809
+ case "connect":
810
+ return connect(args, flags);
811
+ case "disconnect":
812
+ return disconnect(args, flags);
813
+ case "status":
814
+ return status();
815
+ case "doctor":
816
+ return doctor(flags);
817
+ case "memories":
818
+ return memories(args, flags);
819
+ case "project":
820
+ return projectCommand(args);
821
+ default:
822
+ throw new Error(`Unknown command: ${command}`);
823
+ }
824
+ }
825
+ async function init(flags) {
826
+ const existing = readConfigFile();
827
+ const interactive = process.stdin.isTTY && !flags.yes;
828
+ const prompt = interactive ? createPrompter() : void 0;
829
+ try {
830
+ const provider = flags.provider ?? await prompt?.choose("LLM provider", ["openai-compatible", "anthropic"], existing?.llm?.provider ?? "openai-compatible") ?? existing?.llm?.provider ?? "openai-compatible";
831
+ if (provider !== "openai-compatible" && provider !== "anthropic") throw new Error('--provider must be "openai-compatible" or "anthropic"');
832
+ const sameProvider = existing?.llm?.provider === provider;
833
+ let baseURL;
834
+ if (provider === "openai-compatible") {
835
+ baseURL = flags["base-url"] ?? await prompt?.ask("Base URL (empty for OpenAI)", sameProvider ? existing?.llm?.baseURL ?? "" : "") ?? (sameProvider ? existing?.llm?.baseURL : void 0);
836
+ baseURL = baseURL || void 0;
837
+ }
838
+ const model = flags.model ?? await prompt?.ask("Model (empty for the provider default)", sameProvider ? existing?.llm?.model ?? "" : "") ?? (sameProvider ? existing?.llm?.model : void 0);
839
+ let apiKey;
840
+ if (flags["api-key-env"]) {
841
+ apiKey = process.env[flags["api-key-env"]];
842
+ if (!apiKey) throw new Error(`Environment variable ${flags["api-key-env"]} is empty.`);
843
+ } else if (prompt) {
844
+ apiKey = await prompt.secret(sameProvider && existing?.llm?.apiKey ? "API key (empty to keep the current one)" : "API key") || (sameProvider ? existing?.llm?.apiKey : void 0);
845
+ } else if (sameProvider) {
846
+ apiKey = existing?.llm?.apiKey;
847
+ }
848
+ if (!apiKey) throw new Error("An API key is required. Pass --api-key-env <VAR> to read it from an environment variable.");
849
+ const store = flags.store ?? await prompt?.choose("Storage", [...STORAGE_TYPES], existing?.storage?.type ?? "sqlite") ?? existing?.storage?.type ?? "sqlite";
850
+ if (!STORAGE_TYPES.includes(store)) throw new Error(`--store must be one of ${STORAGE_TYPES.join(", ")}`);
851
+ const storage = await storageSettings(store, flags, existing, prompt);
852
+ const llm = { provider, apiKey, ...baseURL ? { baseURL } : {}, ...model ? { model } : {} };
853
+ out("Checking the LLM provider with a test request...");
854
+ await checkLLM(buildLLM(llm));
855
+ out(" \u2713 The provider answered.");
856
+ const config = { version: 1, llm, storage };
857
+ if (storage.type === "sqlite" && storage.path) mkdirSync(join(storage.path, ".."), { recursive: true, mode: 448 });
858
+ writeConfigFile(config);
859
+ out(`Saved ${configFilePath()} (readable only by you).`);
860
+ const mcp = findMemstackMcp();
861
+ const driver = STORAGE_DRIVERS[store];
862
+ out();
863
+ if (!mcp || driver && !canResolveFrom(mcp, driver.module)) {
864
+ out("Next, install the MCP server and your storage driver (MemStack does not install them for you):");
865
+ out(` ${installCommand(store)}`);
866
+ }
867
+ out("Then connect your agents:");
868
+ out(" memstack connect claude-code");
869
+ out(" memstack connect codex");
870
+ return 0;
871
+ } finally {
872
+ prompt?.close();
873
+ }
874
+ }
875
+ async function storageSettings(type, flags, existing, prompt) {
876
+ const previous = existing?.storage?.type === type ? existing.storage : void 0;
877
+ switch (type) {
878
+ case "sqlite":
879
+ case "disk":
880
+ case "markdown": {
881
+ const fallback = previous?.path ?? join(memstackHome(), type === "sqlite" ? "memstack.db" : type === "disk" ? "data" : "memories");
882
+ const path = flags.path ?? await prompt?.ask(type === "sqlite" ? "Database file" : "Directory", fallback) ?? fallback;
883
+ return { type, path };
884
+ }
885
+ case "postgres":
886
+ case "redis": {
887
+ const url = flags.url ?? await prompt?.ask(`${type === "postgres" ? "Postgres" : "Redis"} connection URL`, previous?.url ?? "") ?? previous?.url;
888
+ if (!url) throw new Error(`--url is required for ${type}.`);
889
+ return { type, url };
890
+ }
891
+ case "memory":
892
+ return { type };
893
+ }
894
+ }
895
+ function buildLLM(llm) {
896
+ return llm.provider === "anthropic" ? new AnthropicLLMAdapter({ apiKey: llm.apiKey, defaultModel: llm.model }) : new OpenAILLMAdapter({ apiKey: llm.apiKey, baseURL: llm.baseURL, defaultModel: llm.model });
897
+ }
898
+ async function checkLLM(llm) {
899
+ let text;
900
+ try {
901
+ ({ text } = await llm.complete({ system: "Reply with the single word OK.", user: "ping", maxTokens: 1024, temperature: 0 }));
902
+ } catch (error) {
903
+ throw new Error(`The LLM provider rejected the test request: ${error.message}`);
904
+ }
905
+ if (!text.trim()) throw new Error("The LLM provider returned an empty reply to the test request.");
906
+ }
907
+ async function connect(ids, flags) {
908
+ if (ids.length === 0) throw new Error(`Name a harness: memstack connect <${HARNESS_IDS.join("|")}>`);
909
+ const file = readConfigFile();
910
+ if (!file && !process.env.OPENAI_API_KEY && !process.env.ANTHROPIC_API_KEY) {
911
+ throw new Error("No MemStack configuration found. Run `memstack init` first.");
912
+ }
913
+ const mcp = findMemstackMcp();
914
+ if (!mcp) {
915
+ throw new Error(`memstack-mcp is not installed. MemStack does not install it for you; run:
916
+ ${installCommand(file?.storage?.type)}`);
917
+ }
918
+ for (const id of ids) {
919
+ const adapter = harnessAdapter(id);
920
+ const launch = harnessLaunch(mcp, adapter.id);
921
+ const result = await connectHarness({
922
+ adapter,
923
+ launch,
924
+ dryRun: flags["dry-run"],
925
+ instructions: !flags["no-agents-md"],
926
+ hooks: !flags["no-hooks"]
927
+ });
928
+ const instructions = result.plan.instructions;
929
+ const hook = result.plan.hook;
930
+ if (flags["dry-run"]) {
931
+ out(`${adapter.displayName}: the server works (${result.verification.stats}).`);
932
+ const pending = result.plan.steps.length > 0 || instructions && instructions.change !== "none" || hook && hook.change !== "none";
933
+ out(pending ? "Would run:" : "Already connected; nothing to change.");
934
+ for (const step of result.plan.steps) out(` ${describeStep(step)}`);
935
+ if (instructions && instructions.change !== "none") {
936
+ out(` ${instructions.change === "add" ? "add" : "update"} the marked MemStack block in ${instructions.path}:`);
937
+ for (const line of renderBlock(adapter.instructions.body, adapter.id).trimEnd().split("\n")) out(` ${line}`);
938
+ }
939
+ if (hook && hook.change !== "none") {
940
+ out(` ${hook.change} the MemStack session-start hook in ${hook.path}:`);
941
+ out(` ${sessionStartHook(launch, adapter.id).command}`);
942
+ }
943
+ } else if (result.changed) {
944
+ out(`\u2713 Connected ${adapter.displayName}. ${result.verification.stats}`);
945
+ if (instructions && instructions.change !== "none") {
946
+ out(` ${instructions.change === "add" ? "Added" : "Updated"} MemStack guidance in ${instructions.path} (between memstack:begin/end markers).`);
947
+ }
948
+ if (hook && hook.change !== "none") {
949
+ out(` ${hook.change === "add" ? "Added" : "Updated"} a session-start hook in ${hook.path} that loads project memories.`);
950
+ if (adapter.id === "codex") out(" Codex runs new hooks only after you approve them: open Codex and run /hooks once.");
951
+ }
952
+ out(` ${adapter.id === "claude-code" ? "Restart Claude Code" : "Start a new Codex session"} to load MemStack.`);
953
+ } else {
954
+ out(`\u2713 ${adapter.displayName} is already connected. ${result.verification.stats}`);
955
+ }
956
+ }
957
+ return 0;
958
+ }
959
+ async function disconnect(ids, flags) {
960
+ if (ids.length === 0) throw new Error(`Name a harness: memstack disconnect <${HARNESS_IDS.join("|")}>`);
961
+ for (const id of ids) {
962
+ const adapter = harnessAdapter(id);
963
+ const result = await disconnectHarness({ adapter, dryRun: flags["dry-run"] });
964
+ const removeBlock2 = result.plan.instructions?.change === "remove";
965
+ const removeHook2 = result.plan.hook?.change === "remove";
966
+ if (flags["dry-run"]) {
967
+ const pending = result.plan.steps.length > 0 || removeBlock2 || removeHook2;
968
+ out(pending ? `${adapter.displayName}: would run:` : `${adapter.displayName}: not connected; nothing to change.`);
969
+ for (const step of result.plan.steps) out(` ${describeStep(step)}`);
970
+ if (removeBlock2) out(` remove the marked MemStack block from ${result.plan.instructions.path}`);
971
+ if (removeHook2) out(` remove the MemStack session-start hook from ${result.plan.hook.path}`);
972
+ } else {
973
+ out(result.changed ? `\u2713 Disconnected ${adapter.displayName}. Your memories are kept.` : `${adapter.displayName} was not connected.`);
974
+ }
975
+ }
976
+ return 0;
977
+ }
978
+ var label = (name) => (name ? `${name}:` : "").padEnd(14);
979
+ async function status() {
980
+ const file = readConfigFile();
981
+ out(`${label("Config")}${file ? configFilePath() : "none (run `memstack init`)"}`);
982
+ if (file?.llm) out(`${label("LLM")}${file.llm.provider}${file.llm.model ? ` (${file.llm.model})` : ""}${file.llm.baseURL ? ` at ${file.llm.baseURL}` : ""}`);
983
+ if (file?.storage) out(`${label("Storage")}${file.storage.type}${file.storage.path ? ` at ${file.storage.path}` : file.storage.url ? " (URL configured)" : ""}`);
984
+ const project = resolveProject();
985
+ out(`${label("Project")}${project.id} (${describeSource(project.source)}: ${project.key})`);
986
+ const mcp = findMemstackMcp();
987
+ out(`${label("Server")}${mcp ?? "memstack-mcp not installed"}`);
988
+ for (const id of HARNESS_IDS) {
989
+ const adapter = harnessAdapter(id);
990
+ const state = await adapter.inspect();
991
+ let line;
992
+ if (!state.installed) line = "not installed";
993
+ else if (!state.entry) line = "not connected";
994
+ else if (!mcp) {
995
+ const present = [state.entry.command, ...state.entry.args.filter((a) => a.startsWith("/"))].every((p) => existsSync(p));
996
+ line = present ? "connected (memstack-mcp is not on PATH, so updates can't be checked)" : "connected to a command that no longer exists (run `memstack doctor`)";
997
+ } else if (sameLaunch(state.entry, harnessLaunch(mcp, adapter.id))) line = "connected";
998
+ else line = "connected with a different command (run `memstack connect` to update)";
999
+ out(`${label(adapter.displayName)}${line}`);
1000
+ if (adapter.instructions && state.installed) {
1001
+ const block = readBlock(adapter.instructions.path);
1002
+ const current = block === renderBlock(adapter.instructions.body, adapter.id);
1003
+ out(`${label("")}guidance in ${adapter.instructions.path}: ${block === null ? "none" : current ? "present" : "outdated (run `memstack connect`)"}`);
1004
+ }
1005
+ if (adapter.sessionHook && state.installed) {
1006
+ out(`${label("")}session-start hook in ${adapter.sessionHook.path}: ${hookState(adapter, mcp)}`);
1007
+ }
1008
+ }
1009
+ return 0;
1010
+ }
1011
+ function hookState(adapter, mcp) {
1012
+ const current = readHook(adapter.sessionHook.path);
1013
+ if (!current) return "missing";
1014
+ if (mcp && JSON.stringify(current) !== JSON.stringify(sessionStartHook(harnessLaunch(mcp, adapter.id), adapter.id))) return "outdated";
1015
+ return "present";
1016
+ }
1017
+ async function doctor(flags) {
1018
+ let failures = 0;
1019
+ const pass = (message) => out(`\u2713 ${message}`);
1020
+ const warn = (message) => out(`! ${message}`);
1021
+ const fail2 = (message) => {
1022
+ failures++;
1023
+ out(`\u2717 ${message}`);
1024
+ };
1025
+ let file;
1026
+ try {
1027
+ file = readConfigFile();
1028
+ if (file) pass(`Config file ${configFilePath()}`);
1029
+ else warn(`No config file at ${configFilePath()}; run \`memstack init\`.`);
1030
+ } catch (error) {
1031
+ fail2(error.message);
1032
+ }
1033
+ for (const warning of checkConfigPermissions()) warn(warning);
1034
+ const env = mergeConfigFile(file, process.env);
1035
+ if (env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY) {
1036
+ pass(`LLM key configured (${env.ANTHROPIC_API_KEY ? "anthropic" : "openai-compatible"}${env.MEMSTACK_LLM_MODEL ? `, ${env.MEMSTACK_LLM_MODEL}` : ""})`);
1037
+ if (flags.live && file?.llm && !process.env.OPENAI_API_KEY && !process.env.ANTHROPIC_API_KEY) {
1038
+ try {
1039
+ await checkLLM(buildLLM(file.llm));
1040
+ pass("LLM provider answered a test request");
1041
+ } catch (error) {
1042
+ fail2(error.message);
1043
+ }
1044
+ }
1045
+ } else {
1046
+ fail2("No LLM key. Run `memstack init`.");
1047
+ }
1048
+ const storageType = env.MEMSTACK_STORAGE ?? "memory";
1049
+ if (storageType === "memory") warn("Storage is in-memory: memories are lost when a session ends. Run `memstack init` to choose a store.");
1050
+ else pass(`Storage: ${storageType}`);
1051
+ const mcp = findMemstackMcp();
1052
+ if (!mcp) {
1053
+ fail2(`memstack-mcp is not installed. Run: ${installCommand(storageType)}`);
1054
+ } else {
1055
+ pass(`memstack-mcp at ${mcp}`);
1056
+ const driver = STORAGE_DRIVERS[storageType];
1057
+ if (driver) {
1058
+ if (canResolveFrom(mcp, driver.module)) pass(`Storage driver ${driver.module} is installed`);
1059
+ else fail2(`Storage driver ${driver.module} is not installed next to memstack-mcp. Run: npm install -g ${driver.spec}`);
1060
+ }
1061
+ }
1062
+ const project = resolveProject();
1063
+ pass(`Project ${project.id} (${describeSource(project.source)}: ${project.key})`);
1064
+ for (const id of HARNESS_IDS) {
1065
+ const adapter = harnessAdapter(id);
1066
+ let state;
1067
+ try {
1068
+ state = await adapter.inspect();
1069
+ } catch (error) {
1070
+ fail2(`${adapter.displayName}: ${error.message}`);
1071
+ continue;
1072
+ }
1073
+ if (!state.installed) {
1074
+ out(`- ${adapter.displayName} is not installed`);
1075
+ continue;
1076
+ }
1077
+ if (!state.entry) {
1078
+ warn(`${adapter.displayName} is not connected. Run: memstack connect ${id}`);
1079
+ continue;
1080
+ }
1081
+ const missing = [state.entry.command, ...state.entry.args.filter((a) => a.startsWith("/"))].filter((p) => !existsSync(p));
1082
+ if (missing.length > 0) fail2(`${adapter.displayName} points at ${missing.join(", ")}, which no longer exists. Run: memstack connect ${id}`);
1083
+ else if (mcp && !sameLaunch(state.entry, harnessLaunch(mcp, adapter.id))) warn(`${adapter.displayName} uses a different MemStack command. Run: memstack connect ${id}`);
1084
+ else pass(`${adapter.displayName} is connected`);
1085
+ if (adapter.instructions) {
1086
+ const block = readBlock(adapter.instructions.path);
1087
+ if (block === null) warn(`${adapter.displayName} has no MemStack guidance in ${adapter.instructions.path}, so it may not save memories when asked. Run: memstack connect ${id}`);
1088
+ else if (block !== renderBlock(adapter.instructions.body, adapter.id)) warn(`MemStack guidance in ${adapter.instructions.path} is outdated. Run: memstack connect ${id}`);
1089
+ else pass(`${adapter.displayName} guidance in ${adapter.instructions.path}`);
1090
+ }
1091
+ if (adapter.sessionHook) {
1092
+ const hookStatus = hookState(adapter, mcp);
1093
+ if (hookStatus === "present") {
1094
+ pass(`${adapter.displayName} session-start hook in ${adapter.sessionHook.path}${id === "codex" ? " (approve it once in Codex with /hooks)" : ""}`);
1095
+ } else {
1096
+ warn(`${adapter.displayName} session-start hook is ${hookStatus}. Run: memstack connect ${id}`);
1097
+ }
1098
+ }
1099
+ }
1100
+ if (mcp) {
1101
+ const verification = await verifyServer(harnessLaunch(mcp, "claude-code"));
1102
+ if (verification.ok) pass(`MCP server starts and answers: ${verification.stats}`);
1103
+ else fail2(`MCP server check failed: ${verification.error}`);
1104
+ }
1105
+ out();
1106
+ out(failures === 0 ? "No problems found." : `${failures} problem${failures === 1 ? "" : "s"} found.`);
1107
+ return failures === 0 ? 0 : 1;
1108
+ }
1109
+ async function memories(args, flags) {
1110
+ const { config } = await loadConfig();
1111
+ if (!config.storage) throw new Error("No storage configured. Run `memstack init`.");
1112
+ const project = resolveProject();
1113
+ const namespaces = defaultRecallNamespaces(project.id);
1114
+ const memory = new HarnessMemory({ storage: config.storage, llm: config.llm, autoTags: false });
1115
+ try {
1116
+ await memory.adoptProjects(project.previousIds, project.id);
1117
+ if (flags.delete) {
1118
+ await memory.forget(flags.delete, namespaces);
1119
+ out(`Deleted ${flags.delete}.`);
1120
+ return 0;
1121
+ }
1122
+ const query = args.join(" ").trim() || void 0;
1123
+ const { hits, fallback } = await memory.recall({
1124
+ namespaces: flags.global ? ["global"] : namespaces,
1125
+ query,
1126
+ limit: flags.limit ? Number(flags.limit) : 20,
1127
+ maxChars: 5e4
1128
+ });
1129
+ out(`Project ${project.id} (${project.key})`);
1130
+ if (hits.length === 0) {
1131
+ out("No memories.");
1132
+ return 0;
1133
+ }
1134
+ if (query && fallback) out(`Nothing matched "${query}". The most important memories instead:`);
1135
+ for (const { memory: m } of hits) {
1136
+ const source = m.metadata?.source?.harness;
1137
+ const scope = m.actorId === "global" ? "global" : "project";
1138
+ out(`${m.id} ${m.memoryType}, ${scope}${source ? `, from ${source}` : ""}, ${m.createdAt.toISOString().slice(0, 10)}`);
1139
+ out(` ${m.content}`);
1140
+ }
1141
+ return 0;
1142
+ } finally {
1143
+ await config.storage.close();
1144
+ }
1145
+ }
1146
+ async function projectCommand(args) {
1147
+ const [sub, value] = args;
1148
+ if (sub === "pin") {
1149
+ if (!value) throw new Error("Name the project: memstack project pin <id>");
1150
+ const before = resolveProject();
1151
+ const path = pinProject(value);
1152
+ out(`Pinned this repository to project "${value}" in ${path}. Commit it so every clone uses the same project.`);
1153
+ if (before.id !== value) {
1154
+ out(`Memories stored under the previous ID stay there. To move them: memstack project merge ${before.id}`);
1155
+ }
1156
+ return 0;
1157
+ }
1158
+ if (sub === "merge") {
1159
+ if (!value) throw new Error("Name the old project ID: memstack project merge <old-id>");
1160
+ const oldId = value.replace(/^project:/, "");
1161
+ const project2 = resolveProject();
1162
+ const { config } = await loadConfig();
1163
+ if (!config.storage) throw new Error("No storage configured. Run `memstack init`.");
1164
+ const memory = new HarnessMemory({ storage: config.storage, llm: config.llm, autoTags: false });
1165
+ try {
1166
+ const moved = await memory.moveNamespace(projectNamespace(oldId), projectNamespace(project2.id));
1167
+ out(`Moved ${moved} ${moved === 1 ? "memory" : "memories"} from ${oldId} to ${project2.id}.`);
1168
+ } finally {
1169
+ await config.storage.close();
1170
+ }
1171
+ return 0;
1172
+ }
1173
+ if (sub) throw new Error(`Unknown subcommand "${sub}". Use: memstack project [pin <id> | merge <old-id>]`);
1174
+ const project = resolveProject();
1175
+ out(`${label("Project")}${project.id}`);
1176
+ out(`${label("From")}${describeSource(project.source)}: ${project.key}`);
1177
+ out(`${label("Root")}${project.root}`);
1178
+ return 0;
1179
+ }
1180
+ function describeSource(source) {
1181
+ switch (source) {
1182
+ case "pin":
1183
+ return `pinned in ${PIN_FILE}`;
1184
+ case "root-commit":
1185
+ return "first commit";
1186
+ case "remote":
1187
+ return "origin remote (shallow clone)";
1188
+ case "git":
1189
+ return "git directory (no commits yet)";
1190
+ default:
1191
+ return "directory";
1192
+ }
1193
+ }
1194
+ function createPrompter() {
1195
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1196
+ let muted = false;
1197
+ const output = rl;
1198
+ const write2 = output._writeToOutput.bind(rl);
1199
+ output._writeToOutput = (text) => {
1200
+ if (!muted || text.includes("\n") || text.includes("\r")) write2(text);
1201
+ };
1202
+ return {
1203
+ async ask(question, fallback) {
1204
+ const answer = (await rl.question(`${question}${fallback ? ` [${fallback}]` : ""}: `)).trim();
1205
+ return answer || fallback;
1206
+ },
1207
+ async choose(question, options, fallback) {
1208
+ for (; ; ) {
1209
+ const answer = (await rl.question(`${question} (${options.join(", ")}) [${fallback}]: `)).trim() || fallback;
1210
+ if (options.includes(answer)) return answer;
1211
+ out(`Choose one of: ${options.join(", ")}`);
1212
+ }
1213
+ },
1214
+ async secret(question) {
1215
+ const pending = rl.question(`${question}: `);
1216
+ muted = true;
1217
+ try {
1218
+ return (await pending).trim();
1219
+ } finally {
1220
+ muted = false;
1221
+ out();
1222
+ }
1223
+ },
1224
+ close() {
1225
+ rl.close();
1226
+ }
1227
+ };
1228
+ }
93
1229
 
94
1230
  // src/cli.ts
1231
+ process.stdout.on("error", (error) => {
1232
+ if (error.code === "EPIPE") process.exit(0);
1233
+ throw error;
1234
+ });
95
1235
  async function main() {
96
1236
  const { positionals, values } = parseArgs({
97
1237
  allowPositionals: true,
@@ -115,7 +1255,20 @@ async function main() {
115
1255
  "created-before": { type: "string" },
116
1256
  "dry-run": { type: "boolean" },
117
1257
  out: { type: "string" },
118
- file: { type: "string" }
1258
+ file: { type: "string" },
1259
+ provider: { type: "string" },
1260
+ "base-url": { type: "string" },
1261
+ model: { type: "string" },
1262
+ "api-key-env": { type: "string" },
1263
+ store: { type: "string" },
1264
+ path: { type: "string" },
1265
+ url: { type: "string" },
1266
+ yes: { type: "boolean" },
1267
+ live: { type: "boolean" },
1268
+ global: { type: "boolean" },
1269
+ delete: { type: "string" },
1270
+ "no-agents-md": { type: "boolean" },
1271
+ "no-hooks": { type: "boolean" }
119
1272
  }
120
1273
  });
121
1274
  const command = positionals[0];
@@ -123,7 +1276,11 @@ async function main() {
123
1276
  printUsage();
124
1277
  process.exit(1);
125
1278
  }
126
- const config = await loadConfig();
1279
+ if (HARNESS_COMMANDS.includes(command)) {
1280
+ process.exitCode = await runHarnessCommand(command, positionals.slice(1), values);
1281
+ return;
1282
+ }
1283
+ const config = await loadConfig2();
127
1284
  const ms = new MemStack(config);
128
1285
  let result;
129
1286
  switch (command) {
@@ -219,27 +1376,27 @@ async function main() {
219
1376
  break;
220
1377
  }
221
1378
  case "export": {
222
- const snapshot = await ms.export(values.actor);
1379
+ const snapshot2 = await ms.export(values.actor);
223
1380
  const outPath = values.out;
224
1381
  if (outPath) {
225
- await writeFile(outPath, JSON.stringify(snapshot, null, 2), "utf-8");
1382
+ await writeFile(outPath, JSON.stringify(snapshot2, null, 2), "utf-8");
226
1383
  }
227
- result = outPath ? { saved: outPath, count: snapshot.memories.length } : snapshot;
1384
+ result = outPath ? { saved: outPath, count: snapshot2.memories.length } : snapshot2;
228
1385
  break;
229
1386
  }
230
1387
  case "import": {
231
1388
  if (!values.actor) fail("--actor is required");
232
1389
  if (!values.file) fail("--file is required");
233
1390
  const raw = await readFile(String(values.file), "utf-8");
234
- let snapshot = JSON.parse(raw);
235
- if (Array.isArray(snapshot)) {
236
- snapshot = { version: 1, memories: snapshot, exportedAt: (/* @__PURE__ */ new Date()).toISOString() };
1391
+ let snapshot2 = JSON.parse(raw);
1392
+ if (Array.isArray(snapshot2)) {
1393
+ snapshot2 = { version: 1, memories: snapshot2, exportedAt: (/* @__PURE__ */ new Date()).toISOString() };
237
1394
  }
238
- if (!snapshot.memories || !Array.isArray(snapshot.memories) || snapshot.memories.length === 0) {
1395
+ if (!snapshot2.memories || !Array.isArray(snapshot2.memories) || snapshot2.memories.length === 0) {
239
1396
  fail("Snapshot contains no memories to import");
240
1397
  }
241
- await ms.import(snapshot);
242
- result = { imported: snapshot.memories.length };
1398
+ await ms.import(snapshot2);
1399
+ result = { imported: snapshot2.memories.length };
243
1400
  break;
244
1401
  }
245
1402
  default:
@@ -273,6 +1430,17 @@ function parseDuration(input) {
273
1430
  function printUsage() {
274
1431
  process.stderr.write(`memstack <command> [flags]
275
1432
 
1433
+ Agent harnesses (Claude Code, Codex):
1434
+ init Choose an LLM provider and store; writes ~/.memstack/config.json
1435
+ connect Register MemStack with a harness: connect claude-code|codex [--dry-run]
1436
+ Adds a session-start hook that loads project memories (skip with --no-hooks)
1437
+ For Codex, also adds a marked block to ~/.codex/AGENTS.md (skip with --no-agents-md)
1438
+ disconnect Remove MemStack from a harness; memories are kept
1439
+ status Show configuration, project, and harness connections
1440
+ doctor Diagnose setup problems [--live to test the LLM key]
1441
+ memories List or search this project's memories [query] [--delete <id>]
1442
+ project Show this repository's project ID; project pin <id>; project merge <old-id>
1443
+
276
1444
  Commands:
277
1445
  store Store a memory
278
1446
  retrieve Retrieve memories