@eleboucher/pi-memini 0.6.12 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +45 -37
  2. package/dist/index.js +545 -546
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,168 +1,8 @@
1
1
  // src/index.ts
2
2
  import { Type } from "typebox";
3
-
4
- // ../../../packages/namespace-resolver/src/index.ts
5
- import { execSync } from "node:child_process";
6
- import { basename } from "node:path";
7
- import { homedir } from "node:os";
8
- import fs from "node:fs";
9
- import path from "node:path";
10
- var DEFAULT_TEMPLATE = "{tenant}/{project}/{agent}";
11
- function defaultConfigPath() {
12
- const xdg = process.env["XDG_CONFIG_HOME"];
13
- const base = xdg && xdg.trim() ? xdg : path.join(homedir(), ".config");
14
- return path.join(base, "memini", "config.json");
15
- }
16
- function readConfig(configPath) {
17
- const p = configPath || defaultConfigPath();
18
- try {
19
- const raw = fs.readFileSync(p, "utf8");
20
- const parsed = JSON.parse(raw);
21
- return {
22
- tenantRoots: Array.isArray(parsed.tenantRoots) ? parsed.tenantRoots : [],
23
- template: typeof parsed.template === "string" ? parsed.template : DEFAULT_TEMPLATE,
24
- overrides: parsed.overrides && typeof parsed.overrides === "object" ? parsed.overrides : {},
25
- found: true
26
- };
27
- } catch {
28
- return { tenantRoots: [], template: DEFAULT_TEMPLATE, overrides: {}, found: false };
29
- }
30
- }
31
- function gitOut(args, dir) {
32
- try {
33
- return execSync(`git ${args}`, {
34
- cwd: dir,
35
- stdio: ["ignore", "pipe", "ignore"],
36
- timeout: 500
37
- }).toString().trim();
38
- } catch {
39
- return "";
40
- }
41
- }
42
- function repoNameFromRemote(url) {
43
- if (typeof url !== "string" || !url) return null;
44
- const cleaned = url.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
45
- if (!cleaned) return null;
46
- const scpMatch = cleaned.match(/^[^/:]+:[^/]/);
47
- const p = scpMatch ? cleaned.slice(scpMatch[0].indexOf(":") + 1) : cleaned;
48
- const segs = p.split("/").filter(Boolean);
49
- return segs.length ? segs[segs.length - 1] : null;
50
- }
51
- function repoSlugFromRemote(url) {
52
- if (typeof url !== "string" || !url) return null;
53
- const cleaned = url.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
54
- if (!cleaned) return null;
55
- const scpMatch = cleaned.match(/^[^/:]+:[^/]/);
56
- const p = scpMatch ? cleaned.slice(scpMatch[0].indexOf(":") + 1) : cleaned;
57
- const segs = p.split("/").filter(Boolean);
58
- if (!segs.length) return null;
59
- if (segs.length === 1) return segs[0];
60
- const owner = segs[segs.length - 2].replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
61
- const repo = segs[segs.length - 1];
62
- return owner ? `${owner}-${repo}` : repo;
63
- }
64
- function expandTilde(p) {
65
- if (p === "~") return homedir();
66
- if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
67
- return p;
68
- }
69
- function sanitizeSegment(s) {
70
- return s.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "");
71
- }
72
- function resolveTenant(cwd, config) {
73
- const resolvedCwd = path.resolve(cwd);
74
- for (const root of config.tenantRoots) {
75
- if (!root || typeof root !== "object") continue;
76
- if (typeof root.path !== "string" || !root.path) continue;
77
- const rootPath = path.resolve(expandTilde(root.path));
78
- if (resolvedCwd === rootPath || resolvedCwd.startsWith(rootPath + path.sep)) {
79
- const t = sanitizeSegment(String(root.tenant || ""));
80
- if (t) return t;
81
- }
82
- }
83
- return void 0;
84
- }
85
- function resolveProject(cwd, gitRemoteUrl, env) {
86
- const ownerRepo = (env?.["MEMINI_NAMESPACE_SCOPE"] || "").trim() === "owner-repo";
87
- let remote = gitRemoteUrl;
88
- if (!remote) {
89
- remote = gitOut("remote get-url origin", cwd);
90
- }
91
- if (remote) {
92
- const name = ownerRepo ? repoSlugFromRemote(remote) : repoNameFromRemote(remote);
93
- if (name) return sanitizeSegment(name);
94
- }
95
- const toplevel = gitOut("rev-parse --show-toplevel", cwd);
96
- if (toplevel) return sanitizeSegment(basename(toplevel));
97
- const b = basename(cwd);
98
- return sanitizeSegment(b) || "default";
99
- }
100
- function resolveAgent(opts) {
101
- const agent = (opts.agentId || opts.env?.["MEMINI_AGENT"] || "").trim();
102
- if (!agent) return void 0;
103
- return sanitizeSegment(agent);
104
- }
105
- function resolveHome(env) {
106
- const home = (env["MEMINI_HOME"] || "").trim();
107
- return home || void 0;
108
- }
109
- function applyTemplate(template, segments) {
110
- const all = {
111
- tenant: segments.tenant,
112
- project: segments.project,
113
- agent: segments.agent,
114
- namespace: segments.namespace
115
- };
116
- let result = template.replace(/\{(tenant|project|agent|namespace)\}/g, (_, key) => {
117
- return all[key] ?? "";
118
- });
119
- result = result.replace(/\/{2,}/g, "/");
120
- result = result.replace(/^\/+|\/+$/g, "");
121
- return result;
122
- }
123
- function resolveNamespace(opts) {
124
- const env = opts.env || process.env;
125
- const cwd = opts.cwd && opts.cwd.trim() ? opts.cwd : process.cwd();
126
- const home = resolveHome(env);
127
- const homeSource = home ? "env" : void 0;
128
- const nsEnv = (env["MEMINI_NAMESPACE"] || "").trim();
129
- if (nsEnv) {
130
- return { namespace: nsEnv, segments: {}, source: "env", home, homeSource };
131
- }
132
- const config = readConfig(opts.configPath);
133
- if (!config.found) {
134
- const project2 = sanitizeSegment(basename(cwd));
135
- return {
136
- namespace: project2,
137
- segments: project2 ? { project: project2 } : {},
138
- source: "cwd",
139
- home,
140
- homeSource
141
- };
142
- }
143
- const tenant = resolveTenant(cwd, config);
144
- const project = resolveProject(cwd, opts.gitRemoteUrl, env);
145
- const agent = resolveAgent(opts);
146
- const override = opts.integration ? config.overrides[opts.integration] : void 0;
147
- const baseNamespace = override?.namespace;
148
- const segments = { tenant, project, agent, namespace: baseNamespace };
149
- const template = override?.template || config.template;
150
- let namespace = applyTemplate(template, segments);
151
- if (!namespace) {
152
- namespace = project || "default";
153
- }
154
- let source = "default";
155
- if (tenant) source = "config";
156
- else if (opts.gitRemoteUrl || gitOut("remote get-url origin", cwd)) source = "git";
157
- else source = "cwd";
158
- return { namespace, segments, source, home, homeSource };
159
- }
3
+ import { readFileSync } from "node:fs";
160
4
 
161
5
  // ../../../packages/memini-client/src/redact.ts
162
- var SENSITIVE = /(^|_)(KEY|TOKEN|SECRET|PASSWORD|PASS|BEARER|DSN|CREDENTIALS?)$/i;
163
- function isSensitive(name) {
164
- return SENSITIVE.test(name);
165
- }
166
6
  function redactValue(value) {
167
7
  if (!value) return "";
168
8
  if (value.length <= 12) return "***";
@@ -188,216 +28,262 @@ function validateNamespace(ns) {
188
28
  return null;
189
29
  }
190
30
 
191
- // ../../../packages/memini-client/src/override.ts
192
- import { execSync as execSync2 } from "node:child_process";
193
- import { homedir as homedir2 } from "node:os";
194
- import fs2 from "node:fs";
195
- import path2 from "node:path";
196
- var OVERRIDES_VERSION = 1;
197
- var EMPTY = { version: OVERRIDES_VERSION, overrides: {} };
198
- function defaultOverridesPath(env = process.env) {
199
- const xdg = env["XDG_CONFIG_HOME"];
200
- const base = xdg && xdg.trim() ? xdg : path2.join(homedir2(), ".config");
201
- return path2.join(base, "memini", "overrides.json");
31
+ // ../../../packages/memini-client/src/session.ts
32
+ var SESSION_CWD_TTL_MS = 6 * 60 * 60 * 1e3;
33
+
34
+ // ../../../packages/memini-client/src/settings.ts
35
+ var BEHAVIOR_KNOBS = [
36
+ { envName: "MEMINI_CAPTURE_TURNS", wireKey: "capture_turns", kind: "bool", default: true },
37
+ { envName: "MEMINI_SESSION_DIGEST", wireKey: "session_digest", kind: "bool", default: true },
38
+ { envName: "MEMINI_INLINE_EXTRACT", wireKey: "inline_extract", kind: "bool", default: true },
39
+ { envName: "MEMINI_AUTO_SAVE", wireKey: "auto_save", kind: "bool", default: true },
40
+ { envName: "MEMINI_AUTO_SAVE_INTERVAL", wireKey: "auto_save_interval", kind: "int", default: 10 },
41
+ { envName: "MEMINI_AUTO_SAVE_MIN_EVENTS", wireKey: "auto_save_min_events", kind: "int", default: 3 },
42
+ { envName: "MEMINI_INJECT_BRIEFING_PINNED", wireKey: "inject_briefing_pinned", kind: "int", default: 5 },
43
+ { envName: "MEMINI_INJECT_BRIEFING_FACTS", wireKey: "inject_briefing_facts", kind: "int", default: 5 },
44
+ { envName: "MEMINI_INJECT_BRIEFING_PROCEDURES", wireKey: "inject_briefing_procedures", kind: "int", default: 5 },
45
+ { envName: "MEMINI_INJECT_BRIEFING_RECENT", wireKey: "inject_briefing_recent", kind: "int", default: 3 },
46
+ { envName: "MEMINI_INJECT_BRIEFING_MAX_TOK", wireKey: "inject_briefing_max_tok", kind: "int", default: 0 },
47
+ { envName: "MEMINI_INJECT_PRETOOL_ITEMS", wireKey: "inject_pretool_items", kind: "int", default: 3 },
48
+ { envName: "MEMINI_INJECT_PRETOOL_MAX_TOK", wireKey: "inject_pretool_max_tok", kind: "int", default: 0 },
49
+ { envName: "MEMINI_INJECT_PRETOOL_MIN_SCORE", wireKey: "inject_pretool_min_score", kind: "float", default: 0 },
50
+ {
51
+ envName: "MEMINI_INJECT_PRETOOL_TOOLS",
52
+ wireKey: "inject_pretool_tools",
53
+ kind: "list",
54
+ default: ["Read", "Write", "Edit", "Glob", "Grep"]
55
+ },
56
+ { envName: "MEMINI_INJECT_DEDUPE", wireKey: "inject_dedupe", kind: "bool", default: true },
57
+ { envName: "MEMINI_INJECT_LABELS", wireKey: "inject_labels", kind: "list", default: [] },
58
+ { envName: "MEMINI_RECALL", wireKey: "recall", kind: "bool", default: true },
59
+ { envName: "MEMINI_CAPTURE", wireKey: "capture", kind: "bool", default: true },
60
+ { envName: "MEMINI_RECALL_LIMIT", wireKey: "recall_limit", kind: "int", default: 3 },
61
+ { envName: "MEMINI_INJECT_RECALL_MAX_TOK", wireKey: "inject_recall_max_tok", kind: "int", default: 0 },
62
+ { envName: "MEMINI_INJECT_RECALL_MIN_SCORE", wireKey: "inject_recall_min_score", kind: "float", default: 0 },
63
+ { envName: "MEMINI_MIN_CAPTURE_CHARS", wireKey: "min_capture_chars", kind: "int", default: 0 }
64
+ ];
65
+ function parseIntKnob(raw, fallback) {
66
+ const n = Number.parseInt(raw, 10);
67
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
202
68
  }
203
- function overrideKey(cwd) {
204
- const dir = cwd && cwd.trim() ? cwd : process.cwd();
205
- try {
206
- const top = execSync2("git rev-parse --show-toplevel", {
207
- cwd: dir,
208
- stdio: ["ignore", "pipe", "ignore"],
209
- timeout: 500
210
- }).toString().trim();
211
- if (top) return path2.resolve(top);
212
- } catch {
213
- }
214
- return path2.resolve(dir);
69
+ function parseFloatKnob(raw, fallback) {
70
+ const n = Number.parseFloat(raw);
71
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
215
72
  }
216
- function readOverrides(opts = {}) {
217
- const p = opts.overridesPath || defaultOverridesPath(opts.env);
218
- try {
219
- const parsed = JSON.parse(fs2.readFileSync(p, "utf8"));
220
- if (!parsed || typeof parsed !== "object" || typeof parsed.overrides !== "object") {
221
- return { ...EMPTY, overrides: {} };
73
+ function parseListKnob(raw) {
74
+ return raw.split(/[|,]/).map((s) => s.trim().toLowerCase()).filter(Boolean);
75
+ }
76
+ function effectiveSetting(knob2, server, env = process.env) {
77
+ const raw = env[knob2.envName];
78
+ if (raw != null && raw !== "") {
79
+ let value;
80
+ switch (knob2.kind) {
81
+ case "bool":
82
+ value = !/^(0|false|no|off)$/i.test(raw.trim());
83
+ break;
84
+ case "int":
85
+ value = parseIntKnob(raw, knob2.default);
86
+ break;
87
+ case "float":
88
+ value = parseFloatKnob(raw, knob2.default);
89
+ break;
90
+ case "list":
91
+ value = parseListKnob(raw);
92
+ break;
222
93
  }
223
- return {
224
- version: typeof parsed.version === "number" ? parsed.version : OVERRIDES_VERSION,
225
- overrides: parsed.overrides || {}
226
- };
227
- } catch {
228
- return { ...EMPTY, overrides: {} };
94
+ return { value, source: "env-override" };
95
+ }
96
+ if (server && Object.prototype.hasOwnProperty.call(server, knob2.wireKey)) {
97
+ return { value: server[knob2.wireKey], source: "server" };
229
98
  }
99
+ return { value: knob2.default, source: "default" };
230
100
  }
231
- function readOverride(cwd, opts = {}) {
232
- const file = readOverrides(opts);
233
- const keys = Object.keys(file.overrides);
234
- if (keys.length === 0) return void 0;
235
- const entry = file.overrides[overrideKey(cwd)];
236
- if (!entry || typeof entry.namespace !== "string" || !entry.namespace.trim()) return void 0;
237
- return entry;
101
+
102
+ // ../../../packages/memini-client/src/bootstrap.ts
103
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
104
+ function envEnabled(raw, defaultOn) {
105
+ if (raw == null || raw === "") return defaultOn;
106
+ return !/^(0|false|no|off)$/i.test(raw.trim());
238
107
  }
239
- function writeOverride(cwd, namespace, opts = {}) {
240
- const ns = normalizeNamespace(namespace);
241
- const bad = validateNamespace(ns);
242
- if (bad) throw new Error(`invalid namespace ${JSON.stringify(namespace)}: ${bad}`);
243
- const p = opts.overridesPath || defaultOverridesPath(opts.env);
244
- const file = readOverrides(opts);
245
- const entry = {
246
- namespace: ns,
247
- setAt: (opts.now ? opts.now() : /* @__PURE__ */ new Date()).toISOString()
108
+ function readBootstrap(env = process.env) {
109
+ return {
110
+ baseUrl: env["MEMINI_BASE_URL"] || "http://localhost:8080",
111
+ apiKey: env["MEMINI_API_KEY"] || "",
112
+ requireHttps: envEnabled(env["MEMINI_REQUIRE_HTTPS"], false),
113
+ debug: envEnabled(env["MEMINI_DEBUG"], false),
114
+ agent: env["MEMINI_AGENT"] || "",
115
+ namespaceEnv: (env["MEMINI_NAMESPACE"] || "").trim(),
116
+ namespacePrefixEnv: (env["MEMINI_NAMESPACE_PREFIX"] || "").trim(),
117
+ homeEnv: (env["MEMINI_HOME"] || "").trim()
248
118
  };
249
- file.version = OVERRIDES_VERSION;
250
- file.overrides[overrideKey(cwd)] = entry;
251
- fs2.mkdirSync(path2.dirname(p), { recursive: true });
252
- fs2.writeFileSync(p, JSON.stringify(file, null, 2) + "\n");
253
- return entry;
254
119
  }
255
- function clearOverride(cwd, opts = {}) {
256
- const p = opts.overridesPath || defaultOverridesPath(opts.env);
257
- const file = readOverrides(opts);
258
- const key = overrideKey(cwd);
259
- if (!(key in file.overrides)) return false;
260
- delete file.overrides[key];
120
+ function isPlaintextBearerUnsafe(baseUrl, secret) {
121
+ if (!secret) return false;
261
122
  try {
262
- fs2.mkdirSync(path2.dirname(p), { recursive: true });
263
- fs2.writeFileSync(p, JSON.stringify(file, null, 2) + "\n");
123
+ const u = new URL(baseUrl);
124
+ return u.protocol === "http:" && !LOOPBACK_HOSTS.has(u.hostname.replace(/^\[|\]$/g, "").toLowerCase());
264
125
  } catch {
265
126
  return false;
266
127
  }
267
- return true;
128
+ }
129
+ function assertBearerTransportSafe(baseUrl, secret, env = process.env) {
130
+ if (!isPlaintextBearerUnsafe(baseUrl, secret)) return;
131
+ if (!envEnabled(env["MEMINI_REQUIRE_HTTPS"], false)) return;
132
+ throw new Error(
133
+ `memini: a bearer token is configured for plaintext HTTP to ${baseUrl}. The token and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`
134
+ );
268
135
  }
269
136
 
270
- // ../../../packages/memini-client/src/session.ts
271
- var SESSION_CWD_TTL_MS = 6 * 60 * 60 * 1e3;
272
-
273
- // ../../../packages/memini-client/src/settings.ts
274
- var CLIENT_KNOBS = [
275
- // Connection
276
- { name: "MEMINI_BASE_URL", kind: "string", default: "http://localhost:8080", usedBy: "hooks + MCP", description: "memini base URL (alias: MEMINI_URL)" },
277
- { name: "MEMINI_MCP_URL", kind: "string", default: "${MEMINI_BASE_URL}/mcp", usedBy: "MCP", description: "MCP endpoint; derived from the base URL unless set" },
278
- { name: "MEMINI_API_KEY", kind: "string", default: "", usedBy: "hooks + MCP", description: "bearer token sent to the server (alias: MEMINI_TOKEN)" },
279
- { name: "MEMINI_REQUIRE_HTTPS", kind: "bool", default: "0", usedBy: "hooks + MCP", description: "refuse to send a bearer token over plaintext HTTP" },
280
- // Namespace
281
- { name: "MEMINI_NAMESPACE", kind: "string", default: "(auto: git/cwd)", usedBy: "hooks + MCP", description: "pin the namespace; overrides git and directory detection" },
282
- { name: "MEMINI_NAMESPACE_SCOPE", kind: "string", default: "repo", usedBy: "hooks", description: "owner-repo derives owner-repo slugs from the git remote" },
283
- { name: "MEMINI_AGENT", kind: "string", default: "", usedBy: "hooks + MCP", description: "nest the namespace under a per-agent segment" },
284
- { name: "MEMINI_HOME", kind: "string", default: "", usedBy: "hooks + MCP", description: 'personal namespace; required for visibility:"personal" writes' },
285
- // Capture
286
- { name: "MEMINI_CAPTURE_TURNS", kind: "bool", default: "on", usedBy: "hooks", description: "capture each user\u2192assistant turn as episodic memory" },
287
- { name: "MEMINI_SESSION_DIGEST", kind: "bool", default: "on", usedBy: "hooks", description: "record session digests (files edited, commands run); 0 to keep memory to durable facts only" },
288
- { name: "MEMINI_INLINE_EXTRACT", kind: "bool", default: "on", usedBy: "hooks", description: "inject the memory-save directive at SessionStart" },
289
- { name: "MEMINI_AUTO_SAVE", kind: "bool", default: "on", usedBy: "hooks", description: "periodic auto-save nudge on Stop" },
290
- { name: "MEMINI_AUTO_SAVE_INTERVAL", kind: "int", default: "10", usedBy: "hooks", description: "user messages between auto-save nudges" },
291
- // Injection budgets
292
- { name: "MEMINI_INJECT_BRIEFING_PINNED", kind: "int", default: "5", usedBy: "hooks", description: "max pinned memories at SessionStart (0 disables)" },
293
- { name: "MEMINI_INJECT_BRIEFING_FACTS", kind: "int", default: "5", usedBy: "hooks", description: "max durable facts at SessionStart (0 disables)" },
294
- { name: "MEMINI_INJECT_BRIEFING_PROCEDURES", kind: "int", default: "5", usedBy: "hooks", description: "max procedural how-tos at SessionStart (0 disables)" },
295
- { name: "MEMINI_INJECT_BRIEFING_RECENT", kind: "int", default: "3", usedBy: "hooks", description: "max recent episodic entries at SessionStart (0 disables)" },
296
- { name: "MEMINI_INJECT_BRIEFING_MAX_TOK", kind: "int", default: "uncapped", usedBy: "hooks", description: "token ceiling on the SessionStart briefing" },
297
- { name: "MEMINI_INJECT_PRETOOL_ITEMS", kind: "int", default: "3", usedBy: "hooks", description: "max hits surfaced per file on PreToolUse" },
298
- { name: "MEMINI_INJECT_PRETOOL_MAX_TOK", kind: "int", default: "uncapped", usedBy: "hooks", description: "token ceiling per file on PreToolUse" },
299
- { name: "MEMINI_INJECT_PRETOOL_MIN_SCORE", kind: "float", default: "0", usedBy: "hooks", description: "relevance floor for PreToolUse hits" },
300
- { name: "MEMINI_INJECT_PRETOOL_TOOLS", kind: "list", default: "Read|Write|Edit|Glob|Grep", usedBy: "hooks", description: "tool allowlist for PreToolUse recall" },
301
- { name: "MEMINI_INJECT_LABELS", kind: "list", default: "", usedBy: "hooks", description: "annotate injected bullets: tier, confidence, age, reason" },
302
- // Diagnostics
303
- { name: "MEMINI_DEBUG", kind: "bool", default: "0", usedBy: "hooks + MCP", description: "verbose hook logging to stderr" }
304
- ];
305
- function describeKnob(spec, env) {
306
- const raw = env[spec.name];
307
- const set = raw != null && raw !== "";
308
- const sensitive = isSensitive(spec.name);
309
- let value;
310
- if (set) {
311
- value = sensitive ? redactValue(raw) : raw;
312
- } else {
313
- value = spec.default === "" ? "(unset)" : spec.default;
137
+ // ../../../packages/memini-client/src/facts.ts
138
+ import { execFileSync } from "node:child_process";
139
+ import path from "node:path";
140
+ function gitOut(args, dir) {
141
+ try {
142
+ const out = execFileSync("git", args, {
143
+ cwd: dir,
144
+ stdio: ["ignore", "pipe", "ignore"],
145
+ timeout: 500
146
+ }).toString().trim();
147
+ return out || void 0;
148
+ } catch {
149
+ return void 0;
314
150
  }
315
- return {
316
- name: spec.name,
317
- value,
318
- source: set ? "env" : "default",
319
- isDefault: !set,
320
- sensitive,
321
- usedBy: spec.usedBy,
322
- description: spec.description
151
+ }
152
+ function gatherFacts(cwd, env = process.env) {
153
+ const dir = cwd && cwd.trim() ? cwd : process.cwd();
154
+ const facts = {
155
+ cwd_basename: path.basename(dir)
323
156
  };
157
+ const remote = gitOut(["remote", "get-url", "origin"], dir);
158
+ if (remote) facts.remote_url = remote;
159
+ const toplevel = gitOut(["rev-parse", "--show-toplevel"], dir);
160
+ if (toplevel) {
161
+ facts.toplevel_path = toplevel;
162
+ facts.toplevel_basename = path.basename(toplevel);
163
+ }
164
+ const agent = env["MEMINI_AGENT"];
165
+ if (agent) facts.agent = agent;
166
+ const ns = (env["MEMINI_NAMESPACE"] || "").trim();
167
+ if (ns) facts.env_namespace = ns;
168
+ const prefix = (env["MEMINI_NAMESPACE_PREFIX"] || "").trim();
169
+ if (prefix) facts.env_namespace_prefix = prefix;
170
+ return facts;
324
171
  }
325
- var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
326
- function isPlaintextToNonLoopback(baseUrl) {
327
- try {
328
- const u = new URL(baseUrl);
329
- return u.protocol === "http:" && !LOOPBACK.has(u.hostname.replace(/^\[|\]$/g, "").toLowerCase());
330
- } catch {
331
- return false;
172
+
173
+ // ../../../packages/memini-client/src/resolve.ts
174
+ function remotePathSegments(url) {
175
+ if (typeof url !== "string" || !url) return [];
176
+ const cleaned = url.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
177
+ if (!cleaned) return [];
178
+ const scpMatch = cleaned.match(/^[^/:]+:[^/]/);
179
+ const p = scpMatch ? cleaned.slice(scpMatch[0].indexOf(":") + 1) : cleaned;
180
+ return p.split("/").filter(Boolean);
181
+ }
182
+ function repoNameFromRemote(url) {
183
+ const segs = remotePathSegments(url);
184
+ return segs.length ? segs[segs.length - 1] : void 0;
185
+ }
186
+ function repoSlugFromRemote(url) {
187
+ const segs = remotePathSegments(url);
188
+ if (!segs.length) return void 0;
189
+ if (segs.length === 1) return segs[0];
190
+ const owner = segs[segs.length - 2].replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
191
+ const repo = segs[segs.length - 1];
192
+ return owner ? `${owner}-${repo}` : repo;
193
+ }
194
+ function withAgent(ns, agent) {
195
+ const trimmed = (agent || "").trim();
196
+ if (!trimmed) return ns;
197
+ const seg = trimmed.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
198
+ return seg ? `${ns}/${seg}` : ns;
199
+ }
200
+ function deriveLocalNamespace(f, scope = "repo") {
201
+ if (f.declared_namespace) {
202
+ return { namespace: f.declared_namespace, source: "declared" };
203
+ }
204
+ let base;
205
+ let source = "default";
206
+ if (f.remote_url) {
207
+ const name = scope === "owner_repo" ? repoSlugFromRemote(f.remote_url) : repoNameFromRemote(f.remote_url);
208
+ if (name) {
209
+ base = name;
210
+ source = "remote";
211
+ }
212
+ }
213
+ if (!base && f.toplevel_basename) {
214
+ base = f.toplevel_basename;
215
+ source = "toplevel";
216
+ }
217
+ if (!base && f.cwd_basename) {
218
+ base = f.cwd_basename;
219
+ source = "cwd";
220
+ }
221
+ if (!base) {
222
+ return { namespace: "default", source: "default" };
332
223
  }
224
+ return { namespace: withAgent(base, f.agent), source };
333
225
  }
334
- function describeSettings(opts) {
335
- const env = opts.env || process.env;
336
- const cwd = opts.cwd;
337
- const override = readOverride(cwd, { env, overridesPath: opts.overridesPath });
338
- const withoutOverride = opts.resolve(env, { ignoreOverride: true });
339
- const envSansPin = { ...env };
340
- delete envSansPin["MEMINI_NAMESPACE"];
341
- const derived = opts.resolve(envSansPin, { ignoreOverride: true });
342
- const effective = override ? override.namespace : withoutOverride.namespace;
343
- const source = override ? "override" : withoutOverride.source;
344
- const home = (env["MEMINI_HOME"] || "").trim() || void 0;
345
- const settings = CLIENT_KNOBS.map((k) => describeKnob(k, env));
346
- const warnings = [];
347
- if (override) {
348
- warnings.push({
349
- level: "note",
350
- code: "override-active",
351
- message: `namespace is overridden to "${override.namespace}" for this project (set ${override.setAt}); without it this project would use "${withoutOverride.namespace}".`,
352
- fix: "Run the namespace command with --clear to return to automatic resolution."
353
- });
226
+ function resolveNamespace(boot, facts, hs) {
227
+ if (hs) {
228
+ return { namespace: hs.namespace, source: `server:${hs.namespace_source}`, degraded: false };
354
229
  }
355
- const pin = (env["MEMINI_NAMESPACE"] || "").trim();
356
- if (pin && !override && derived.namespace && derived.namespace !== pin) {
357
- warnings.push({
358
- level: "warn",
359
- code: "global-namespace-pin",
360
- message: `MEMINI_NAMESPACE is set to "${pin}", which pins EVERY project on this machine to one namespace. This project would otherwise resolve to "${derived.namespace}". If this variable is exported from a shell rc (or a fish universal variable), every repo you work in is sharing one memory pool.`,
361
- fix: `Unset MEMINI_NAMESPACE and let each repo resolve on its own, or set a per-project override instead.`
362
- });
230
+ if (boot.namespaceEnv) {
231
+ return { namespace: boot.namespaceEnv, source: "env", degraded: true };
363
232
  }
364
- if (!home) {
365
- warnings.push({
366
- level: "warn",
367
- code: "home-unset",
368
- message: 'MEMINI_HOME is unset: there is no personal namespace, so visibility:"personal" writes will error and no personal leg merges into recall.',
369
- fix: "Export MEMINI_HOME=personal/<you>."
370
- });
233
+ const { namespace, source } = deriveLocalNamespace(facts);
234
+ const prefix = boot.namespacePrefixEnv;
235
+ if (prefix && (source === "remote" || source === "toplevel" || source === "cwd")) {
236
+ return { namespace: `${prefix}/${namespace}`, source: `local-${source}`, degraded: true };
371
237
  }
372
- const baseUrl = env["MEMINI_BASE_URL"] || env["MEMINI_URL"] || "http://localhost:8080";
373
- const token = env["MEMINI_API_KEY"] || env["MEMINI_TOKEN"] || "";
374
- if (token && isPlaintextToNonLoopback(baseUrl)) {
375
- warnings.push({
376
- level: "warn",
377
- code: "plaintext-bearer",
378
- message: `a bearer token is configured for plaintext HTTP to ${baseUrl}; the token and your memory payloads can be observed on the network.`,
379
- fix: "Use HTTPS, or tunnel over SSH. Set MEMINI_REQUIRE_HTTPS=1 to make this an error."
238
+ return { namespace, source: `local-${source}`, degraded: true };
239
+ }
240
+
241
+ // ../../../packages/memini-client/src/handshake.ts
242
+ var HANDSHAKE_TTL_MS = 10 * 60 * 1e3;
243
+ async function performHandshake(boot, facts, opts = {}) {
244
+ assertBearerTransportSafe(boot.baseUrl, boot.apiKey, {
245
+ MEMINI_REQUIRE_HTTPS: boot.requireHttps ? "1" : "0"
246
+ });
247
+ const controller = new AbortController();
248
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 2500);
249
+ try {
250
+ const headers = { "Content-Type": "application/json" };
251
+ if (boot.apiKey) headers["Authorization"] = `Bearer ${boot.apiKey}`;
252
+ if (boot.homeEnv) headers["X-Memini-Home"] = boot.homeEnv;
253
+ const body = JSON.stringify({
254
+ project: facts,
255
+ client: { name: opts.clientName, version: opts.clientVersion }
256
+ });
257
+ const res = await fetch(`${boot.baseUrl}/v1/handshake`, {
258
+ method: "POST",
259
+ headers,
260
+ body,
261
+ signal: controller.signal
380
262
  });
263
+ if (!res.ok) return void 0;
264
+ return await res.json();
265
+ } catch {
266
+ return void 0;
267
+ } finally {
268
+ clearTimeout(timer);
381
269
  }
382
- return {
383
- cwd,
384
- namespace: { effective, source, override, withoutOverride, derived, home },
385
- settings,
386
- paths: {
387
- overrides: opts.overridesPath || defaultOverridesPath(env),
388
- cache: opts.cacheDir
389
- },
390
- warnings
391
- };
392
270
  }
393
271
 
394
272
  // src/index.ts
395
- var DEFAULT_BASE_URL = "http://localhost:8080";
396
273
  var DEFAULT_TIMEOUT_MS = 3e4;
397
274
  var DEFAULT_RECALL_LIMIT = 3;
398
- var DEFAULT_NAMESPACE = "pi";
399
275
  var STATUS_TIMEOUT_MS = 4e3;
400
- var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
276
+ var HANDSHAKE_TIMEOUT_MS = 2500;
277
+ var CLIENT_NAME = "pi-memini";
278
+ function readPluginVersion() {
279
+ try {
280
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
281
+ return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
282
+ } catch {
283
+ return "0.0.0";
284
+ }
285
+ }
286
+ var CLIENT_VERSION = readPluginVersion();
401
287
  function envBool(value, fallback) {
402
288
  if (value === void 0 || value === null || value === "") return fallback;
403
289
  return !/^(0|false|no|off)$/i.test(String(value).trim());
@@ -423,65 +309,74 @@ function labelsEnv(name = "MEMINI_INJECT_LABELS") {
423
309
  raw.split(/[|,]/).map((s) => s.trim().toLowerCase()).filter(Boolean)
424
310
  );
425
311
  }
426
- function sanitizeNamespace(s) {
427
- return String(s).trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
428
- }
429
- function sanitizeNamespacePath(s) {
430
- return String(s).split("/").map(sanitizeNamespace).filter(Boolean).join("/");
431
- }
432
- function deriveNamespace(cwd) {
433
- if (typeof cwd !== "string" || !cwd.trim()) return "";
434
- const base = cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
435
- return sanitizeNamespace(base);
436
- }
437
- function resolverSource(source) {
438
- return source === "git" ? "git-remote" : source;
312
+ function memoizeAsync(fn, ttlMs, now = Date.now) {
313
+ let cached = null;
314
+ return {
315
+ async get() {
316
+ const t = now();
317
+ if (!cached || t >= cached.expiresAt) {
318
+ const value = await fn();
319
+ cached = { value, expiresAt: t + ttlMs };
320
+ }
321
+ return cached.value;
322
+ },
323
+ invalidate() {
324
+ cached = null;
325
+ }
326
+ };
439
327
  }
440
- function resolveProjectNamespace(env, cwd, opts = {}) {
441
- const e = env || {};
442
- if (!opts.ignoreOverride && cwd) {
443
- const override = readOverride(cwd, { env: e });
444
- if (override) return { namespace: override.namespace, source: "override" };
445
- }
446
- const nsEnv = (e.MEMINI_NAMESPACE || "").trim();
447
- if (nsEnv) {
448
- return { namespace: nsEnv, source: "env" };
449
- }
450
- if (cwd) {
451
- const { namespace: resolvedNs, source } = resolveNamespace({
452
- cwd,
453
- env: e,
454
- integration: "pi"
328
+ async function attemptHandshake(boot, facts, fallbackOnError) {
329
+ try {
330
+ return await performHandshake(boot, facts, {
331
+ timeoutMs: HANDSHAKE_TIMEOUT_MS,
332
+ clientName: CLIENT_NAME,
333
+ clientVersion: CLIENT_VERSION
455
334
  });
456
- const sanitized = sanitizeNamespacePath(resolvedNs);
457
- if (sanitized) return { namespace: sanitized, source: resolverSource(source) };
335
+ } catch (error) {
336
+ if (!fallbackOnError) throw error;
337
+ return void 0;
458
338
  }
459
- return { namespace: DEFAULT_NAMESPACE, source: "default" };
460
339
  }
461
- function resolveConfig(env, cwd) {
340
+ function createSessionContext(cwd, env = process.env, now = Date.now) {
341
+ const e = env;
342
+ const boot = readBootstrap(e);
343
+ const facts = gatherFacts(cwd, e);
344
+ const fallbackOnError = envBool(e.MEMINI_FALLBACK, true);
345
+ const memo = memoizeAsync(() => attemptHandshake(boot, facts, fallbackOnError), HANDSHAKE_TTL_MS, now);
346
+ return { boot, facts, memo };
347
+ }
348
+ function resolveStaticConfig(env = process.env) {
462
349
  const e = env || {};
463
- const { namespace } = resolveProjectNamespace(e, cwd);
464
- const recall_limit = (() => {
465
- const n = Number(e.MEMINI_RECALL_LIMIT);
466
- return Number.isFinite(n) && n >= 0 ? n : DEFAULT_RECALL_LIMIT;
467
- })();
468
350
  const homeEnv = (e.MEMINI_HOME || "").trim();
469
351
  return {
470
- base_url: e.MEMINI_BASE_URL || e.MEMINI_URL || DEFAULT_BASE_URL,
471
- // namespace is already resolved above (verbatim on the override/env paths,
472
- // per-segment sanitized on the resolver path); re-sanitizing here would
473
- // flatten tenant separators.
474
- namespace: namespace || DEFAULT_NAMESPACE,
475
352
  home: homeEnv || void 0,
476
- recall: envBool(e.MEMINI_RECALL, true),
477
- capture: envBool(e.MEMINI_CAPTURE, true),
478
- recall_limit,
479
- recall_max_tokens: intEnv("MEMINI_INJECT_RECALL_MAX_TOK", 0),
480
- recall_min_score: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
481
353
  timeout_ms: Number(e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
482
354
  fallback_on_error: envBool(e.MEMINI_FALLBACK, true)
483
355
  };
484
356
  }
357
+ function knob(wireKey) {
358
+ const k = BEHAVIOR_KNOBS.find((b) => b.wireKey === wireKey);
359
+ if (!k) throw new Error(`pi-memini: unknown behavior knob "${wireKey}"`);
360
+ return k;
361
+ }
362
+ function resolveLiveConfig(boot, facts, hs, env = process.env) {
363
+ const resolved = resolveNamespace(boot, facts, hs);
364
+ const server = hs?.settings;
365
+ return {
366
+ namespace: resolved.namespace,
367
+ namespace_source: resolved.source,
368
+ degraded: resolved.degraded,
369
+ recall: effectiveSetting(knob("recall"), server, env).value,
370
+ capture: effectiveSetting(knob("capture"), server, env).value,
371
+ recall_limit: effectiveSetting(knob("recall_limit"), server, env).value,
372
+ recall_max_tokens: effectiveSetting(knob("inject_recall_max_tok"), server, env).value,
373
+ recall_min_score: effectiveSetting(knob("inject_recall_min_score"), server, env).value
374
+ };
375
+ }
376
+ async function sessionLive(ctx, env = process.env) {
377
+ const hs = await ctx.memo.get();
378
+ return resolveLiveConfig(ctx.boot, ctx.facts, hs, env);
379
+ }
485
380
  function approxTokens(text) {
486
381
  if (!text) return 0;
487
382
  const words = String(text).trim().split(/\s+/).filter(Boolean).length;
@@ -535,25 +430,13 @@ function formatResults(results, limit, labels) {
535
430
  return `[${tagParts.join(" \xB7 ")}] ${text}`;
536
431
  }).filter((x) => Boolean(x));
537
432
  }
538
- function normalizedHostname(hostname) {
539
- return hostname.replace(/^\[|\]$/g, "").toLowerCase();
540
- }
541
- function usesPlaintextBearerAuth(baseUrl, secret) {
542
- if (!secret) return false;
543
- try {
544
- const parsed = new URL(baseUrl);
545
- return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
546
- } catch {
547
- return false;
548
- }
549
- }
550
433
  function plaintextBearerAuthMessage(baseUrl) {
551
434
  return `memini: MEMINI_API_KEY is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`;
552
435
  }
553
436
  function createPlaintextBearerAuthGuard(warn, env) {
554
437
  let warned = false;
555
438
  return function guardPlaintextBearerAuth(baseUrl, secret) {
556
- if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
439
+ if (!isPlaintextBearerUnsafe(baseUrl, secret || "")) return;
557
440
  const message = plaintextBearerAuthMessage(baseUrl);
558
441
  if ((env || process.env).MEMINI_REQUIRE_HTTPS === "1") throw new Error(message);
559
442
  if (!warned) {
@@ -562,53 +445,53 @@ function createPlaintextBearerAuthGuard(warn, env) {
562
445
  }
563
446
  };
564
447
  }
565
- function createClient(cfg, warn) {
566
- const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
567
- const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
448
+ function createClient(staticCfg, boot, warn) {
449
+ const baseUrl = String(boot.baseUrl).replace(/\/+$/, "");
450
+ const secret = boot.apiKey;
568
451
  const guard = createPlaintextBearerAuthGuard(warn);
569
- if (process.env.MEMINI_REQUIRE_HTTPS === "1") guard(baseUrl, secret);
570
- function headers(extra) {
571
- const h = { "X-Memini-Namespace": cfg.namespace, ...extra || {} };
452
+ if (boot.requireHttps) guard(baseUrl, secret);
453
+ function headers(namespace, extra) {
454
+ const h = { "X-Memini-Namespace": namespace, ...extra || {} };
572
455
  if (secret) h.Authorization = `Bearer ${secret}`;
573
- if (cfg.home) h["X-Memini-Home"] = cfg.home;
456
+ if (staticCfg.home) h["X-Memini-Home"] = staticCfg.home;
574
457
  return h;
575
458
  }
576
- async function request(method, path3, body) {
459
+ async function request(method, path2, namespace, body) {
577
460
  guard(baseUrl, secret);
578
461
  try {
579
- const res = await fetch(`${baseUrl}${path3}`, {
462
+ const res = await fetch(`${baseUrl}${path2}`, {
580
463
  method,
581
- headers: headers(body ? { "Content-Type": "application/json" } : void 0),
464
+ headers: headers(namespace, body ? { "Content-Type": "application/json" } : void 0),
582
465
  body: body ? JSON.stringify(body) : void 0,
583
- signal: AbortSignal.timeout(cfg.timeout_ms)
466
+ signal: AbortSignal.timeout(staticCfg.timeout_ms)
584
467
  });
585
468
  if (!res.ok) {
586
- if (cfg.fallback_on_error) {
587
- warn(`memini ${method} ${path3} failed: ${res.status}`);
469
+ if (staticCfg.fallback_on_error) {
470
+ warn(`memini ${method} ${path2} failed: ${res.status}`);
588
471
  return null;
589
472
  }
590
473
  const text = await res.text().catch(() => "");
591
- throw new Error(`memini ${method} ${path3} failed: ${res.status} ${text}`);
474
+ throw new Error(`memini ${method} ${path2} failed: ${res.status} ${text}`);
592
475
  }
593
476
  return await res.json().catch(() => ({ ok: true }));
594
477
  } catch (error) {
595
- if (!cfg.fallback_on_error) throw error;
478
+ if (!staticCfg.fallback_on_error) throw error;
596
479
  warn(`memini: ${String(error)}`);
597
480
  return null;
598
481
  }
599
482
  }
600
- async function requestResult(method, path3, body) {
483
+ async function requestResult(method, path2, namespace, body) {
601
484
  try {
602
485
  guard(baseUrl, secret);
603
- const res = await fetch(`${baseUrl}${path3}`, {
486
+ const res = await fetch(`${baseUrl}${path2}`, {
604
487
  method,
605
- headers: headers(body ? { "Content-Type": "application/json" } : void 0),
488
+ headers: headers(namespace, body ? { "Content-Type": "application/json" } : void 0),
606
489
  body: body ? JSON.stringify(body) : void 0,
607
- signal: AbortSignal.timeout(cfg.timeout_ms)
490
+ signal: AbortSignal.timeout(staticCfg.timeout_ms)
608
491
  });
609
492
  if (!res.ok) {
610
493
  const detail = (await res.text().catch(() => "")).trim();
611
- warn(`memini ${method} ${path3} failed: ${res.status} ${detail}`);
494
+ warn(`memini ${method} ${path2} failed: ${res.status} ${detail}`);
612
495
  return { ok: false, error: detail || `HTTP ${res.status}` };
613
496
  }
614
497
  return { ok: true, data: await res.json().catch(() => ({})) };
@@ -618,10 +501,10 @@ function createClient(cfg, warn) {
618
501
  }
619
502
  }
620
503
  return {
621
- postJson: (path3, payload) => request("POST", path3, payload),
622
- getJson: (path3) => request("GET", path3),
623
- deleteJson: (path3) => request("DELETE", path3),
624
- postJsonResult: (path3, payload) => requestResult("POST", path3, payload)
504
+ postJson: (path2, payload, namespace) => request("POST", path2, namespace, payload),
505
+ getJson: (path2, namespace) => request("GET", path2, namespace),
506
+ deleteJson: (path2, namespace) => request("DELETE", path2, namespace),
507
+ postJsonResult: (path2, payload, namespace) => requestResult("POST", path2, namespace, payload)
625
508
  };
626
509
  }
627
510
  function meminiListPath(args) {
@@ -660,37 +543,69 @@ function buildTurnContent(userText, assistantText) {
660
543
 
661
544
  ${String(assistantText).slice(0, 3e3)}`;
662
545
  }
663
- async function statusGet(cfg, namespace, path3, warn, quiet = false) {
664
- const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
665
- const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
546
+ function pinKeyFacts(facts) {
547
+ const out = {};
548
+ if (facts.remote_url) out.remote_url = facts.remote_url;
549
+ if (facts.toplevel_path) out.toplevel_path = facts.toplevel_path;
550
+ return out;
551
+ }
552
+ async function pinsRequest(boot, method, body) {
553
+ assertBearerTransportSafe(boot.baseUrl, boot.apiKey);
554
+ const headers = { "Content-Type": "application/json" };
555
+ if (boot.apiKey) headers.Authorization = `Bearer ${boot.apiKey}`;
556
+ if (boot.homeEnv) headers["X-Memini-Home"] = boot.homeEnv;
557
+ const res = await fetch(`${boot.baseUrl}/v1/pins`, {
558
+ method,
559
+ headers,
560
+ body: JSON.stringify(body),
561
+ signal: AbortSignal.timeout(5e3)
562
+ });
563
+ let parsed = null;
564
+ try {
565
+ parsed = await res.json();
566
+ } catch {
567
+ }
568
+ return { ok: res.ok, status: res.status, body: parsed };
569
+ }
570
+ function pinErrorMessage(res) {
571
+ return res.body?.error || res.body?.message || `HTTP ${res.status}`;
572
+ }
573
+ function offlineMessage(boot, error) {
574
+ const detail = String(error?.message || error);
575
+ return `${detail}
576
+
577
+ Could not reach the memini server at ${boot.baseUrl}. Pins live on the server, so setting one needs it reachable. For an offline, machine-local override instead, export MEMINI_NAMESPACE=<namespace>.`;
578
+ }
579
+ async function statusGet(boot, namespace, path2, warn, quiet = false) {
580
+ const baseUrl = String(boot.baseUrl).replace(/\/+$/, "");
666
581
  const headers = { "X-Memini-Namespace": namespace };
667
- if (secret) headers.Authorization = `Bearer ${secret}`;
668
- if (cfg.home) headers["X-Memini-Home"] = cfg.home;
582
+ if (boot.apiKey) headers.Authorization = `Bearer ${boot.apiKey}`;
583
+ if (boot.homeEnv) headers["X-Memini-Home"] = boot.homeEnv;
669
584
  try {
670
- const res = await fetch(`${baseUrl}${path3}`, {
585
+ const res = await fetch(`${baseUrl}${path2}`, {
671
586
  method: "GET",
672
587
  headers,
673
588
  signal: AbortSignal.timeout(STATUS_TIMEOUT_MS)
674
589
  });
675
590
  if (!res.ok) {
676
- if (!quiet) warn(`GET ${path3} -> ${res.status}`);
591
+ if (!quiet) warn(`GET ${path2} -> ${res.status}`);
677
592
  return null;
678
593
  }
679
594
  return await res.json();
680
595
  } catch (error) {
681
- if (!quiet) warn(`GET ${path3} failed: ${String(error)}`);
596
+ if (!quiet) warn(`GET ${path2} failed: ${String(error)}`);
682
597
  return null;
683
598
  }
684
599
  }
685
- async function fetchServer(cfg, namespace, warn) {
600
+ async function fetchServer(boot, namespace, warn) {
686
601
  const started = Date.now();
687
- const readSet = await statusGet(cfg, namespace, "/v1/namespaces/read-set", warn);
602
+ const readSet = await statusGet(boot, namespace, "/v1/namespaces/readset", warn);
688
603
  const out = {
689
604
  reachable: readSet != null,
690
605
  latencyMs: Date.now() - started,
691
606
  readSet
692
607
  };
693
- const health = await statusGet(cfg, namespace, "/healthz?verbose=1", warn, true);
608
+ const health = await statusGet(boot, namespace, "/healthz?verbose=1", warn, true);
694
609
  if (health) {
695
610
  out.version = health.version;
696
611
  out.status = health.status;
@@ -701,49 +616,75 @@ async function fetchServer(cfg, namespace, warn) {
701
616
  return out;
702
617
  }
703
618
  var pad = (s, n) => String(s).padEnd(n);
704
- function renderStatus(settings, cfg, server) {
705
- const ns = settings.namespace;
619
+ function buildWarnings(ctx, live, hs) {
620
+ const warnings = [];
621
+ if (live.degraded) {
622
+ warnings.push({
623
+ level: "warn",
624
+ code: "degraded-mode",
625
+ message: `could not reach the memini server at ${ctx.boot.baseUrl}: the namespace is local-derived and every setting is a built-in default, not what the server would return.`,
626
+ fix: "Check MEMINI_BASE_URL and that the server is running; recall and capture are both failing until it is reachable."
627
+ });
628
+ }
629
+ if (ctx.boot.namespaceEnv && hs?.namespace_source !== "pin") {
630
+ warnings.push({
631
+ level: "warn",
632
+ code: "global-namespace-pin",
633
+ message: `MEMINI_NAMESPACE is set to "${ctx.boot.namespaceEnv}", which pins EVERY project on this machine to one namespace (unless this repo has a stronger server-side pin). If it is exported from a shell rc (or a fish universal variable), every repo you work in is sharing one memory pool.`,
634
+ fix: "Set a pin instead: /memini:namespace <ns> (a pin beats the environment)."
635
+ });
636
+ }
637
+ if (!ctx.boot.homeEnv) {
638
+ warnings.push({
639
+ level: "warn",
640
+ code: "home-unset",
641
+ message: 'MEMINI_HOME is unset: there is no personal namespace, so visibility:"personal" writes will error and no personal leg merges into recall.',
642
+ fix: "Export MEMINI_HOME=personal/<you>."
643
+ });
644
+ }
645
+ if (isPlaintextBearerUnsafe(ctx.boot.baseUrl, ctx.boot.apiKey)) {
646
+ warnings.push({
647
+ level: "warn",
648
+ code: "plaintext-bearer",
649
+ message: `a bearer token is configured for plaintext HTTP to ${ctx.boot.baseUrl}; the token and your memory payloads can be observed on the network.`,
650
+ fix: "Use HTTPS, or tunnel over SSH. Set MEMINI_REQUIRE_HTTPS=1 to make this an error."
651
+ });
652
+ }
653
+ return warnings;
654
+ }
655
+ function renderStatus(ctx, staticCfg, live, hs, server) {
706
656
  const L = [];
707
657
  L.push(`memini \u2014 effective settings (pi)`);
708
- L.push(`cwd: ${settings.cwd}`);
658
+ L.push(`cwd: ${ctx.facts.toplevel_path || process.cwd()}`);
709
659
  L.push("");
710
660
  L.push(`NAMESPACE`);
711
- L.push(` ${pad("effective", 28)} ${pad(ns.effective, 34)} <- ${ns.source}`);
712
- if (ns.override) {
713
- L.push(
714
- ` ${pad("without the override", 28)} ${pad(ns.withoutOverride.namespace, 34)} <- ${ns.withoutOverride.source}`
715
- );
716
- }
717
- if (ns.derived.namespace !== ns.effective) {
718
- L.push(` ${pad("git/cwd would give", 28)} ${pad(ns.derived.namespace, 34)} <- ${ns.derived.source}`);
661
+ L.push(` ${pad("effective", 28)} ${pad(live.namespace, 34)} <- ${live.namespace_source}`);
662
+ if (live.degraded) {
663
+ const local = deriveLocalNamespace(ctx.facts);
664
+ if (local.namespace !== live.namespace) {
665
+ L.push(` ${pad("git/cwd would give", 28)} ${pad(local.namespace, 34)} <- local-${local.source}`);
666
+ }
719
667
  }
720
- L.push(` ${pad("home (personal)", 28)} ${ns.home || "(unset)"}`);
668
+ L.push(` ${pad("home (personal)", 28)} ${ctx.boot.homeEnv || "(unset)"}`);
721
669
  L.push("");
722
- const groups = [
723
- ["CONNECTION", ["MEMINI_BASE_URL", "MEMINI_API_KEY", "MEMINI_REQUIRE_HTTPS"]],
724
- ["NAMESPACE INPUTS", ["MEMINI_NAMESPACE", "MEMINI_NAMESPACE_SCOPE", "MEMINI_AGENT", "MEMINI_HOME"]]
725
- ];
726
- for (const [group, names] of groups) {
727
- const rows = (settings.settings || []).filter((s) => names.includes(s.name));
728
- if (!rows.length) continue;
729
- L.push(group);
730
- for (const r of rows) {
731
- const origin = r.source === "env" ? `<- env` : `(default)`;
732
- L.push(` ${pad(r.name.replace(/^MEMINI_/, "").toLowerCase(), 28)} ${pad(r.value, 34)} ${origin}`);
733
- }
734
- L.push("");
670
+ L.push(`SETTINGS`);
671
+ const wireKeys = ["recall", "capture", "recall_limit", "inject_recall_max_tok", "inject_recall_min_score"];
672
+ for (const wireKey of wireKeys) {
673
+ const k = knob(wireKey);
674
+ const { value, source } = effectiveSetting(k, hs?.settings, process.env);
675
+ const origin = source === "env-override" ? "<- env" : source === "server" ? "<- server" : "(default)";
676
+ L.push(` ${pad(k.envName.replace(/^MEMINI_/, "").toLowerCase(), 28)} ${pad(String(value), 22)} ${origin}`);
735
677
  }
736
- const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN || "";
678
+ L.push("");
679
+ const secret = ctx.boot.apiKey;
737
680
  L.push(`EXTENSION`);
738
- L.push(` ${pad("recall", 28)} ${cfg.recall ? "on" : "off"}`);
739
- L.push(` ${pad("capture", 28)} ${cfg.capture ? "on" : "off"}`);
740
- L.push(` ${pad("recall_limit", 28)} ${cfg.recall_limit}`);
741
- L.push(` ${pad("timeout_ms", 28)} ${cfg.timeout_ms}`);
681
+ L.push(` ${pad("base_url", 28)} ${ctx.boot.baseUrl}`);
682
+ L.push(` ${pad("timeout_ms", 28)} ${staticCfg.timeout_ms}`);
742
683
  L.push(` ${pad("bearer", 28)} ${secret ? redactValue(secret) : "(none)"}`);
743
684
  L.push("");
744
685
  L.push(`SERVER`);
745
686
  if (!server.reachable) {
746
- L.push(` ${pad("reachable", 28)} NO \u2014 could not reach ${cfg.base_url}`);
687
+ L.push(` ${pad("reachable", 28)} NO \u2014 could not reach ${ctx.boot.baseUrl}`);
747
688
  } else {
748
689
  const ver = server.version ? `, ${server.version}` : "";
749
690
  L.push(` ${pad("reachable", 28)} yes (${server.latencyMs}ms${ver})`);
@@ -758,7 +699,7 @@ function renderStatus(settings, cfg, server) {
758
699
  }
759
700
  L.push("");
760
701
  if (server.readSet?.entries?.length) {
761
- L.push(`READ SET for "${ns.effective}" \u2014 where a plain recall looks`);
702
+ L.push(`READ SET for "${live.namespace}" \u2014 where a plain recall looks`);
762
703
  L.push(` ${pad("NAMESPACE", 34)} ${pad("ORIGIN", 12)} TIERS`);
763
704
  for (const e of server.readSet.entries) {
764
705
  const tiers = Array.isArray(e.tiers) && e.tiers.length ? e.tiers.join(",") : "all";
@@ -766,12 +707,10 @@ function renderStatus(settings, cfg, server) {
766
707
  }
767
708
  L.push("");
768
709
  }
769
- L.push(`PATHS`);
770
- L.push(` ${pad("overrides", 28)} ${settings.paths.overrides}`);
771
- L.push("");
772
- if (settings.warnings.length) {
710
+ const warnings = buildWarnings(ctx, live, hs);
711
+ if (warnings.length) {
773
712
  L.push(`WARNINGS`);
774
- for (const w of settings.warnings) {
713
+ for (const w of warnings) {
775
714
  L.push(` [${w.level === "warn" ? "!" : "i"}] ${w.code}: ${w.message}`);
776
715
  if (w.fix) L.push(` fix: ${w.fix}`);
777
716
  }
@@ -780,69 +719,94 @@ function renderStatus(settings, cfg, server) {
780
719
  }
781
720
  return L.join("\n");
782
721
  }
783
- function registerMeminiCommands(pi, cfg, warn) {
722
+ function registerMeminiCommands(pi, ctx, staticCfg, warn) {
784
723
  const show = (content) => {
785
724
  pi.sendMessage({ customType: "memini-status", content, display: true });
786
725
  };
787
726
  pi.registerCommand("memini:status", {
788
727
  description: "Show memini's effective settings: namespace + provenance, connection, server read set",
789
- handler: async (_args, ctx) => {
728
+ handler: async (_args, cmdCtx) => {
790
729
  try {
791
- const cwd = ctx.cwd || process.cwd();
792
- const settings = describeSettings({
793
- cwd,
794
- env: process.env,
795
- // Hand describeSettings THIS harness's resolver, so what it reports is
796
- // what the extension actually does. The opts pass-through carries
797
- // ignoreOverride, which is how the counterfactual lines see past an
798
- // override (it lives in a file, so no env-doctoring would remove it).
799
- resolve: (env, o) => resolveProjectNamespace(env, cwd, o)
800
- });
801
- const server = await fetchServer(cfg, settings.namespace.effective, warn);
802
- show(renderStatus(settings, cfg, server));
730
+ const hs = await ctx.memo.get();
731
+ const live = resolveLiveConfig(ctx.boot, ctx.facts, hs, process.env);
732
+ const server = await fetchServer(ctx.boot, live.namespace, warn);
733
+ show(renderStatus(ctx, staticCfg, live, hs, server));
803
734
  } catch (error) {
804
- ctx.ui.notify(`memini: status failed: ${String(error)}`, "error");
735
+ cmdCtx.ui.notify(`memini: status failed: ${String(error)}`, "error");
805
736
  }
806
737
  }
807
738
  });
808
739
  pi.registerCommand("memini:namespace", {
809
- description: "Show, set, or --clear the memini namespace override for this project",
810
- handler: async (args, ctx) => {
740
+ description: "Show, set, or --clear the memini namespace pin for this project (server-side)",
741
+ handler: async (args, cmdCtx) => {
811
742
  try {
812
- const cwd = ctx.cwd || process.cwd();
813
743
  const arg = String(args || "").trim();
814
- const before = resolveProjectNamespace(process.env, cwd);
744
+ const { boot, facts } = ctx;
815
745
  if (!arg) {
816
- const current = readOverride(cwd, { env: process.env });
817
- const out = [
818
- `namespace: ${before.namespace} (source: ${before.source})`,
819
- `project: ${overrideKey(cwd)}`,
820
- ``
821
- ];
822
- if (current) {
823
- out.push(`An override is active (set ${current.setAt}).`);
824
- out.push(`Clear it with: /memini:namespace --clear`);
825
- } else {
826
- out.push(`No override \u2014 resolving automatically.`);
827
- out.push(`Set one with: /memini:namespace <namespace>`);
746
+ const hs = await ctx.memo.get();
747
+ const live = resolveLiveConfig(boot, facts, hs, process.env);
748
+ const out = [];
749
+ if (!hs) {
750
+ out.push(`namespace: ${live.namespace} (${live.namespace_source} \u2014 server unreachable)`);
751
+ out.push("");
752
+ out.push(`Could not reach ${boot.baseUrl}, so this is a local guess, not the server's authority.`);
753
+ out.push(`A pin (if any) can only be read from the server.`);
754
+ show(out.join("\n"));
755
+ return;
756
+ }
757
+ out.push(`namespace: ${hs.namespace} (source: ${hs.namespace_source})`);
758
+ if (hs.namespace_source === "pin" && hs.pin) {
759
+ out.push(`pin: key ${hs.pin.key}`);
760
+ if (hs.pin.created_by) out.push(` set by ${hs.pin.created_by}`);
761
+ if (hs.pin.updated_at) out.push(` updated ${hs.pin.updated_at}`);
762
+ if (hs.pin.note) out.push(` note: ${hs.pin.note}`);
763
+ if (boot.namespaceEnv) {
764
+ out.push("");
765
+ out.push(`MEMINI_NAMESPACE is set to "${boot.namespaceEnv}", but the pin wins \u2014 a pin`);
766
+ out.push(`beats the environment on purpose.`);
767
+ }
768
+ } else if (hs.namespace_source === "env") {
769
+ out.push("");
770
+ out.push(`This comes from MEMINI_NAMESPACE, which pins EVERY project on this machine to`);
771
+ out.push(`one namespace. To scope just this project, set a pin: /memini:namespace <ns>`);
772
+ out.push(`(a pin beats the environment).`);
828
773
  }
829
- out.push(`Overrides file: ${defaultOverridesPath(process.env)}`);
774
+ out.push("");
775
+ out.push(`Set a pin with: /memini:namespace <namespace>`);
776
+ out.push(`Clear it with: /memini:namespace --clear`);
830
777
  show(out.join("\n"));
831
778
  return;
832
779
  }
833
780
  if (arg === "--clear" || arg === "clear") {
834
- const removed = clearOverride(cwd, { env: process.env });
835
- if (!removed) {
836
- show(`No override was set for ${overrideKey(cwd)} \u2014 nothing to clear.`);
781
+ const keyFacts2 = pinKeyFacts(facts);
782
+ if (!keyFacts2.remote_url && !keyFacts2.toplevel_path) {
783
+ cmdCtx.ui.notify(
784
+ `memini: this project has no git remote or toplevel, so it cannot have a pin to clear.`,
785
+ "error"
786
+ );
837
787
  return;
838
788
  }
839
- const after2 = resolveProjectNamespace(process.env, cwd);
840
- cfg.namespace = after2.namespace;
789
+ let res2;
790
+ try {
791
+ res2 = await pinsRequest(boot, "DELETE", keyFacts2);
792
+ } catch (error) {
793
+ cmdCtx.ui.notify(`memini: ${offlineMessage(boot, error)}`, "error");
794
+ return;
795
+ }
796
+ if (res2.status === 404) {
797
+ show(`No pin was set for this project \u2014 nothing to clear.`);
798
+ return;
799
+ }
800
+ if (!res2.ok) {
801
+ cmdCtx.ui.notify(`memini: could not clear the pin: ${pinErrorMessage(res2)}`, "error");
802
+ return;
803
+ }
804
+ ctx.memo.invalidate();
841
805
  show(
842
806
  [
843
- `namespace override cleared: ${before.namespace} -> ${after2.namespace} (source: ${after2.source})`,
807
+ `namespace pin cleared \u2014 this project resolves automatically again.`,
844
808
  ``,
845
- `Recall and capture use the new namespace from the next turn.`
809
+ `Recall and capture use the new resolution from the next turn.`
846
810
  ].join("\n")
847
811
  );
848
812
  return;
@@ -850,22 +814,42 @@ function registerMeminiCommands(pi, cfg, warn) {
850
814
  const ns = normalizeNamespace(arg);
851
815
  const bad = validateNamespace(ns);
852
816
  if (bad) {
853
- ctx.ui.notify(`memini: invalid namespace ${JSON.stringify(arg)}: ${bad}`, "error");
817
+ cmdCtx.ui.notify(`memini: invalid namespace ${JSON.stringify(arg)}: ${bad}`, "error");
854
818
  return;
855
819
  }
856
- writeOverride(cwd, ns, { env: process.env });
857
- const after = resolveProjectNamespace(process.env, cwd);
858
- cfg.namespace = after.namespace;
820
+ const keyFacts = pinKeyFacts(facts);
821
+ if (!keyFacts.remote_url && !keyFacts.toplevel_path) {
822
+ cmdCtx.ui.notify(
823
+ `memini: this project has no git remote or toplevel to pin a namespace to. A pin is keyed by the project's git identity; run inside a git repository, or export MEMINI_NAMESPACE=${ns} for a machine-local override.`,
824
+ "error"
825
+ );
826
+ return;
827
+ }
828
+ let res;
829
+ try {
830
+ res = await pinsRequest(boot, "PUT", { namespace: ns, ...keyFacts });
831
+ } catch (error) {
832
+ cmdCtx.ui.notify(`memini: ${offlineMessage(boot, error)}`, "error");
833
+ return;
834
+ }
835
+ if (!res.ok) {
836
+ cmdCtx.ui.notify(`memini: could not set the pin: ${pinErrorMessage(res)}`, "error");
837
+ return;
838
+ }
839
+ ctx.memo.invalidate();
840
+ const entry = res.body || {};
859
841
  show(
860
842
  [
861
- `namespace override set: ${before.namespace} -> ${after.namespace}`,
862
- `project: ${overrideKey(cwd)}`,
843
+ `namespace pinned: ${entry.namespace || ns}`,
844
+ `project key: ${entry.key || keyFacts.remote_url || keyFacts.toplevel_path}`,
863
845
  ``,
864
- `The override wins over MEMINI_NAMESPACE. Recall and capture use it from the next turn.`
846
+ `Recall and capture use it from the next turn. The pin lives on the memini server, so it`,
847
+ `follows you across machines and every client resolves the same namespace. It beats`,
848
+ `MEMINI_NAMESPACE.`
865
849
  ].join("\n")
866
850
  );
867
851
  } catch (error) {
868
- ctx.ui.notify(`memini: namespace failed: ${String(error)}`, "error");
852
+ cmdCtx.ui.notify(`memini: namespace failed: ${String(error)}`, "error");
869
853
  }
870
854
  }
871
855
  });
@@ -898,10 +882,11 @@ function meminiExtension(pi) {
898
882
  } catch {
899
883
  }
900
884
  };
901
- const cfg = resolveConfig(process.env, process.cwd());
902
- const client = createClient(cfg, warn);
885
+ const sessionCtx = createSessionContext(process.cwd(), process.env);
886
+ const staticCfg = resolveStaticConfig(process.env);
887
+ const client = createClient(staticCfg, sessionCtx.boot, warn);
903
888
  try {
904
- if (typeof pi.registerCommand === "function") registerMeminiCommands(pi, cfg, warn);
889
+ if (typeof pi.registerCommand === "function") registerMeminiCommands(pi, sessionCtx, staticCfg, warn);
905
890
  } catch (error) {
906
891
  warn(`command registration skipped: ${String(error)}`);
907
892
  }
@@ -966,22 +951,23 @@ function meminiExtension(pi) {
966
951
  const sid = sessionIdOf(ctx);
967
952
  const query = String(event?.prompt || "").trim();
968
953
  if (query && sid) rememberPendingUser(sid, query);
969
- if (!cfg.recall || !query) return;
970
- const body = { query, limit: cfg.recall_limit };
954
+ const live = await sessionLive(sessionCtx);
955
+ if (!live.recall || !query) return;
956
+ const body = { query, limit: live.recall_limit };
971
957
  if (sid) body.exclude_metadata = { session_id: sid };
972
- if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
958
+ if (live.recall_min_score > 0) body.min_score = live.recall_min_score;
973
959
  const excludeIds = sid ? [...injectedBySession.get(sid) ?? []] : [];
974
960
  const result = await searchExcluding(body, excludeIds);
975
- const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
961
+ const floor = live.recall_min_score > 0 ? live.recall_min_score : 0;
976
962
  let rawHits = Array.isArray(result?.results) ? result.results : [];
977
963
  if (sid) {
978
964
  const seen = injectedBySession.get(sid);
979
965
  if (seen?.size) rawHits = rawHits.filter((r) => !seen.has(r?.memory?.id));
980
966
  }
981
967
  const filtered = floor > 0 ? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor) : rawHits;
982
- const hits = formatResults(filtered, cfg.recall_limit, labelsEnv());
968
+ const hits = formatResults(filtered, live.recall_limit, labelsEnv());
983
969
  if (hits.length === 0) return;
984
- const fit = fitByTokens(hits, cfg.recall_max_tokens);
970
+ const fit = fitByTokens(hits, live.recall_max_tokens);
985
971
  if (fit.items.length === 0) return;
986
972
  if (sid) {
987
973
  rememberInjected(sid, filtered.map((r) => r?.memory?.id).filter(Boolean));
@@ -1005,7 +991,8 @@ function meminiExtension(pi) {
1005
991
  };
1006
992
  });
1007
993
  pi.on("agent_end", async (event, ctx) => {
1008
- if (!cfg.capture) return;
994
+ const live = await sessionLive(sessionCtx);
995
+ if (!live.capture) return;
1009
996
  const sid = sessionIdOf(ctx);
1010
997
  const userText = sid && pendingUser.get(sid) || "";
1011
998
  const assistantText = extractLastAssistantText(event?.messages);
@@ -1014,11 +1001,15 @@ function meminiExtension(pi) {
1014
1001
  if (dedupKey && captured.has(dedupKey)) return;
1015
1002
  const metadata = { source: "pi", format: "turn" };
1016
1003
  if (sid) metadata.session_id = sid;
1017
- const stored = await client.postJson("/v1/memories", {
1018
- content: buildTurnContent(userText, assistantText),
1019
- tags: ["pi"],
1020
- metadata
1021
- });
1004
+ const stored = await client.postJson(
1005
+ "/v1/memories",
1006
+ {
1007
+ content: buildTurnContent(userText, assistantText),
1008
+ tags: ["pi"],
1009
+ metadata
1010
+ },
1011
+ live.namespace
1012
+ );
1022
1013
  if (stored !== null) {
1023
1014
  if (dedupKey) rememberCaptured(dedupKey);
1024
1015
  if (sid) pendingUser.delete(sid);
@@ -1051,11 +1042,12 @@ function meminiExtension(pi) {
1051
1042
  scope: Scope
1052
1043
  }),
1053
1044
  async execute(_toolCallId, params) {
1045
+ const live = await sessionLive(sessionCtx);
1054
1046
  const body = { query: params.query, limit: params.limit || DEFAULT_RECALL_LIMIT };
1055
1047
  if (params.tags?.length) body.tags = params.tags;
1056
1048
  if (params.metadata && Object.keys(params.metadata).length) body.metadata = params.metadata;
1057
1049
  if (VALID_SCOPES.includes(params.scope)) body.scope = params.scope;
1058
- const res = await client.postJson("/v1/search", body);
1050
+ const res = await client.postJson("/v1/search", body, live.namespace);
1059
1051
  const results = (res?.results || []).map((r) => {
1060
1052
  const mem = r?.memory || {};
1061
1053
  const out = {
@@ -1078,7 +1070,8 @@ function meminiExtension(pi) {
1078
1070
  description: "Layered session-start briefing for this project from long-term memory (memini) \u2014 pinned context, durable facts, how-to procedures, and recent activity \u2014 in one query-less call. Call it when a session opens to orient yourself; prefer it over broad recall queries at session start. The scope_header line ('Scope: acme/phoenix/api \u2190 acme/phoenix(3) \u2190 acme(4) \u2190 personal(2)') spells out the ancestor chain you inherit from \u2014 read it instead of guessing namespace paths, and name one of those ancestors as memory_remember's visibility to share a fact up that chain. scope='everywhere' also briefs nested sub-projects.",
1079
1071
  parameters: Type.Object({ scope: Scope }),
1080
1072
  async execute(_toolCallId, params) {
1081
- const res = await client.getJson(briefingPath(params));
1073
+ const live = await sessionLive(sessionCtx);
1074
+ const res = await client.getJson(briefingPath(params), live.namespace);
1082
1075
  if (!res) return text({ briefing: null, error: "memini unavailable" });
1083
1076
  const section = (items) => (items || []).map((b) => {
1084
1077
  const mem = b?.memory || {};
@@ -1110,8 +1103,9 @@ function meminiExtension(pi) {
1110
1103
  limit: Type.Optional(Type.Number({ description: "Max results (0 = all, default 20)" }))
1111
1104
  }),
1112
1105
  async execute(_toolCallId, params) {
1106
+ const live = await sessionLive(sessionCtx);
1113
1107
  const args = { ...params, limit: params.limit ?? 20 };
1114
- const res = await client.getJson(meminiListPath(args));
1108
+ const res = await client.getJson(meminiListPath(args), live.namespace);
1115
1109
  const memories = (res?.memories || []).map((m) => ({
1116
1110
  id: m.id || "",
1117
1111
  content: m.content || "",
@@ -1126,7 +1120,8 @@ function meminiExtension(pi) {
1126
1120
  pi.registerTool({
1127
1121
  name: "memory_remember",
1128
1122
  label: "Remember",
1129
- description: "Store a durable fact, decision, or preference in long-term memory (memini). Call proactively when the user says 'remember this', after an architectural decision (capture the why), or after discovering a non-obvious bug or convention. Keep memories atomic \u2014 one self-contained fact per call. Don't store what's already in project docs or trivially recoverable from code. To correct an existing memory, pass its id \u2014 the write updates it in place. visibility decides who should know: 'project' (default) keeps it here; 'personal' follows the user everywhere; or name an ancestor from the memory_briefing Scope line to share it up that chain. reinforced=true in the result means the fact was ALREADY KNOWN: no new memory was created, the existing one was strengthened, and `id` names that pre-existing memory rather than anything you just wrote \u2014 do not report it to the user as a new save.",
1123
+ // Save-policy invariants are canonical in internal/api/mcp/mcp.go serverInstructions.
1124
+ description: "Store a fact, decision, preference, or event for later recall. Do not wait to be asked \u2014 call this the moment you learn: a decision and why it was made, a bug's root cause, a project convention, a stated user preference, a correction from the user (a correction IS a durable preference), an environment or tool quirk, or a non-obvious command/workflow. When the user says 'remember this', 'note that', 'don't forget', 'going forward...', or corrects you, call this tool FIRST, then acknowledge \u2014 and on an explicit request save unconditionally, even if it seems trivial or already stored; secrets and credentials are the one exception. Keep memories atomic \u2014 one self-contained fact per call; search works better on small records. Do NOT store secrets or credentials, transient session state, task progress, or facts already in project docs/CLAUDE.md or trivially recoverable from code. To correct an existing memory, pass its id \u2014 the write updates it in place. If a stored memory proves wrong or outdated, fix it immediately: re-save the corrected fact with the existing id, or delete it with memory_forget if it should not exist \u2014 never leave a known-incorrect memory in place. visibility decides who should know: 'project' (default) keeps it here; 'personal' follows the user everywhere; or name an ancestor from the memory_briefing Scope line to share it up that chain. reinforced=true in the result means the fact was ALREADY KNOWN: no new memory was created, the existing one was strengthened, and `id` names that pre-existing memory rather than anything you just wrote \u2014 do not report it to the user as a new save.",
1130
1125
  parameters: Type.Object({
1131
1126
  content: Type.String({ description: "The fact to remember \u2014 atomic and self-contained." }),
1132
1127
  id: Type.Optional(
@@ -1156,6 +1151,7 @@ function meminiExtension(pi) {
1156
1151
  )
1157
1152
  }),
1158
1153
  async execute(_toolCallId, params) {
1154
+ const live = await sessionLive(sessionCtx);
1159
1155
  const body = { content: params.content };
1160
1156
  if (params.id) body.id = params.id;
1161
1157
  if (params.tier && VALID_TIERS.includes(params.tier)) body.tier = params.tier;
@@ -1163,7 +1159,7 @@ function meminiExtension(pi) {
1163
1159
  if (params.category) body.metadata = { category: params.category };
1164
1160
  const visibility = String(params.visibility || "").trim();
1165
1161
  if (visibility) body.visibility = visibility;
1166
- const res = await client.postJsonResult("/v1/memories", body);
1162
+ const res = await client.postJsonResult("/v1/memories", body, live.namespace);
1167
1163
  if (!res.ok) return text({ id: null, success: false, error: res.error });
1168
1164
  const out = { id: res.data?.id || null, success: true };
1169
1165
  if (res.data?.reinforced) out.reinforced = true;
@@ -1178,8 +1174,9 @@ function meminiExtension(pi) {
1178
1174
  id: Type.String({ description: "The id of the memory to forget (from memory_recall / memory_list)." })
1179
1175
  }),
1180
1176
  async execute(_toolCallId, params) {
1177
+ const live = await sessionLive(sessionCtx);
1181
1178
  if (!params.id) return text({ forgotten: false, error: "id is required" });
1182
- const res = await client.deleteJson(`/v1/memories/${encodeURIComponent(params.id)}`);
1179
+ const res = await client.deleteJson(`/v1/memories/${encodeURIComponent(params.id)}`, live.namespace);
1183
1180
  return text({ forgotten: res != null });
1184
1181
  }
1185
1182
  });
@@ -1189,9 +1186,10 @@ export {
1189
1186
  approxTokens,
1190
1187
  briefingPath,
1191
1188
  buildTurnContent,
1189
+ buildWarnings,
1192
1190
  createPlaintextBearerAuthGuard,
1191
+ createSessionContext,
1193
1192
  meminiExtension as default,
1194
- deriveNamespace,
1195
1193
  extractLastAssistantText,
1196
1194
  extractMessageText,
1197
1195
  fitByTokens,
@@ -1200,11 +1198,12 @@ export {
1200
1198
  intEnv,
1201
1199
  labelsEnv,
1202
1200
  meminiListPath,
1201
+ memoizeAsync,
1202
+ pinKeyFacts,
1203
1203
  registerMeminiCommands,
1204
1204
  renderStatus,
1205
- resolveConfig,
1206
- resolveProjectNamespace,
1207
- sanitizeNamespace,
1208
- sanitizeNamespacePath,
1205
+ resolveLiveConfig,
1206
+ resolveStaticConfig,
1207
+ sessionLive,
1209
1208
  truncate
1210
1209
  };