@eleboucher/pi-memini 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,614 +1,619 @@
1
- /**
2
- * memini memory extension for Pi (https://pi.dev).
3
- *
4
- * Pi has no built-in MCP, but it has a first-class extension API. This extension
5
- * wires memory two ways at once:
6
- *
7
- * - Automatic (no tool call needed):
8
- * - before_agent_start: recall memories relevant to the user's prompt and
9
- * inject them as a persistent context message before the agent runs.
10
- * - agent_end: capture the completed user/assistant turn into memini as
11
- * episodic memory.
12
- * - Explicit tools (the model calls them on demand), modeled on the tool set
13
- * Claude Code gets from memini's MCP server: memory_recall, memory_list,
14
- * memory_remember, memory_forget.
15
- *
16
- * Talks to memini over REST (/v1/search, /v1/memories), scoped by the
17
- * X-Memini-Namespace header. Config comes from MEMINI_* env vars; secrets like
18
- * MEMINI_API_KEY stay in the environment. See ../README.md for the table.
19
- */
1
+ // src/index.ts
20
2
  import { Type } from "typebox";
21
- import { resolveNamespace } from "@memini/namespace-resolver";
22
- const DEFAULT_BASE_URL = "http://localhost:8080";
23
- const DEFAULT_TIMEOUT_MS = 30000;
24
- const DEFAULT_RECALL_LIMIT = 3;
25
- const DEFAULT_NAMESPACE = "pi";
26
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
27
- function envBool(value, fallback) {
28
- if (value === undefined || value === null || value === "")
29
- return fallback;
30
- return !/^(0|false|no|off)$/i.test(String(value).trim());
31
- }
32
- /**
33
- * intEnv parses a non-negative integer env var and returns `def` when unset or
34
- * malformed — env values are user input and shouldn't crash a hook.
35
- */
36
- export function intEnv(name, def) {
37
- const raw = process.env[name];
38
- if (raw == null || raw === "")
39
- return def;
40
- const n = Number.parseInt(raw, 10);
41
- if (!Number.isFinite(n) || n < 0)
42
- return def;
43
- return n;
44
- }
45
- /** floatEnv parses a non-negative float env var; falls back to `def`. */
46
- export function floatEnv(name, def) {
47
- const raw = process.env[name];
48
- if (raw == null || raw === "")
49
- return def;
50
- const n = Number.parseFloat(raw);
51
- if (!Number.isFinite(n) || n < 0)
52
- return def;
53
- return n;
54
- }
55
- /**
56
- * labelsEnv parses MEMINI_INJECT_LABELS into a Set of enabled labels.
57
- * Recognized: "tier", "confidence", "age". Empty/unset returns an empty Set.
58
- */
59
- export function labelsEnv(name = "MEMINI_INJECT_LABELS") {
60
- const raw = process.env[name];
61
- if (!raw)
62
- return new Set();
63
- return new Set(raw
64
- .split(/[|,]/)
65
- .map((s) => s.trim().toLowerCase())
66
- .filter(Boolean));
67
- }
68
- // sanitizeNamespace keeps the X-Memini-Namespace value header-safe: alnum, dot,
69
- // dash, underscore; collapse the rest to dashes and trim.
70
- export function sanitizeNamespace(s) {
71
- return String(s)
72
- .trim()
73
- .replace(/[^A-Za-z0-9._-]+/g, "-")
74
- .replace(/^-+|-+$/g, "");
75
- }
76
- // sanitizeNamespacePath sanitizes a hierarchical namespace per segment,
77
- // preserving the "/" separators the resolver's tenant paths carry
78
- // (work/memini must not flatten to work-memini — the other integrations keep
79
- // the separator, and flattening would split memory across integrations).
80
- export function sanitizeNamespacePath(s) {
81
- return String(s)
82
- .split("/")
83
- .map(sanitizeNamespace)
84
- .filter(Boolean)
85
- .join("/");
86
- }
87
- // deriveNamespace scopes memory to the project: the basename of the working
88
- // directory, the same scheme memini auto-resolves from a git repo. Returns ""
89
- // when no path is given.
90
- export function deriveNamespace(cwd) {
91
- if (typeof cwd !== "string" || !cwd.trim())
92
- return "";
93
- const base = cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
94
- return sanitizeNamespace(base);
95
- }
96
- // resolveConfig builds the config from env vars (Claude Code plugin style),
97
- // deriving the namespace from cwd when MEMINI_NAMESPACE is unset. Exported for
98
- // testing.
99
- export function resolveConfig(env, cwd) {
100
- const e = env || {};
101
- const nsEnv = (e.MEMINI_NAMESPACE || "").trim();
102
- let namespace;
103
- if (nsEnv) {
104
- // MEMINI_NAMESPACE wins and is used raw-trimmed (the server validates the
105
- // header). Routing it through sanitizeNamespacePath would alter an explicit
106
- // value — the canonical resolver returns it untouched.
107
- namespace = nsEnv;
108
- }
109
- else if (cwd) {
110
- const { namespace: resolvedNs } = resolveNamespace({
111
- cwd,
112
- env: e,
113
- integration: "pi",
114
- });
115
- // Per-segment sanitize: resolver output may be a tenant path (work/memini).
116
- namespace = sanitizeNamespacePath(resolvedNs) || DEFAULT_NAMESPACE;
117
- }
118
- else {
119
- // No explicit namespace and no cwd to resolve against.
120
- namespace = DEFAULT_NAMESPACE;
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;
121
81
  }
122
- const recall_limit = (() => {
123
- const n = Number(e.MEMINI_RECALL_LIMIT);
124
- return Number.isFinite(n) && n >= 0 ? n : DEFAULT_RECALL_LIMIT;
125
- })();
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 applyTemplate(template, segments) {
106
+ const all = {
107
+ tenant: segments.tenant,
108
+ project: segments.project,
109
+ agent: segments.agent,
110
+ namespace: segments.namespace
111
+ };
112
+ let result = template.replace(/\{(tenant|project|agent|namespace)\}/g, (_, key) => {
113
+ return all[key] ?? "";
114
+ });
115
+ result = result.replace(/\/{2,}/g, "/");
116
+ result = result.replace(/^\/+|\/+$/g, "");
117
+ return result;
118
+ }
119
+ function resolveNamespace(opts) {
120
+ const env = opts.env || process.env;
121
+ const cwd = opts.cwd && opts.cwd.trim() ? opts.cwd : process.cwd();
122
+ const nsEnv = (env["MEMINI_NAMESPACE"] || "").trim();
123
+ if (nsEnv) {
124
+ return { namespace: nsEnv, segments: {}, source: "env" };
125
+ }
126
+ const config = readConfig(opts.configPath);
127
+ if (!config.found) {
128
+ const project2 = sanitizeSegment(basename(cwd));
126
129
  return {
127
- base_url: e.MEMINI_BASE_URL || e.MEMINI_URL || DEFAULT_BASE_URL,
128
- // namespace is already resolved above (raw-trimmed on the env path,
129
- // per-segment sanitized on the resolver path); re-sanitizing here would
130
- // flatten tenant separators.
131
- namespace: namespace || DEFAULT_NAMESPACE,
132
- recall: envBool(e.MEMINI_RECALL, true),
133
- capture: envBool(e.MEMINI_CAPTURE, true),
134
- recall_limit,
135
- recall_max_tokens: intEnv("MEMINI_INJECT_RECALL_MAX_TOK", 0),
136
- recall_min_score: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
137
- timeout_ms: Number(e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
138
- fallback_on_error: envBool(e.MEMINI_FALLBACK, true),
130
+ namespace: project2,
131
+ segments: project2 ? { project: project2 } : {},
132
+ source: "cwd"
139
133
  };
134
+ }
135
+ const tenant = resolveTenant(cwd, config);
136
+ const project = resolveProject(cwd, opts.gitRemoteUrl, env);
137
+ const agent = resolveAgent(opts);
138
+ const override = opts.integration ? config.overrides[opts.integration] : void 0;
139
+ const baseNamespace = override?.namespace;
140
+ const segments = { tenant, project, agent, namespace: baseNamespace };
141
+ const template = override?.template || config.template;
142
+ let namespace = applyTemplate(template, segments);
143
+ if (!namespace) {
144
+ namespace = project || "default";
145
+ }
146
+ let source = "default";
147
+ if (tenant) source = "config";
148
+ else if (opts.gitRemoteUrl || gitOut("remote get-url origin", cwd)) source = "git";
149
+ else source = "cwd";
150
+ return { namespace, segments, source };
151
+ }
152
+
153
+ // src/index.ts
154
+ var DEFAULT_BASE_URL = "http://localhost:8080";
155
+ var DEFAULT_TIMEOUT_MS = 3e4;
156
+ var DEFAULT_RECALL_LIMIT = 3;
157
+ var DEFAULT_NAMESPACE = "pi";
158
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
159
+ function envBool(value, fallback) {
160
+ if (value === void 0 || value === null || value === "") return fallback;
161
+ return !/^(0|false|no|off)$/i.test(String(value).trim());
162
+ }
163
+ function intEnv(name, def) {
164
+ const raw = process.env[name];
165
+ if (raw == null || raw === "") return def;
166
+ const n = Number.parseInt(raw, 10);
167
+ if (!Number.isFinite(n) || n < 0) return def;
168
+ return n;
169
+ }
170
+ function floatEnv(name, def) {
171
+ const raw = process.env[name];
172
+ if (raw == null || raw === "") return def;
173
+ const n = Number.parseFloat(raw);
174
+ if (!Number.isFinite(n) || n < 0) return def;
175
+ return n;
140
176
  }
141
- // --- token budget (copied from the opencode plugin; both ship standalone) ----
142
- /** approxTokens: ~0.75 tokens/word, floor of 1 for any non-empty line. */
143
- export function approxTokens(text) {
144
- if (!text)
145
- return 0;
146
- const words = String(text).trim().split(/\s+/).filter(Boolean).length;
147
- return Math.max(1, Math.ceil((words * 4) / 3));
148
- }
149
- /**
150
- * fitByTokens trims a list of pre-formatted strings under `maxTokens`, keeping
151
- * the head (most relevant first). maxTokens<=0 means unbounded.
152
- */
153
- export function fitByTokens(items, maxTokens) {
154
- if (!Array.isArray(items) || items.length === 0)
155
- return { items: [], tokens: 0, dropped: 0 };
156
- if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
157
- const tokens = items.reduce((sum, s) => sum + approxTokens(s), 0);
158
- return { items: items.slice(), tokens, dropped: 0 };
177
+ function labelsEnv(name = "MEMINI_INJECT_LABELS") {
178
+ const raw = process.env[name];
179
+ if (!raw) return /* @__PURE__ */ new Set();
180
+ return new Set(
181
+ raw.split(/[|,]/).map((s) => s.trim().toLowerCase()).filter(Boolean)
182
+ );
183
+ }
184
+ function sanitizeNamespace(s) {
185
+ return String(s).trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
186
+ }
187
+ function sanitizeNamespacePath(s) {
188
+ return String(s).split("/").map(sanitizeNamespace).filter(Boolean).join("/");
189
+ }
190
+ function deriveNamespace(cwd) {
191
+ if (typeof cwd !== "string" || !cwd.trim()) return "";
192
+ const base = cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
193
+ return sanitizeNamespace(base);
194
+ }
195
+ function resolveConfig(env, cwd) {
196
+ const e = env || {};
197
+ const nsEnv = (e.MEMINI_NAMESPACE || "").trim();
198
+ let namespace;
199
+ if (nsEnv) {
200
+ namespace = nsEnv;
201
+ } else if (cwd) {
202
+ const { namespace: resolvedNs } = resolveNamespace({
203
+ cwd,
204
+ env: e,
205
+ integration: "pi"
206
+ });
207
+ namespace = sanitizeNamespacePath(resolvedNs) || DEFAULT_NAMESPACE;
208
+ } else {
209
+ namespace = DEFAULT_NAMESPACE;
210
+ }
211
+ const recall_limit = (() => {
212
+ const n = Number(e.MEMINI_RECALL_LIMIT);
213
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_RECALL_LIMIT;
214
+ })();
215
+ return {
216
+ base_url: e.MEMINI_BASE_URL || e.MEMINI_URL || DEFAULT_BASE_URL,
217
+ // namespace is already resolved above (raw-trimmed on the env path,
218
+ // per-segment sanitized on the resolver path); re-sanitizing here would
219
+ // flatten tenant separators.
220
+ namespace: namespace || DEFAULT_NAMESPACE,
221
+ recall: envBool(e.MEMINI_RECALL, true),
222
+ capture: envBool(e.MEMINI_CAPTURE, true),
223
+ recall_limit,
224
+ recall_max_tokens: intEnv("MEMINI_INJECT_RECALL_MAX_TOK", 0),
225
+ recall_min_score: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
226
+ timeout_ms: Number(e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
227
+ fallback_on_error: envBool(e.MEMINI_FALLBACK, true)
228
+ };
229
+ }
230
+ function approxTokens(text) {
231
+ if (!text) return 0;
232
+ const words = String(text).trim().split(/\s+/).filter(Boolean).length;
233
+ return Math.max(1, Math.ceil(words * 4 / 3));
234
+ }
235
+ function fitByTokens(items, maxTokens) {
236
+ if (!Array.isArray(items) || items.length === 0) return { items: [], tokens: 0, dropped: 0 };
237
+ if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
238
+ const tokens = items.reduce((sum, s) => sum + approxTokens(s), 0);
239
+ return { items: items.slice(), tokens, dropped: 0 };
240
+ }
241
+ const out = [];
242
+ let used = 0;
243
+ let dropped = 0;
244
+ for (const s of items) {
245
+ const t = approxTokens(s);
246
+ if (used + t > maxTokens) {
247
+ dropped++;
248
+ continue;
159
249
  }
160
- const out = [];
161
- let used = 0;
162
- let dropped = 0;
163
- for (const s of items) {
164
- const t = approxTokens(s);
165
- if (used + t > maxTokens) {
166
- dropped++;
167
- continue;
168
- }
169
- out.push(s);
170
- used += t;
250
+ out.push(s);
251
+ used += t;
252
+ }
253
+ return { items: out, tokens: used, dropped };
254
+ }
255
+ function truncate(value, max) {
256
+ return value.length > max ? value.slice(0, max) + "\n[...truncated]" : value;
257
+ }
258
+ function formatResults(results, limit, labels) {
259
+ if (!Array.isArray(results) || results.length === 0) return [];
260
+ const useLabels = labels && labels.size > 0 ? labels : null;
261
+ return results.slice(0, limit || DEFAULT_RECALL_LIMIT).map((result, index) => {
262
+ const mem = result && result.memory || {};
263
+ const text = truncate(String(mem.summary || mem.content || `Memory ${index + 1}`).trim(), 300);
264
+ if (!text) return null;
265
+ const tier = String(mem.tier || "memory").trim();
266
+ if (!useLabels) return `- (${tier}) ${text}`;
267
+ const tagParts = [];
268
+ if (useLabels.has("tier") && tier) tagParts.push(tier);
269
+ if (useLabels.has("confidence") && typeof mem.confidence === "number") {
270
+ tagParts.push(`conf=${mem.confidence.toFixed(2)}`);
171
271
  }
172
- return { items: out, tokens: used, dropped };
173
- }
174
- /** truncate to `max` chars with a marker. */
175
- export function truncate(value, max) {
176
- return value.length > max ? value.slice(0, max) + "\n[...truncated]" : value;
177
- }
178
- // formatResults renders search hits to bullet lines. Empty labels -> "- (tier)
179
- // text"; non-empty -> "[tier · conf · age] text". Matches the opencode plugin.
180
- export function formatResults(results, limit, labels) {
181
- if (!Array.isArray(results) || results.length === 0)
182
- return [];
183
- const useLabels = labels && labels.size > 0 ? labels : null;
184
- return results
185
- .slice(0, limit || DEFAULT_RECALL_LIMIT)
186
- .map((result, index) => {
187
- const mem = (result && result.memory) || {};
188
- const text = truncate(String(mem.summary || mem.content || `Memory ${index + 1}`).trim(), 300);
189
- if (!text)
190
- return null;
191
- const tier = String(mem.tier || "memory").trim();
192
- if (!useLabels)
193
- return `- (${tier}) ${text}`;
194
- const tagParts = [];
195
- if (useLabels.has("tier") && tier)
196
- tagParts.push(tier);
197
- if (useLabels.has("confidence") && typeof mem.confidence === "number") {
198
- tagParts.push(`conf=${mem.confidence.toFixed(2)}`);
199
- }
200
- if (useLabels.has("age") && mem.created_at) {
201
- const ageMs = Date.now() - new Date(mem.created_at).getTime();
202
- if (Number.isFinite(ageMs) && ageMs >= 0) {
203
- const days = Math.floor(ageMs / 86400000);
204
- tagParts.push(days === 0 ? "today" : `${days}d`);
205
- }
206
- }
207
- if (tagParts.length === 0)
208
- return `- (${tier}) ${text}`;
209
- return `[${tagParts.join(" · ")}] ${text}`;
210
- })
211
- .filter((x) => Boolean(x));
272
+ if (useLabels.has("age") && mem.created_at) {
273
+ const ageMs = Date.now() - new Date(mem.created_at).getTime();
274
+ if (Number.isFinite(ageMs) && ageMs >= 0) {
275
+ const days = Math.floor(ageMs / 864e5);
276
+ tagParts.push(days === 0 ? "today" : `${days}d`);
277
+ }
278
+ }
279
+ if (tagParts.length === 0) return `- (${tier}) ${text}`;
280
+ return `[${tagParts.join(" \xB7 ")}] ${text}`;
281
+ }).filter((x) => Boolean(x));
212
282
  }
213
- // --- plaintext-bearer guard (ported from the opencode/openclaw plugins) ------
214
283
  function normalizedHostname(hostname) {
215
- return hostname.replace(/^\[|\]$/g, "").toLowerCase();
284
+ return hostname.replace(/^\[|\]$/g, "").toLowerCase();
216
285
  }
217
286
  function usesPlaintextBearerAuth(baseUrl, secret) {
218
- if (!secret)
219
- return false;
220
- try {
221
- const parsed = new URL(baseUrl);
222
- return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
223
- }
224
- catch {
225
- return false;
226
- }
287
+ if (!secret) return false;
288
+ try {
289
+ const parsed = new URL(baseUrl);
290
+ return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
291
+ } catch {
292
+ return false;
293
+ }
227
294
  }
228
295
  function plaintextBearerAuthMessage(baseUrl) {
229
- 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.`;
230
- }
231
- export function createPlaintextBearerAuthGuard(warn, env) {
232
- let warned = false;
233
- return function guardPlaintextBearerAuth(baseUrl, secret) {
234
- if (!usesPlaintextBearerAuth(baseUrl, secret))
235
- return;
236
- const message = plaintextBearerAuthMessage(baseUrl);
237
- if ((env || process.env).MEMINI_REQUIRE_HTTPS === "1")
238
- throw new Error(message);
239
- if (!warned) {
240
- warned = true;
241
- warn(message);
242
- }
243
- };
296
+ 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.`;
244
297
  }
245
- function createClient(cfg, warn) {
246
- const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
247
- const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
248
- const guard = createPlaintextBearerAuthGuard(warn);
249
- if (process.env.MEMINI_REQUIRE_HTTPS === "1")
250
- guard(baseUrl, secret);
251
- function headers(extra) {
252
- const h = { "X-Memini-Namespace": cfg.namespace, ...(extra || {}) };
253
- if (secret)
254
- h.Authorization = `Bearer ${secret}`;
255
- return h;
298
+ function createPlaintextBearerAuthGuard(warn, env) {
299
+ let warned = false;
300
+ return function guardPlaintextBearerAuth(baseUrl, secret) {
301
+ if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
302
+ const message = plaintextBearerAuthMessage(baseUrl);
303
+ if ((env || process.env).MEMINI_REQUIRE_HTTPS === "1") throw new Error(message);
304
+ if (!warned) {
305
+ warned = true;
306
+ warn(message);
256
307
  }
257
- async function request(method, path, body) {
258
- guard(baseUrl, secret);
259
- try {
260
- const res = await fetch(`${baseUrl}${path}`, {
261
- method,
262
- headers: headers(body ? { "Content-Type": "application/json" } : undefined),
263
- body: body ? JSON.stringify(body) : undefined,
264
- signal: AbortSignal.timeout(cfg.timeout_ms),
265
- });
266
- if (!res.ok) {
267
- if (cfg.fallback_on_error) {
268
- // Degrade but never silently: a swallowed 401/500 on a capture or
269
- // recall looks like "memory isn't working" with nothing to debug.
270
- warn(`memini ${method} ${path} failed: ${res.status}`);
271
- return null;
272
- }
273
- const text = await res.text().catch(() => "");
274
- throw new Error(`memini ${method} ${path} failed: ${res.status} ${text}`);
275
- }
276
- // 204 (DELETE) has an empty body; treat a 2xx as ok.
277
- return await res.json().catch(() => ({ ok: true }));
278
- }
279
- catch (error) {
280
- if (!cfg.fallback_on_error)
281
- throw error;
282
- warn(`memini: ${String(error)}`);
283
- return null;
308
+ };
309
+ }
310
+ function createClient(cfg, warn) {
311
+ const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
312
+ const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
313
+ const guard = createPlaintextBearerAuthGuard(warn);
314
+ if (process.env.MEMINI_REQUIRE_HTTPS === "1") guard(baseUrl, secret);
315
+ function headers(extra) {
316
+ const h = { "X-Memini-Namespace": cfg.namespace, ...extra || {} };
317
+ if (secret) h.Authorization = `Bearer ${secret}`;
318
+ return h;
319
+ }
320
+ async function request(method, path2, body) {
321
+ guard(baseUrl, secret);
322
+ try {
323
+ const res = await fetch(`${baseUrl}${path2}`, {
324
+ method,
325
+ headers: headers(body ? { "Content-Type": "application/json" } : void 0),
326
+ body: body ? JSON.stringify(body) : void 0,
327
+ signal: AbortSignal.timeout(cfg.timeout_ms)
328
+ });
329
+ if (!res.ok) {
330
+ if (cfg.fallback_on_error) {
331
+ warn(`memini ${method} ${path2} failed: ${res.status}`);
332
+ return null;
284
333
  }
334
+ const text = await res.text().catch(() => "");
335
+ throw new Error(`memini ${method} ${path2} failed: ${res.status} ${text}`);
336
+ }
337
+ return await res.json().catch(() => ({ ok: true }));
338
+ } catch (error) {
339
+ if (!cfg.fallback_on_error) throw error;
340
+ warn(`memini: ${String(error)}`);
341
+ return null;
285
342
  }
286
- return {
287
- postJson: (path, payload) => request("POST", path, payload),
288
- getJson: (path) => request("GET", path),
289
- deleteJson: (path) => request("DELETE", path),
290
- };
343
+ }
344
+ return {
345
+ postJson: (path2, payload) => request("POST", path2, payload),
346
+ getJson: (path2) => request("GET", path2),
347
+ deleteJson: (path2) => request("DELETE", path2)
348
+ };
291
349
  }
292
- // meminiListPath builds the GET /v1/memories query string for memory_list:
293
- // repeatable tier/tag params plus meta=key=value pairs. Exported for testing.
294
- export function meminiListPath(args) {
295
- const parts = [];
296
- for (const t of args?.tiers || [])
297
- parts.push(`tier=${encodeURIComponent(String(t))}`);
298
- for (const tag of args?.tags || [])
299
- parts.push(`tag=${encodeURIComponent(String(tag))}`);
300
- for (const [k, v] of Object.entries(args?.metadata || {})) {
301
- parts.push(`meta=${encodeURIComponent(`${k}=${v}`)}`);
302
- }
303
- if (Number.isInteger(args?.limit) && args.limit > 0)
304
- parts.push(`limit=${args.limit}`);
305
- return parts.length ? `/v1/memories?${parts.join("&")}` : "/v1/memories";
306
- }
307
- // --- turn capture helpers ----------------------------------------------------
308
- // extractMessageText pulls plain text out of a Pi AgentMessage, whose content
309
- // may be a string or an array of typed parts. Exported for testing.
310
- export function extractMessageText(message) {
311
- if (!message)
312
- return "";
313
- const c = message.content;
314
- if (typeof c === "string")
315
- return c.trim();
316
- if (Array.isArray(c)) {
317
- return c
318
- .filter((p) => p && p.type === "text" && typeof p.text === "string")
319
- .map((p) => p.text)
320
- .join("\n")
321
- .trim();
322
- }
323
- if (typeof message.text === "string")
324
- return message.text.trim();
325
- return "";
350
+ function meminiListPath(args) {
351
+ const parts = [];
352
+ for (const t of args?.tiers || []) parts.push(`tier=${encodeURIComponent(String(t))}`);
353
+ for (const tag of args?.tags || []) parts.push(`tag=${encodeURIComponent(String(tag))}`);
354
+ for (const [k, v] of Object.entries(args?.metadata || {})) {
355
+ parts.push(`meta=${encodeURIComponent(`${k}=${v}`)}`);
356
+ }
357
+ if (Number.isInteger(args?.limit) && args.limit > 0) parts.push(`limit=${args.limit}`);
358
+ return parts.length ? `/v1/memories?${parts.join("&")}` : "/v1/memories";
326
359
  }
327
- // extractLastAssistantText returns the text of the most recent assistant message.
328
- // agent_end carries the full conversation (AgentMessage[]), not just this run's
329
- // messages, so iterate in reverse and take the latest assistant turn only — never
330
- // a join of every reply in the session. Exported for testing.
331
- export function extractLastAssistantText(messages) {
332
- if (!Array.isArray(messages))
333
- return "";
334
- for (let i = messages.length - 1; i >= 0; i--) {
335
- const m = messages[i];
336
- if (m && m.role === "assistant") {
337
- const t = extractMessageText(m);
338
- if (t)
339
- return t;
340
- }
360
+ function extractMessageText(message) {
361
+ if (!message) return "";
362
+ const c = message.content;
363
+ if (typeof c === "string") return c.trim();
364
+ if (Array.isArray(c)) {
365
+ return c.filter((p) => p && p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n").trim();
366
+ }
367
+ if (typeof message.text === "string") return message.text.trim();
368
+ return "";
369
+ }
370
+ function extractLastAssistantText(messages) {
371
+ if (!Array.isArray(messages)) return "";
372
+ for (let i = messages.length - 1; i >= 0; i--) {
373
+ const m = messages[i];
374
+ if (m && m.role === "assistant") {
375
+ const t = extractMessageText(m);
376
+ if (t) return t;
341
377
  }
342
- return "";
378
+ }
379
+ return "";
343
380
  }
344
- // buildTurnContent assembles the episodic payload from the user prompt and the
345
- // assistant reply, bounding each side. Exported for testing.
346
- export function buildTurnContent(userText, assistantText) {
347
- return `${String(userText).slice(0, 1000)}\n\n${String(assistantText).slice(0, 3000)}`;
381
+ function buildTurnContent(userText, assistantText) {
382
+ return `${String(userText).slice(0, 1e3)}
383
+
384
+ ${String(assistantText).slice(0, 3e3)}`;
348
385
  }
349
- const TOOL_NAMES = ["memory_recall", "memory_list", "memory_remember", "memory_forget"];
350
- const VALID_TIERS = ["working", "episodic", "semantic", "procedural"];
351
- // sessionIdOf resolves Pi's session id from the read-only session manager on the
352
- // extension context, so echo-exclusion and capture-dedup are keyed consistently.
353
- // "" when unavailable.
386
+ var TOOL_NAMES = ["memory_recall", "memory_list", "memory_remember", "memory_forget"];
387
+ var VALID_TIERS = ["working", "episodic", "semantic", "procedural"];
354
388
  function sessionIdOf(ctx) {
355
- try {
356
- return String(ctx?.sessionManager?.getSessionId?.() ?? "");
357
- }
358
- catch {
359
- return "";
360
- }
389
+ try {
390
+ return String(ctx?.sessionManager?.getSessionId?.() ?? "");
391
+ } catch {
392
+ return "";
393
+ }
361
394
  }
362
- // leafIdOf returns the current session leaf-entry id — a stable per-turn key for
363
- // capture dedup, since Pi's AgentMessages carry no id of their own.
364
395
  function leafIdOf(ctx) {
396
+ try {
397
+ return String(ctx?.sessionManager?.getLeafId?.() ?? "");
398
+ } catch {
399
+ return "";
400
+ }
401
+ }
402
+ function meminiExtension(pi) {
403
+ const warn = (m) => {
365
404
  try {
366
- return String(ctx?.sessionManager?.getLeafId?.() ?? "");
405
+ console.error(`[memini] ${m}`);
406
+ } catch {
367
407
  }
368
- catch {
369
- return "";
408
+ };
409
+ const cfg = resolveConfig(process.env, process.cwd());
410
+ const client = createClient(cfg, warn);
411
+ const pendingUser = /* @__PURE__ */ new Map();
412
+ const captured = /* @__PURE__ */ new Set();
413
+ const injectedBySession = /* @__PURE__ */ new Map();
414
+ const MAX_TRACKED_SESSIONS = 200;
415
+ const rememberInjected = (session, ids) => {
416
+ let seen = injectedBySession.get(session);
417
+ if (!seen) {
418
+ seen = /* @__PURE__ */ new Set();
419
+ injectedBySession.set(session, seen);
420
+ while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
421
+ const oldest = injectedBySession.keys().next().value;
422
+ if (oldest === void 0) break;
423
+ injectedBySession.delete(oldest);
424
+ }
370
425
  }
371
- }
372
- export default function meminiExtension(pi) {
373
- const warn = (m) => {
374
- try {
375
- // ctx.ui.notify isn't available at module scope; log to stderr.
376
- console.error(`[memini] ${m}`);
377
- }
378
- catch {
379
- /* ignore */
380
- }
381
- };
382
- const cfg = resolveConfig(process.env, process.cwd());
383
- const client = createClient(cfg, warn);
384
- // Latest user prompt per session, set in before_agent_start and consumed in
385
- // agent_end to assemble the full turn.
386
- const pendingUser = new Map();
387
- // Assistant message ids already captured, so a re-fired agent_end never writes
388
- // a duplicate turn.
389
- const captured = new Set();
390
- // Memory ids each session has already been shown (mirrors the openclaw
391
- // plugin): the recall injection is a persistent context message, so
392
- // re-injecting an unchanged match every turn stacks identical blocks in the
393
- // prompt. Bounded so long-lived hosts can't grow the map without limit.
394
- const injectedBySession = new Map();
395
- const MAX_TRACKED_SESSIONS = 200;
396
- const rememberInjected = (session, ids) => {
397
- let seen = injectedBySession.get(session);
398
- if (!seen) {
399
- seen = new Set();
400
- injectedBySession.set(session, seen);
401
- while (injectedBySession.size > MAX_TRACKED_SESSIONS) {
402
- const oldest = injectedBySession.keys().next().value;
403
- if (oldest === undefined)
404
- break;
405
- injectedBySession.delete(oldest);
406
- }
407
- }
408
- for (const id of ids)
409
- if (id)
410
- seen.add(id);
426
+ for (const id of ids) if (id) seen.add(id);
427
+ };
428
+ pi.on("before_agent_start", async (event, ctx) => {
429
+ const sid = sessionIdOf(ctx);
430
+ const query = String(event?.prompt || "").trim();
431
+ if (query && sid) pendingUser.set(sid, query);
432
+ if (!cfg.recall || !query) return;
433
+ const body = { query, limit: cfg.recall_limit };
434
+ if (sid) body.exclude_metadata = { session_id: sid };
435
+ if (cfg.recall_min_score > 0) body.min_score = cfg.recall_min_score;
436
+ const result = await client.postJson("/v1/search", body);
437
+ const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
438
+ let rawHits = Array.isArray(result?.results) ? result.results : [];
439
+ if (sid) {
440
+ const seen = injectedBySession.get(sid);
441
+ if (seen?.size) rawHits = rawHits.filter((r) => !seen.has(r?.memory?.id));
442
+ }
443
+ const filtered = floor > 0 ? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor) : rawHits;
444
+ const hits = formatResults(filtered, cfg.recall_limit, labelsEnv());
445
+ if (hits.length === 0) return;
446
+ const fit = fitByTokens(hits, cfg.recall_max_tokens);
447
+ if (fit.items.length === 0) return;
448
+ if (sid) {
449
+ rememberInjected(sid, filtered.map((r) => r?.memory?.id).filter(Boolean));
450
+ }
451
+ const lines = [
452
+ "Relevant long-term memory from memini (background context \u2014 prefer current workspace state and the user's instructions):",
453
+ ...fit.items
454
+ ];
455
+ if (result?.degraded) {
456
+ lines.push(
457
+ `[memini: ${result.note || "semantic search unavailable \u2014 results are keyword-only and may be incomplete"}]`
458
+ );
459
+ }
460
+ if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
461
+ return {
462
+ message: {
463
+ customType: "memini-recall",
464
+ content: lines.join("\n"),
465
+ display: true
466
+ }
411
467
  };
412
- // Recall before the turn: search for the user's prompt and inject the matches
413
- // as a persistent context message. Buffer the prompt for capture at agent_end.
414
- pi.on("before_agent_start", async (event, ctx) => {
415
- const sid = sessionIdOf(ctx);
416
- const query = String(event?.prompt || "").trim();
417
- if (query && sid)
418
- pendingUser.set(sid, query);
419
- if (!cfg.recall || !query)
420
- return;
421
- const body = { query, limit: cfg.recall_limit };
422
- // Exclude this session's own captured turns: they're still in live context,
423
- // so recalling them just echoes the conversation back a turn behind.
424
- if (sid)
425
- body.exclude_metadata = { session_id: sid };
426
- if (cfg.recall_min_score > 0)
427
- body.min_score = cfg.recall_min_score;
428
- const result = await client.postJson("/v1/search", body);
429
- const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
430
- let rawHits = Array.isArray(result?.results) ? result.results : [];
431
- // Suppress memories this session has already been shown — the injected
432
- // message persists in context, so a repeat adds nothing but noise.
433
- if (sid) {
434
- const seen = injectedBySession.get(sid);
435
- if (seen?.size)
436
- rawHits = rawHits.filter((r) => !seen.has(r?.memory?.id));
437
- }
438
- const filtered = floor > 0
439
- ? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
440
- : rawHits;
441
- const hits = formatResults(filtered, cfg.recall_limit, labelsEnv());
442
- if (hits.length === 0)
443
- return;
444
- const fit = fitByTokens(hits, cfg.recall_max_tokens);
445
- if (fit.items.length === 0)
446
- return;
447
- if (sid) {
448
- rememberInjected(sid, filtered.map((r) => r?.memory?.id).filter(Boolean));
449
- }
450
- const lines = [
451
- "Relevant long-term memory from memini (background context — prefer " +
452
- "current workspace state and the user's instructions):",
453
- ...fit.items,
454
- ];
455
- // /v1/search sets `degraded: "keyword_only"` (plus a `note`) when the query
456
- // embed was unavailable and it fell back to keyword-only matching; both are
457
- // already on `result`, so surfacing them is a one-line addition.
458
- if (result?.degraded) {
459
- lines.push(`[memini: ${result.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`);
460
- }
461
- if (fit.dropped > 0)
462
- lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
463
- return {
464
- message: {
465
- customType: "memini-recall",
466
- content: lines.join("\n"),
467
- display: true,
468
- },
469
- };
470
- });
471
- // Capture after the turn: pair the buffered user prompt with the assistant
472
- // reply from this run and store it as episodic memory.
473
- pi.on("agent_end", async (event, ctx) => {
474
- if (!cfg.capture)
475
- return;
476
- const sid = sessionIdOf(ctx);
477
- const userText = (sid && pendingUser.get(sid)) || "";
478
- const assistantText = extractLastAssistantText(event?.messages);
479
- if (!userText || !assistantText)
480
- return;
481
- // AgentMessages carry no id, so key dedup on the session leaf entry — it
482
- // advances each turn, so a re-fired agent_end for the same turn is skipped.
483
- const dedupKey = leafIdOf(ctx);
484
- if (dedupKey && captured.has(dedupKey))
485
- return;
486
- const metadata = { source: "pi", format: "turn" };
487
- if (sid)
488
- metadata.session_id = sid;
489
- const stored = await client.postJson("/v1/memories", {
490
- content: buildTurnContent(userText, assistantText),
491
- tags: ["pi"],
492
- metadata,
493
- });
494
- if (stored !== null) {
495
- if (dedupKey)
496
- captured.add(dedupKey);
497
- if (sid)
498
- pendingUser.delete(sid);
499
- }
468
+ });
469
+ pi.on("agent_end", async (event, ctx) => {
470
+ if (!cfg.capture) return;
471
+ const sid = sessionIdOf(ctx);
472
+ const userText = sid && pendingUser.get(sid) || "";
473
+ const assistantText = extractLastAssistantText(event?.messages);
474
+ if (!userText || !assistantText) return;
475
+ const dedupKey = leafIdOf(ctx);
476
+ if (dedupKey && captured.has(dedupKey)) return;
477
+ const metadata = { source: "pi", format: "turn" };
478
+ if (sid) metadata.session_id = sid;
479
+ const stored = await client.postJson("/v1/memories", {
480
+ content: buildTurnContent(userText, assistantText),
481
+ tags: ["pi"],
482
+ metadata
500
483
  });
501
- // Explicit tools the same set Claude Code gets from memini's MCP server.
502
- const text = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj) }], details: {} });
503
- const Tags = Type.Optional(Type.Array(Type.String(), { description: "Match only memories carrying every listed tag (AND)." }));
504
- const Metadata = Type.Optional(Type.Record(Type.String(), Type.String(), {
505
- description: 'Match memories whose top-level metadata contains each key=value pair, e.g. {"category":"bug_fixes"}.',
506
- }));
507
- pi.registerTool({
508
- name: "memory_recall",
509
- label: "Recall memory",
510
- description: "Recall relevant memories from long-term memory (memini) via hybrid (semantic + keyword) search. " +
511
- "Call before starting work that may have history: editing an unfamiliar file, debugging a recurring " +
512
- "issue, or when asked what's known about something. A degraded:\"keyword_only\" field in the result " +
513
- "means semantic search was unavailable and results came from keyword matching alone — treat as " +
514
- "incomplete, not exhaustive.",
515
- parameters: Type.Object({
516
- query: Type.String({ description: "What to search for" }),
517
- limit: Type.Optional(Type.Number({ description: "Max results (default 3)" })),
518
- tags: Tags,
519
- metadata: Metadata,
520
- }),
521
- async execute(_toolCallId, params) {
522
- const body = { query: params.query, limit: params.limit || DEFAULT_RECALL_LIMIT };
523
- if (params.tags?.length)
524
- body.tags = params.tags;
525
- if (params.metadata && Object.keys(params.metadata).length)
526
- body.metadata = params.metadata;
527
- const res = await client.postJson("/v1/search", body);
528
- const results = (res?.results || []).map((r) => ({
529
- id: r?.memory?.id || "",
530
- content: r?.memory?.content || "",
531
- summary: r?.memory?.summary || "",
532
- tier: r?.memory?.tier || "",
533
- score: typeof r?.score === "number" ? r.score : 0,
534
- }));
535
- // /v1/search already carries `degraded`/`note` on `res`; pass them through
536
- // rather than dropping them silently.
537
- return text(res?.degraded ? { results, degraded: res.degraded, note: res.note } : { results });
538
- },
539
- });
540
- pi.registerTool({
541
- name: "memory_list",
542
- label: "List memory",
543
- description: "Browse long-term memory (memini) without a query filter by tier, tags, or metadata " +
544
- "category (e.g. all procedural memories or everything categorized bug_fixes). Newest first.",
545
- parameters: Type.Object({
546
- tiers: Type.Optional(Type.Array(Type.String(), { description: "Restrict to these tiers; empty means all." })),
547
- tags: Tags,
548
- metadata: Metadata,
549
- limit: Type.Optional(Type.Number({ description: "Max results (0 = all, default 20)" })),
550
- }),
551
- async execute(_toolCallId, params) {
552
- const args = { ...params, limit: params.limit ?? 20 };
553
- const res = await client.getJson(meminiListPath(args));
554
- const memories = (res?.memories || []).map((m) => ({
555
- id: m.id || "",
556
- content: m.content || "",
557
- summary: m.summary || "",
558
- tier: m.tier || "",
559
- tags: m.tags || [],
560
- metadata: m.metadata || {},
561
- }));
562
- return text({ memories });
563
- },
564
- });
565
- pi.registerTool({
566
- name: "memory_remember",
567
- label: "Remember",
568
- description: "Store a durable fact, decision, or preference in long-term memory (memini). Call proactively when " +
569
- "the user says 'remember this', after an architectural decision (capture the why), or after " +
570
- "discovering a non-obvious bug or convention. Keep memories atomic — one self-contained fact per call.",
571
- parameters: Type.Object({
572
- content: Type.String({ description: "The fact to remember" }),
573
- tier: Type.Optional(Type.String({
574
- description: "semantic=durable knowledge, procedural=how-to, episodic=what happened, working=transient " +
575
- "(omit to let the server classify from the content)",
576
- })),
577
- tags: Type.Optional(Type.Array(Type.String(), { description: "Optional keywords for later search/filtering." })),
578
- category: Type.Optional(Type.String({
579
- description: "Optional topic bucket stored as metadata.category (e.g. bug_fixes, architecture_decisions) for browsing by subject later.",
580
- })),
581
- }),
582
- async execute(_toolCallId, params) {
583
- // No client-side tier default: an omitted (or invalid) tier lets the
584
- // server classify the content and apply its own default.
585
- const body = { content: params.content };
586
- if (params.tier && VALID_TIERS.includes(params.tier))
587
- body.tier = params.tier;
588
- if (params.tags?.length)
589
- body.tags = params.tags;
590
- if (params.category)
591
- body.metadata = { category: params.category };
592
- const res = await client.postJson("/v1/memories", body);
593
- return text({ id: res?.id || null, success: res != null });
594
- },
595
- });
596
- pi.registerTool({
597
- name: "memory_forget",
598
- label: "Forget",
599
- description: "Permanently delete a memory from long-term memory (memini) by its id — use when a recalled memory " +
600
- "is wrong, outdated, or poisoned. Get the id from memory_recall or memory_list. To correct a fact, " +
601
- "forget the stale one and remember the corrected version — this integration talks to memini over " +
602
- "REST, which has no partial-update endpoint.",
603
- parameters: Type.Object({
604
- id: Type.String({ description: "The id of the memory to forget (from memory_recall / memory_list)." }),
605
- }),
606
- async execute(_toolCallId, params) {
607
- if (!params.id)
608
- return text({ forgotten: false, error: "id is required" });
609
- const res = await client.deleteJson(`/v1/memories/${encodeURIComponent(params.id)}`);
610
- return text({ forgotten: res != null });
611
- },
612
- });
613
- void TOOL_NAMES;
484
+ if (stored !== null) {
485
+ if (dedupKey) captured.add(dedupKey);
486
+ if (sid) pendingUser.delete(sid);
487
+ }
488
+ });
489
+ const text = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj) }], details: {} });
490
+ const Tags = Type.Optional(
491
+ Type.Array(Type.String(), { description: "Match only memories carrying every listed tag (AND)." })
492
+ );
493
+ const Metadata = Type.Optional(
494
+ Type.Record(Type.String(), Type.String(), {
495
+ description: 'Match memories whose top-level metadata contains each key=value pair, e.g. {"category":"bug_fixes"}.'
496
+ })
497
+ );
498
+ pi.registerTool({
499
+ name: "memory_recall",
500
+ label: "Recall memory",
501
+ description: `Recall relevant memories from long-term memory (memini) via hybrid (semantic + keyword) search. Call before starting work that may have history: editing an unfamiliar file, debugging a recurring issue, or when asked what's known about something. Empty results mean nothing is known \u2014 proceed from first principles, never invent a remembered fact. A degraded:"keyword_only" field in the result means semantic search was unavailable and results came from keyword matching alone \u2014 treat as incomplete, not exhaustive.`,
502
+ parameters: Type.Object({
503
+ query: Type.String({ description: "What to search for" }),
504
+ limit: Type.Optional(Type.Number({ description: "Max results (default 3)" })),
505
+ tags: Tags,
506
+ metadata: Metadata
507
+ }),
508
+ async execute(_toolCallId, params) {
509
+ const body = { query: params.query, limit: params.limit || DEFAULT_RECALL_LIMIT };
510
+ if (params.tags?.length) body.tags = params.tags;
511
+ if (params.metadata && Object.keys(params.metadata).length) body.metadata = params.metadata;
512
+ const res = await client.postJson("/v1/search", body);
513
+ const results = (res?.results || []).map((r) => ({
514
+ id: r?.memory?.id || "",
515
+ content: r?.memory?.content || "",
516
+ summary: r?.memory?.summary || "",
517
+ tier: r?.memory?.tier || "",
518
+ score: typeof r?.score === "number" ? r.score : 0
519
+ }));
520
+ return text(res?.degraded ? { results, degraded: res.degraded, note: res.note } : { results });
521
+ }
522
+ });
523
+ pi.registerTool({
524
+ name: "memory_list",
525
+ label: "List memory",
526
+ description: "Browse long-term memory (memini) without a query \u2014 filter by tier, tags, or metadata category (e.g. all procedural memories or everything categorized bug_fixes). Newest first.",
527
+ parameters: Type.Object({
528
+ tiers: Type.Optional(
529
+ Type.Array(Type.String(), { description: "Restrict to these tiers; empty means all." })
530
+ ),
531
+ tags: Tags,
532
+ metadata: Metadata,
533
+ limit: Type.Optional(Type.Number({ description: "Max results (0 = all, default 20)" }))
534
+ }),
535
+ async execute(_toolCallId, params) {
536
+ const args = { ...params, limit: params.limit ?? 20 };
537
+ const res = await client.getJson(meminiListPath(args));
538
+ const memories = (res?.memories || []).map((m) => ({
539
+ id: m.id || "",
540
+ content: m.content || "",
541
+ summary: m.summary || "",
542
+ tier: m.tier || "",
543
+ tags: m.tags || [],
544
+ metadata: m.metadata || {}
545
+ }));
546
+ return text({ memories });
547
+ }
548
+ });
549
+ pi.registerTool({
550
+ name: "memory_remember",
551
+ label: "Remember",
552
+ 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.",
553
+ parameters: Type.Object({
554
+ content: Type.String({ description: "The fact to remember \u2014 atomic and self-contained." }),
555
+ id: Type.Optional(
556
+ Type.String({
557
+ description: "Existing memory id (from memory_recall / memory_list) to correct in place instead of writing a new memory."
558
+ })
559
+ ),
560
+ tier: Type.Optional(
561
+ Type.String({
562
+ description: "semantic=durable knowledge, procedural=how-to, episodic=what happened, working=transient (omit to let the server classify from the content)"
563
+ })
564
+ ),
565
+ tags: Type.Optional(
566
+ Type.Array(Type.String(), {
567
+ description: "Topic keywords for later search/filtering; tag a critical always-relevant fact 'pinned'."
568
+ })
569
+ ),
570
+ category: Type.Optional(
571
+ Type.String({
572
+ description: "Optional topic bucket stored as metadata.category (e.g. bug_fixes, architecture_decisions) for browsing by subject later."
573
+ })
574
+ )
575
+ }),
576
+ async execute(_toolCallId, params) {
577
+ const body = { content: params.content };
578
+ if (params.id) body.id = params.id;
579
+ if (params.tier && VALID_TIERS.includes(params.tier)) body.tier = params.tier;
580
+ if (params.tags?.length) body.tags = params.tags;
581
+ if (params.category) body.metadata = { category: params.category };
582
+ const res = await client.postJson("/v1/memories", body);
583
+ return text({ id: res?.id || null, success: res != null });
584
+ }
585
+ });
586
+ pi.registerTool({
587
+ name: "memory_forget",
588
+ label: "Forget",
589
+ description: "Permanently delete a memory from long-term memory (memini) by its id \u2014 use when a recalled memory is wrong, outdated, or poisoned. Get the id from memory_recall or memory_list. To correct a fact instead, call memory_remember with the existing id (it updates in place, preserving history); forget only memories that should not exist at all.",
590
+ parameters: Type.Object({
591
+ id: Type.String({ description: "The id of the memory to forget (from memory_recall / memory_list)." })
592
+ }),
593
+ async execute(_toolCallId, params) {
594
+ if (!params.id) return text({ forgotten: false, error: "id is required" });
595
+ const res = await client.deleteJson(`/v1/memories/${encodeURIComponent(params.id)}`);
596
+ return text({ forgotten: res != null });
597
+ }
598
+ });
599
+ void TOOL_NAMES;
614
600
  }
601
+ export {
602
+ approxTokens,
603
+ buildTurnContent,
604
+ createPlaintextBearerAuthGuard,
605
+ meminiExtension as default,
606
+ deriveNamespace,
607
+ extractLastAssistantText,
608
+ extractMessageText,
609
+ fitByTokens,
610
+ floatEnv,
611
+ formatResults,
612
+ intEnv,
613
+ labelsEnv,
614
+ meminiListPath,
615
+ resolveConfig,
616
+ sanitizeNamespace,
617
+ sanitizeNamespacePath,
618
+ truncate
619
+ };