@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.
- package/README.md +45 -37
- package/dist/index.js +545 -546
- 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/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
204
|
-
const
|
|
205
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
256
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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/
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
457
|
-
if (
|
|
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
|
|
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 (!
|
|
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(
|
|
566
|
-
const baseUrl = String(
|
|
567
|
-
const secret =
|
|
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 (
|
|
570
|
-
function headers(extra) {
|
|
571
|
-
const h = { "X-Memini-Namespace":
|
|
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 (
|
|
456
|
+
if (staticCfg.home) h["X-Memini-Home"] = staticCfg.home;
|
|
574
457
|
return h;
|
|
575
458
|
}
|
|
576
|
-
async function request(method,
|
|
459
|
+
async function request(method, path2, namespace, body) {
|
|
577
460
|
guard(baseUrl, secret);
|
|
578
461
|
try {
|
|
579
|
-
const res = await fetch(`${baseUrl}${
|
|
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(
|
|
466
|
+
signal: AbortSignal.timeout(staticCfg.timeout_ms)
|
|
584
467
|
});
|
|
585
468
|
if (!res.ok) {
|
|
586
|
-
if (
|
|
587
|
-
warn(`memini ${method} ${
|
|
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} ${
|
|
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 (!
|
|
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,
|
|
483
|
+
async function requestResult(method, path2, namespace, body) {
|
|
601
484
|
try {
|
|
602
485
|
guard(baseUrl, secret);
|
|
603
|
-
const res = await fetch(`${baseUrl}${
|
|
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(
|
|
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} ${
|
|
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: (
|
|
622
|
-
getJson: (
|
|
623
|
-
deleteJson: (
|
|
624
|
-
postJsonResult: (
|
|
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
|
-
|
|
664
|
-
const
|
|
665
|
-
|
|
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 (
|
|
668
|
-
if (
|
|
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}${
|
|
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 ${
|
|
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 ${
|
|
596
|
+
if (!quiet) warn(`GET ${path2} failed: ${String(error)}`);
|
|
682
597
|
return null;
|
|
683
598
|
}
|
|
684
599
|
}
|
|
685
|
-
async function fetchServer(
|
|
600
|
+
async function fetchServer(boot, namespace, warn) {
|
|
686
601
|
const started = Date.now();
|
|
687
|
-
const readSet = await statusGet(
|
|
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(
|
|
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
|
|
705
|
-
const
|
|
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: ${
|
|
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(
|
|
712
|
-
if (
|
|
713
|
-
|
|
714
|
-
|
|
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)} ${
|
|
668
|
+
L.push(` ${pad("home (personal)", 28)} ${ctx.boot.homeEnv || "(unset)"}`);
|
|
721
669
|
L.push("");
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const
|
|
728
|
-
|
|
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
|
-
|
|
678
|
+
L.push("");
|
|
679
|
+
const secret = ctx.boot.apiKey;
|
|
737
680
|
L.push(`EXTENSION`);
|
|
738
|
-
L.push(` ${pad("
|
|
739
|
-
L.push(` ${pad("
|
|
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 ${
|
|
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 "${
|
|
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
|
-
|
|
770
|
-
|
|
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
|
|
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,
|
|
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,
|
|
728
|
+
handler: async (_args, cmdCtx) => {
|
|
790
729
|
try {
|
|
791
|
-
const
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
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
|
|
810
|
-
handler: async (args,
|
|
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
|
|
744
|
+
const { boot, facts } = ctx;
|
|
815
745
|
if (!arg) {
|
|
816
|
-
const
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
out.push(`
|
|
824
|
-
out.
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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(
|
|
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
|
|
835
|
-
if (!
|
|
836
|
-
|
|
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
|
-
|
|
840
|
-
|
|
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
|
|
807
|
+
`namespace pin cleared \u2014 this project resolves automatically again.`,
|
|
844
808
|
``,
|
|
845
|
-
`Recall and capture use the new
|
|
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
|
-
|
|
817
|
+
cmdCtx.ui.notify(`memini: invalid namespace ${JSON.stringify(arg)}: ${bad}`, "error");
|
|
854
818
|
return;
|
|
855
819
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
|
862
|
-
`project:
|
|
843
|
+
`namespace pinned: ${entry.namespace || ns}`,
|
|
844
|
+
`project key: ${entry.key || keyFacts.remote_url || keyFacts.toplevel_path}`,
|
|
863
845
|
``,
|
|
864
|
-
`
|
|
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
|
-
|
|
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
|
|
902
|
-
const
|
|
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,
|
|
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
|
-
|
|
970
|
-
|
|
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 (
|
|
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 =
|
|
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,
|
|
968
|
+
const hits = formatResults(filtered, live.recall_limit, labelsEnv());
|
|
983
969
|
if (hits.length === 0) return;
|
|
984
|
-
const fit = fitByTokens(hits,
|
|
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
|
-
|
|
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(
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
sanitizeNamespacePath,
|
|
1205
|
+
resolveLiveConfig,
|
|
1206
|
+
resolveStaticConfig,
|
|
1207
|
+
sessionLive,
|
|
1209
1208
|
truncate
|
|
1210
1209
|
};
|