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