@eleboucher/pi-memini 0.4.23

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 ADDED
@@ -0,0 +1,91 @@
1
+ # memini + Pi
2
+
3
+ [Pi](https://pi.dev) is an open-source coding agent with a first-class
4
+ [extension API](https://pi.dev/docs/latest/extensions). Pi has **no built-in
5
+ MCP** — capabilities are added through extensions — so memini ships a native
6
+ extension in [`plugin/`](plugin/) that makes memory both automatic and
7
+ tool-callable, with no MCP layer required.
8
+
9
+ ## Recommended: the memory extension
10
+
11
+ What it wires:
12
+
13
+ - **`before_agent_start`** — searches memini for the user's prompt and injects
14
+ the matches as a persistent context message before the agent runs. It excludes
15
+ this session's own captured turns (already in live context), so they aren't
16
+ echoed back a turn behind; past sessions still recall.
17
+ - **`agent_end`** — once the agent finishes a prompt, stores the completed
18
+ user/assistant turn back into memini (episodic, tagged `pi`, with the session
19
+ id) so it can be recalled later.
20
+ - **Explicit tools** — the same set Claude Code gets from memini's MCP server,
21
+ registered natively via `pi.registerTool`: `memory_recall`, `memory_list`,
22
+ `memory_remember`, `memory_forget`. The model can call them on demand even
23
+ though the automatic loop already runs.
24
+
25
+ ### Install
26
+
27
+ The extension is published to npm as
28
+ [`@eleboucher/pi-memini`](https://www.npmjs.com/package/@eleboucher/pi-memini).
29
+ Pi has no `init`/scaffold command — extensions are just discovered from known
30
+ locations or declared in config. Pick one:
31
+
32
+ **Project / global settings** — add the package to `settings.json`
33
+ (`.pi/settings.json` for one project, or `~/.pi/agent/settings.json` globally):
34
+
35
+ ```json
36
+ {
37
+ "packages": ["npm:@eleboucher/pi-memini"]
38
+ }
39
+ ```
40
+
41
+ **Discovery folder** — Pi auto-discovers and hot-reloads extensions in
42
+ `~/.pi/agent/extensions/` (global) or `.pi/extensions/` (project-local). Drop
43
+ the built `dist/index.js` (or the `src/index.ts` source) there.
44
+
45
+ **Quick test** — point Pi at a local checkout for one run:
46
+
47
+ ```sh
48
+ pi -e ./integrations/pi/plugin/dist/index.js
49
+ ```
50
+
51
+ ### Configure
52
+
53
+ All config is via environment variables in the shell that launches Pi (secrets
54
+ stay out of any file):
55
+
56
+ | Env var | Default | Purpose |
57
+ | -------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------- |
58
+ | `MEMINI_BASE_URL` | `http://localhost:8080` | memini REST base URL (alias: `MEMINI_URL`) |
59
+ | `MEMINI_NAMESPACE` | cwd basename | tenant the memory is scoped to (`X-Memini-Namespace`) |
60
+ | `MEMINI_RECALL` | on | `0`/`false` disables recall-before-turn |
61
+ | `MEMINI_CAPTURE` | on | `0`/`false` disables capture-after-turn |
62
+ | `MEMINI_RECALL_LIMIT` | `3` | max memories injected per turn |
63
+ | `MEMINI_INJECT_RECALL_MAX_TOK` | `0` | hard ceiling on recall-block tokens (`0` = unbounded); the tail is dropped with a footer |
64
+ | `MEMINI_INJECT_RECALL_MIN_SCORE` | `0` | fused-score floor (>=) sent as `min_score` to `/v1/search` |
65
+ | `MEMINI_INJECT_LABELS` | — | comma-separated bullet labels: `tier`, `confidence`, `age` |
66
+ | `MEMINI_TIMEOUT_MS` | `30000` | per-request timeout |
67
+ | `MEMINI_FALLBACK` | on | `0`/`false` surfaces errors instead of degrading silently |
68
+ | `MEMINI_API_KEY` | — | bearer token, if memini needs auth (sent as `Authorization: Bearer …`; alias: `MEMINI_TOKEN`) |
69
+ | `MEMINI_REQUIRE_HTTPS` | — | `1` refuses to send the token over plaintext HTTP |
70
+
71
+ Unset, the namespace is derived from the working-directory basename and sent as
72
+ the `X-Memini-Namespace` header — set it to share one memory pool with your
73
+ other agents (Claude Code, opencode, …).
74
+
75
+ ### Build & test
76
+
77
+ ```sh
78
+ cd integrations/pi/plugin
79
+ npm install
80
+ npm run build # tsc -> dist/index.js
81
+ npm test # pure-helper unit tests (tsx --test)
82
+ ```
83
+
84
+ ## Alternative: MCP wire
85
+
86
+ Pi can also reach memini's `memory_*` tools over MCP, but — unlike Claude Code
87
+ or Codex — Pi has no native MCP client, so you first need an MCP extension for
88
+ Pi (e.g. the one prewired in the [`my-pi`](https://github.com/spences10/my-pi)
89
+ distribution), then point it at memini's server: `http://<host>:8080/mcp`
90
+ (remote) or `memini mcp` (stdio). The native extension above is simpler and adds
91
+ the automatic recall/capture loop on top of the tools, so prefer it.
@@ -0,0 +1,66 @@
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
+ */
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ /**
22
+ * intEnv parses a non-negative integer env var and returns `def` when unset or
23
+ * malformed — env values are user input and shouldn't crash a hook.
24
+ */
25
+ export declare function intEnv(name: string, def: number): number;
26
+ /** floatEnv parses a non-negative float env var; falls back to `def`. */
27
+ export declare function floatEnv(name: string, def: number): number;
28
+ /**
29
+ * labelsEnv parses MEMINI_INJECT_LABELS into a Set of enabled labels.
30
+ * Recognized: "tier", "confidence", "age". Empty/unset returns an empty Set.
31
+ */
32
+ export declare function labelsEnv(name?: string): Set<string>;
33
+ export declare function sanitizeNamespace(s: string): string;
34
+ export declare function deriveNamespace(cwd: string | undefined): string;
35
+ export interface ResolvedConfig {
36
+ base_url: string;
37
+ namespace: string;
38
+ recall: boolean;
39
+ capture: boolean;
40
+ recall_limit: number;
41
+ recall_max_tokens: number;
42
+ recall_min_score: number;
43
+ timeout_ms: number;
44
+ fallback_on_error: boolean;
45
+ }
46
+ export declare function resolveConfig(env: NodeJS.ProcessEnv, cwd?: string): ResolvedConfig;
47
+ /** approxTokens: ~0.75 tokens/word, floor of 1 for any non-empty line. */
48
+ export declare function approxTokens(text: string): number;
49
+ /**
50
+ * fitByTokens trims a list of pre-formatted strings under `maxTokens`, keeping
51
+ * the head (most relevant first). maxTokens<=0 means unbounded.
52
+ */
53
+ export declare function fitByTokens(items: string[], maxTokens: number): {
54
+ items: string[];
55
+ tokens: number;
56
+ dropped: number;
57
+ };
58
+ /** truncate to `max` chars with a marker. */
59
+ export declare function truncate(value: string, max: number): string;
60
+ export declare function formatResults(results: any[], limit: number, labels?: Set<string>): string[];
61
+ export declare function createPlaintextBearerAuthGuard(warn: (m: string) => void, env?: NodeJS.ProcessEnv): (baseUrl: string, secret?: string) => void;
62
+ export declare function meminiListPath(args: any): string;
63
+ export declare function extractMessageText(message: any): string;
64
+ export declare function extractLastAssistantText(messages: any[]): string;
65
+ export declare function buildTurnContent(userText: string, assistantText: string): string;
66
+ export default function meminiExtension(pi: ExtensionAPI): void;
package/dist/index.js ADDED
@@ -0,0 +1,525 @@
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
+ */
20
+ import { Type } from "typebox";
21
+ const DEFAULT_BASE_URL = "http://localhost:8080";
22
+ const DEFAULT_TIMEOUT_MS = 30000;
23
+ const DEFAULT_RECALL_LIMIT = 3;
24
+ const DEFAULT_NAMESPACE = "pi";
25
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
26
+ function envBool(value, fallback) {
27
+ if (value === undefined || value === null || value === "")
28
+ return fallback;
29
+ return !/^(0|false|no|off)$/i.test(String(value).trim());
30
+ }
31
+ /**
32
+ * intEnv parses a non-negative integer env var and returns `def` when unset or
33
+ * malformed — env values are user input and shouldn't crash a hook.
34
+ */
35
+ export function intEnv(name, def) {
36
+ const raw = process.env[name];
37
+ if (raw == null || raw === "")
38
+ return def;
39
+ const n = Number.parseInt(raw, 10);
40
+ if (!Number.isFinite(n) || n < 0)
41
+ return def;
42
+ return n;
43
+ }
44
+ /** floatEnv parses a non-negative float env var; falls back to `def`. */
45
+ export function floatEnv(name, def) {
46
+ const raw = process.env[name];
47
+ if (raw == null || raw === "")
48
+ return def;
49
+ const n = Number.parseFloat(raw);
50
+ if (!Number.isFinite(n) || n < 0)
51
+ return def;
52
+ return n;
53
+ }
54
+ /**
55
+ * labelsEnv parses MEMINI_INJECT_LABELS into a Set of enabled labels.
56
+ * Recognized: "tier", "confidence", "age". Empty/unset returns an empty Set.
57
+ */
58
+ export function labelsEnv(name = "MEMINI_INJECT_LABELS") {
59
+ const raw = process.env[name];
60
+ if (!raw)
61
+ return new Set();
62
+ return new Set(raw
63
+ .split(/[|,]/)
64
+ .map((s) => s.trim().toLowerCase())
65
+ .filter(Boolean));
66
+ }
67
+ // sanitizeNamespace keeps the X-Memini-Namespace value header-safe: alnum, dot,
68
+ // dash, underscore; collapse the rest to dashes and trim.
69
+ export function sanitizeNamespace(s) {
70
+ return String(s)
71
+ .trim()
72
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
73
+ .replace(/^-+|-+$/g, "");
74
+ }
75
+ // deriveNamespace scopes memory to the project: the basename of the working
76
+ // directory, the same scheme memini auto-resolves from a git repo. Returns ""
77
+ // when no path is given.
78
+ export function deriveNamespace(cwd) {
79
+ if (typeof cwd !== "string" || !cwd.trim())
80
+ return "";
81
+ const base = cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
82
+ return sanitizeNamespace(base);
83
+ }
84
+ // resolveConfig builds the config from env vars (Claude Code plugin style),
85
+ // deriving the namespace from cwd when MEMINI_NAMESPACE is unset. Exported for
86
+ // testing.
87
+ export function resolveConfig(env, cwd) {
88
+ const e = env || {};
89
+ const namespace = e.MEMINI_NAMESPACE || deriveNamespace(cwd) || DEFAULT_NAMESPACE;
90
+ const recall_limit = (() => {
91
+ const n = Number(e.MEMINI_RECALL_LIMIT);
92
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_RECALL_LIMIT;
93
+ })();
94
+ return {
95
+ base_url: e.MEMINI_BASE_URL || e.MEMINI_URL || DEFAULT_BASE_URL,
96
+ namespace: sanitizeNamespace(namespace) || DEFAULT_NAMESPACE,
97
+ recall: envBool(e.MEMINI_RECALL, true),
98
+ capture: envBool(e.MEMINI_CAPTURE, true),
99
+ recall_limit,
100
+ recall_max_tokens: intEnv("MEMINI_INJECT_RECALL_MAX_TOK", 0),
101
+ recall_min_score: floatEnv("MEMINI_INJECT_RECALL_MIN_SCORE", 0),
102
+ timeout_ms: Number(e.MEMINI_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
103
+ fallback_on_error: envBool(e.MEMINI_FALLBACK, true),
104
+ };
105
+ }
106
+ // --- token budget (copied from the opencode plugin; both ship standalone) ----
107
+ /** approxTokens: ~0.75 tokens/word, floor of 1 for any non-empty line. */
108
+ export function approxTokens(text) {
109
+ if (!text)
110
+ return 0;
111
+ const words = String(text).trim().split(/\s+/).filter(Boolean).length;
112
+ return Math.max(1, Math.ceil((words * 4) / 3));
113
+ }
114
+ /**
115
+ * fitByTokens trims a list of pre-formatted strings under `maxTokens`, keeping
116
+ * the head (most relevant first). maxTokens<=0 means unbounded.
117
+ */
118
+ export function fitByTokens(items, maxTokens) {
119
+ if (!Array.isArray(items) || items.length === 0)
120
+ return { items: [], tokens: 0, dropped: 0 };
121
+ if (!Number.isFinite(maxTokens) || maxTokens <= 0) {
122
+ const tokens = items.reduce((sum, s) => sum + approxTokens(s), 0);
123
+ return { items: items.slice(), tokens, dropped: 0 };
124
+ }
125
+ const out = [];
126
+ let used = 0;
127
+ let dropped = 0;
128
+ for (const s of items) {
129
+ const t = approxTokens(s);
130
+ if (used + t > maxTokens) {
131
+ dropped++;
132
+ continue;
133
+ }
134
+ out.push(s);
135
+ used += t;
136
+ }
137
+ return { items: out, tokens: used, dropped };
138
+ }
139
+ /** truncate to `max` chars with a marker. */
140
+ export function truncate(value, max) {
141
+ return value.length > max ? value.slice(0, max) + "\n[...truncated]" : value;
142
+ }
143
+ // formatResults renders search hits to bullet lines. Empty labels -> "- (tier)
144
+ // text"; non-empty -> "[tier · conf · age] text". Matches the opencode plugin.
145
+ export function formatResults(results, limit, labels) {
146
+ if (!Array.isArray(results) || results.length === 0)
147
+ return [];
148
+ const useLabels = labels && labels.size > 0 ? labels : null;
149
+ return results
150
+ .slice(0, limit || DEFAULT_RECALL_LIMIT)
151
+ .map((result, index) => {
152
+ const mem = (result && result.memory) || {};
153
+ const text = truncate(String(mem.summary || mem.content || `Memory ${index + 1}`).trim(), 300);
154
+ if (!text)
155
+ return null;
156
+ const tier = String(mem.tier || "memory").trim();
157
+ if (!useLabels)
158
+ return `- (${tier}) ${text}`;
159
+ const tagParts = [];
160
+ if (useLabels.has("tier") && tier)
161
+ tagParts.push(tier);
162
+ if (useLabels.has("confidence") && typeof mem.confidence === "number") {
163
+ tagParts.push(`conf=${mem.confidence.toFixed(2)}`);
164
+ }
165
+ if (useLabels.has("age") && mem.created_at) {
166
+ const ageMs = Date.now() - new Date(mem.created_at).getTime();
167
+ if (Number.isFinite(ageMs) && ageMs >= 0) {
168
+ const days = Math.floor(ageMs / 86400000);
169
+ tagParts.push(days === 0 ? "today" : `${days}d`);
170
+ }
171
+ }
172
+ if (tagParts.length === 0)
173
+ return `- (${tier}) ${text}`;
174
+ return `[${tagParts.join(" · ")}] ${text}`;
175
+ })
176
+ .filter((x) => Boolean(x));
177
+ }
178
+ // --- plaintext-bearer guard (ported from the opencode/openclaw plugins) ------
179
+ function normalizedHostname(hostname) {
180
+ return hostname.replace(/^\[|\]$/g, "").toLowerCase();
181
+ }
182
+ function usesPlaintextBearerAuth(baseUrl, secret) {
183
+ if (!secret)
184
+ return false;
185
+ try {
186
+ const parsed = new URL(baseUrl);
187
+ return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
193
+ function plaintextBearerAuthMessage(baseUrl) {
194
+ 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.`;
195
+ }
196
+ export function createPlaintextBearerAuthGuard(warn, env) {
197
+ let warned = false;
198
+ return function guardPlaintextBearerAuth(baseUrl, secret) {
199
+ if (!usesPlaintextBearerAuth(baseUrl, secret))
200
+ return;
201
+ const message = plaintextBearerAuthMessage(baseUrl);
202
+ if ((env || process.env).MEMINI_REQUIRE_HTTPS === "1")
203
+ throw new Error(message);
204
+ if (!warned) {
205
+ warned = true;
206
+ warn(message);
207
+ }
208
+ };
209
+ }
210
+ function createClient(cfg, warn) {
211
+ const baseUrl = String(cfg.base_url).replace(/\/+$/, "");
212
+ const secret = process.env.MEMINI_API_KEY || process.env.MEMINI_TOKEN;
213
+ const guard = createPlaintextBearerAuthGuard(warn);
214
+ if (process.env.MEMINI_REQUIRE_HTTPS === "1")
215
+ guard(baseUrl, secret);
216
+ function headers(extra) {
217
+ const h = { "X-Memini-Namespace": cfg.namespace, ...(extra || {}) };
218
+ if (secret)
219
+ h.Authorization = `Bearer ${secret}`;
220
+ return h;
221
+ }
222
+ async function request(method, path, body) {
223
+ guard(baseUrl, secret);
224
+ try {
225
+ const res = await fetch(`${baseUrl}${path}`, {
226
+ method,
227
+ headers: headers(body ? { "Content-Type": "application/json" } : undefined),
228
+ body: body ? JSON.stringify(body) : undefined,
229
+ signal: AbortSignal.timeout(cfg.timeout_ms),
230
+ });
231
+ if (!res.ok) {
232
+ if (cfg.fallback_on_error)
233
+ return null;
234
+ const text = await res.text().catch(() => "");
235
+ throw new Error(`memini ${method} ${path} failed: ${res.status} ${text}`);
236
+ }
237
+ // 204 (DELETE) has an empty body; treat a 2xx as ok.
238
+ return await res.json().catch(() => ({ ok: true }));
239
+ }
240
+ catch (error) {
241
+ if (!cfg.fallback_on_error)
242
+ throw error;
243
+ warn(`memini: ${String(error)}`);
244
+ return null;
245
+ }
246
+ }
247
+ return {
248
+ postJson: (path, payload) => request("POST", path, payload),
249
+ getJson: (path) => request("GET", path),
250
+ deleteJson: (path) => request("DELETE", path),
251
+ };
252
+ }
253
+ // meminiListPath builds the GET /v1/memories query string for memory_list:
254
+ // repeatable tier/tag params plus meta=key=value pairs. Exported for testing.
255
+ export function meminiListPath(args) {
256
+ const parts = [];
257
+ for (const t of args?.tiers || [])
258
+ parts.push(`tier=${encodeURIComponent(String(t))}`);
259
+ for (const tag of args?.tags || [])
260
+ parts.push(`tag=${encodeURIComponent(String(tag))}`);
261
+ for (const [k, v] of Object.entries(args?.metadata || {})) {
262
+ parts.push(`meta=${encodeURIComponent(`${k}=${v}`)}`);
263
+ }
264
+ if (Number.isInteger(args?.limit) && args.limit > 0)
265
+ parts.push(`limit=${args.limit}`);
266
+ return parts.length ? `/v1/memories?${parts.join("&")}` : "/v1/memories";
267
+ }
268
+ // --- turn capture helpers ----------------------------------------------------
269
+ // extractMessageText pulls plain text out of a Pi AgentMessage, whose content
270
+ // may be a string or an array of typed parts. Exported for testing.
271
+ export function extractMessageText(message) {
272
+ if (!message)
273
+ return "";
274
+ const c = message.content;
275
+ if (typeof c === "string")
276
+ return c.trim();
277
+ if (Array.isArray(c)) {
278
+ return c
279
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
280
+ .map((p) => p.text)
281
+ .join("\n")
282
+ .trim();
283
+ }
284
+ if (typeof message.text === "string")
285
+ return message.text.trim();
286
+ return "";
287
+ }
288
+ // extractLastAssistantText returns the text of the most recent assistant message.
289
+ // agent_end carries the full conversation (AgentMessage[]), not just this run's
290
+ // messages, so iterate in reverse and take the latest assistant turn only — never
291
+ // a join of every reply in the session. Exported for testing.
292
+ export function extractLastAssistantText(messages) {
293
+ if (!Array.isArray(messages))
294
+ return "";
295
+ for (let i = messages.length - 1; i >= 0; i--) {
296
+ const m = messages[i];
297
+ if (m && m.role === "assistant") {
298
+ const t = extractMessageText(m);
299
+ if (t)
300
+ return t;
301
+ }
302
+ }
303
+ return "";
304
+ }
305
+ // buildTurnContent assembles the episodic payload from the user prompt and the
306
+ // assistant reply, bounding each side. Exported for testing.
307
+ export function buildTurnContent(userText, assistantText) {
308
+ return `${String(userText).slice(0, 1000)}\n\n${String(assistantText).slice(0, 3000)}`;
309
+ }
310
+ const TOOL_NAMES = ["memory_recall", "memory_list", "memory_remember", "memory_forget"];
311
+ const VALID_TIERS = ["working", "episodic", "semantic", "procedural"];
312
+ // sessionIdOf resolves Pi's session id from the read-only session manager on the
313
+ // extension context, so echo-exclusion and capture-dedup are keyed consistently.
314
+ // "" when unavailable.
315
+ function sessionIdOf(ctx) {
316
+ try {
317
+ return String(ctx?.sessionManager?.getSessionId?.() ?? "");
318
+ }
319
+ catch {
320
+ return "";
321
+ }
322
+ }
323
+ // leafIdOf returns the current session leaf-entry id — a stable per-turn key for
324
+ // capture dedup, since Pi's AgentMessages carry no id of their own.
325
+ function leafIdOf(ctx) {
326
+ try {
327
+ return String(ctx?.sessionManager?.getLeafId?.() ?? "");
328
+ }
329
+ catch {
330
+ return "";
331
+ }
332
+ }
333
+ export default function meminiExtension(pi) {
334
+ const warn = (m) => {
335
+ try {
336
+ // ctx.ui.notify isn't available at module scope; log to stderr.
337
+ console.error(`[memini] ${m}`);
338
+ }
339
+ catch {
340
+ /* ignore */
341
+ }
342
+ };
343
+ const cfg = resolveConfig(process.env, process.cwd());
344
+ const client = createClient(cfg, warn);
345
+ // Latest user prompt per session, set in before_agent_start and consumed in
346
+ // agent_end to assemble the full turn.
347
+ const pendingUser = new Map();
348
+ // Assistant message ids already captured, so a re-fired agent_end never writes
349
+ // a duplicate turn.
350
+ const captured = new Set();
351
+ // Recall before the turn: search for the user's prompt and inject the matches
352
+ // as a persistent context message. Buffer the prompt for capture at agent_end.
353
+ pi.on("before_agent_start", async (event, ctx) => {
354
+ const sid = sessionIdOf(ctx);
355
+ const query = String(event?.prompt || "").trim();
356
+ if (query && sid)
357
+ pendingUser.set(sid, query);
358
+ if (!cfg.recall || !query)
359
+ return;
360
+ const body = { query, limit: cfg.recall_limit };
361
+ // Exclude this session's own captured turns: they're still in live context,
362
+ // so recalling them just echoes the conversation back a turn behind.
363
+ if (sid)
364
+ body.exclude_metadata = { session_id: sid };
365
+ if (cfg.recall_min_score > 0)
366
+ body.min_score = cfg.recall_min_score;
367
+ const result = await client.postJson("/v1/search", body);
368
+ const floor = cfg.recall_min_score > 0 ? cfg.recall_min_score : 0;
369
+ const rawHits = Array.isArray(result?.results) ? result.results : [];
370
+ const filtered = floor > 0
371
+ ? rawHits.filter((r) => (typeof r?.score === "number" ? r.score : 0) >= floor)
372
+ : rawHits;
373
+ const hits = formatResults(filtered, cfg.recall_limit, labelsEnv());
374
+ if (hits.length === 0)
375
+ return;
376
+ const fit = fitByTokens(hits, cfg.recall_max_tokens);
377
+ if (fit.items.length === 0)
378
+ return;
379
+ const lines = [
380
+ "Relevant long-term memory from memini (background context — prefer " +
381
+ "current workspace state and the user's instructions):",
382
+ ...fit.items,
383
+ ];
384
+ if (fit.dropped > 0)
385
+ lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
386
+ return {
387
+ message: {
388
+ customType: "memini-recall",
389
+ content: lines.join("\n"),
390
+ display: true,
391
+ },
392
+ };
393
+ });
394
+ // Capture after the turn: pair the buffered user prompt with the assistant
395
+ // reply from this run and store it as episodic memory.
396
+ pi.on("agent_end", async (event, ctx) => {
397
+ if (!cfg.capture)
398
+ return;
399
+ const sid = sessionIdOf(ctx);
400
+ const userText = (sid && pendingUser.get(sid)) || "";
401
+ const assistantText = extractLastAssistantText(event?.messages);
402
+ if (!userText || !assistantText)
403
+ return;
404
+ // AgentMessages carry no id, so key dedup on the session leaf entry — it
405
+ // advances each turn, so a re-fired agent_end for the same turn is skipped.
406
+ const dedupKey = leafIdOf(ctx);
407
+ if (dedupKey && captured.has(dedupKey))
408
+ return;
409
+ const metadata = { source: "pi", format: "turn" };
410
+ if (sid)
411
+ metadata.session_id = sid;
412
+ const stored = await client.postJson("/v1/memories", {
413
+ content: buildTurnContent(userText, assistantText),
414
+ tier: "episodic",
415
+ tags: ["pi"],
416
+ metadata,
417
+ });
418
+ if (stored !== null) {
419
+ if (dedupKey)
420
+ captured.add(dedupKey);
421
+ if (sid)
422
+ pendingUser.delete(sid);
423
+ }
424
+ });
425
+ // Explicit tools — the same set Claude Code gets from memini's MCP server.
426
+ const text = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj) }], details: {} });
427
+ const Tags = Type.Optional(Type.Array(Type.String(), { description: "Match only memories carrying every listed tag (AND)." }));
428
+ const Metadata = Type.Optional(Type.Record(Type.String(), Type.String(), {
429
+ description: 'Match memories whose top-level metadata contains each key=value pair, e.g. {"category":"bug_fixes"}.',
430
+ }));
431
+ pi.registerTool({
432
+ name: "memory_recall",
433
+ label: "Recall memory",
434
+ description: "Recall relevant memories from long-term memory (memini) via hybrid (semantic + keyword) search.",
435
+ parameters: Type.Object({
436
+ query: Type.String({ description: "What to search for" }),
437
+ limit: Type.Optional(Type.Number({ description: "Max results (default 3)" })),
438
+ tags: Tags,
439
+ metadata: Metadata,
440
+ }),
441
+ async execute(_toolCallId, params) {
442
+ const body = { query: params.query, limit: params.limit || DEFAULT_RECALL_LIMIT };
443
+ if (params.tags?.length)
444
+ body.tags = params.tags;
445
+ if (params.metadata && Object.keys(params.metadata).length)
446
+ body.metadata = params.metadata;
447
+ const res = await client.postJson("/v1/search", body);
448
+ const results = (res?.results || []).map((r) => ({
449
+ id: r?.memory?.id || "",
450
+ content: r?.memory?.content || "",
451
+ summary: r?.memory?.summary || "",
452
+ tier: r?.memory?.tier || "",
453
+ score: typeof r?.score === "number" ? r.score : 0,
454
+ }));
455
+ return text({ results });
456
+ },
457
+ });
458
+ pi.registerTool({
459
+ name: "memory_list",
460
+ label: "List memory",
461
+ description: "Browse long-term memory (memini) without a query — filter by tier, tags, or metadata " +
462
+ "category (e.g. all procedural memories or everything categorized bug_fixes). Newest first.",
463
+ parameters: Type.Object({
464
+ tiers: Type.Optional(Type.Array(Type.String(), { description: "Restrict to these tiers; empty means all." })),
465
+ tags: Tags,
466
+ metadata: Metadata,
467
+ limit: Type.Optional(Type.Number({ description: "Max results (0 = all, default 20)" })),
468
+ }),
469
+ async execute(_toolCallId, params) {
470
+ const args = { ...params, limit: params.limit ?? 20 };
471
+ const res = await client.getJson(meminiListPath(args));
472
+ const memories = (res?.memories || []).map((m) => ({
473
+ id: m.id || "",
474
+ content: m.content || "",
475
+ summary: m.summary || "",
476
+ tier: m.tier || "",
477
+ tags: m.tags || [],
478
+ metadata: m.metadata || {},
479
+ }));
480
+ return text({ memories });
481
+ },
482
+ });
483
+ pi.registerTool({
484
+ name: "memory_remember",
485
+ label: "Remember",
486
+ description: "Store a durable fact, decision, or preference in long-term memory (memini).",
487
+ parameters: Type.Object({
488
+ content: Type.String({ description: "The fact to remember" }),
489
+ tier: Type.Optional(Type.String({
490
+ description: "semantic=durable knowledge, procedural=how-to, episodic=what happened, working=transient (default semantic)",
491
+ })),
492
+ tags: Type.Optional(Type.Array(Type.String(), { description: "Optional keywords for later search/filtering." })),
493
+ category: Type.Optional(Type.String({
494
+ description: "Optional topic bucket stored as metadata.category (e.g. bug_fixes, architecture_decisions) for browsing by subject later.",
495
+ })),
496
+ }),
497
+ async execute(_toolCallId, params) {
498
+ const body = { content: params.content, tier: params.tier || "semantic" };
499
+ if (!VALID_TIERS.includes(body.tier))
500
+ body.tier = "semantic";
501
+ if (params.tags?.length)
502
+ body.tags = params.tags;
503
+ if (params.category)
504
+ body.metadata = { category: params.category };
505
+ const res = await client.postJson("/v1/memories", body);
506
+ return text({ id: res?.id || null, success: res != null });
507
+ },
508
+ });
509
+ pi.registerTool({
510
+ name: "memory_forget",
511
+ label: "Forget",
512
+ description: "Delete a memory from long-term memory (memini) by its id — use when a recalled memory is wrong, " +
513
+ "outdated, or poisoned. Get the id from memory_recall or memory_list. This is a soft delete (tombstone).",
514
+ parameters: Type.Object({
515
+ id: Type.String({ description: "The id of the memory to forget (from memory_recall / memory_list)." }),
516
+ }),
517
+ async execute(_toolCallId, params) {
518
+ if (!params.id)
519
+ return text({ forgotten: false, error: "id is required" });
520
+ const res = await client.deleteJson(`/v1/memories/${encodeURIComponent(params.id)}`);
521
+ return text({ forgotten: res != null });
522
+ },
523
+ });
524
+ void TOOL_NAMES;
525
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@eleboucher/pi-memini",
3
+ "version": "0.4.23",
4
+ "description": "Shared cross-session memory for the Pi coding agent, backed by a memini service.",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "test": "npx tsx --test test/helpers.test.ts",
12
+ "typecheck": "tsc -p tsconfig.test.json"
13
+ },
14
+ "dependencies": {
15
+ "typebox": "^1.1.38"
16
+ },
17
+ "devDependencies": {
18
+ "@earendil-works/pi-coding-agent": "latest",
19
+ "@types/node": "^24.0.0",
20
+ "tsx": "^4.20.0",
21
+ "typescript": "^5.9.0"
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "./dist/index.js"
29
+ ]
30
+ }
31
+ }