@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 +1209 -41
- package/dist/cli.js.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
7
|
-
|
|
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 (
|
|
11
|
-
return new AnthropicLLMAdapter({ apiKey:
|
|
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:
|
|
15
|
-
baseURL:
|
|
16
|
-
defaultModel:
|
|
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 (
|
|
21
|
-
if (
|
|
22
|
-
return new OpenAIEmbeddingAdapter({ apiKey:
|
|
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 =
|
|
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:
|
|
220
|
+
dir: env.MEMSTACK_DIR ?? "./memories"
|
|
34
221
|
});
|
|
35
222
|
case "disk":
|
|
36
223
|
return new DiskStorageAdapter({
|
|
37
|
-
storageDir:
|
|
224
|
+
storageDir: env.MEMSTACK_DIR ?? "./memstack-data"
|
|
38
225
|
});
|
|
39
226
|
case "postgres":
|
|
40
|
-
if (!
|
|
227
|
+
if (!env.DATABASE_URL) {
|
|
41
228
|
throw new Error("postgres storage requires DATABASE_URL");
|
|
42
229
|
}
|
|
43
230
|
return new PostgresStorageAdapter({
|
|
44
|
-
connectionString:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1379
|
+
const snapshot2 = await ms.export(values.actor);
|
|
223
1380
|
const outPath = values.out;
|
|
224
1381
|
if (outPath) {
|
|
225
|
-
await writeFile(outPath, JSON.stringify(
|
|
1382
|
+
await writeFile(outPath, JSON.stringify(snapshot2, null, 2), "utf-8");
|
|
226
1383
|
}
|
|
227
|
-
result = outPath ? { saved: outPath, count:
|
|
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
|
|
235
|
-
if (Array.isArray(
|
|
236
|
-
|
|
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 (!
|
|
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(
|
|
242
|
-
result = { imported:
|
|
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
|