@everme/pi 0.6.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 ADDED
@@ -0,0 +1,94 @@
1
+ # @everme/pi
2
+
3
+ EverMe long-term memory for the [Pi coding agent](https://pi.dev): profile and
4
+ recall injection, automatic turn capture, and four memory tools.
5
+
6
+ Verified against **pi 0.80.10**. Requires **pi >= 0.80.4** — the
7
+ `agent_settled` event this extension uploads on was added in that release.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ evercli plugin install pi
13
+ ```
14
+
15
+ That provisions the credentials (`~/.pi/agent/everme.env`, mode 0600) and runs
16
+ `pi install npm:@everme/pi`, which records the package in the `packages` array
17
+ of `~/.pi/agent/settings.json`. Start a new pi session afterwards.
18
+
19
+ To install the package without evercli and provide credentials yourself:
20
+
21
+ ```bash
22
+ pi install npm:@everme/pi
23
+ printf 'EVERME_AGENT_ID=agt_...\nEVERME_AGENT_TOKEN=evt_...\n' > ~/.pi/agent/everme.env
24
+ chmod 600 ~/.pi/agent/everme.env
25
+ ```
26
+
27
+ `/everme` prints the status: which agent, which credential file, whether the
28
+ profile was injected, how many turns were saved, and the last error if any.
29
+
30
+ ## What it does
31
+
32
+ | pi event | what the extension does |
33
+ |---|---|
34
+ | `session_start` | resets per-session state; on resume/reload/fork checks whether this session already carries the profile |
35
+ | `before_agent_start` | injects `<everme_profile>` (once per session) and `<everme_recall>` (every turn) as a `custom_message` — model context, not TUI output |
36
+ | `agent_end` | buffers that run's messages |
37
+ | `agent_settled` | uploads the buffered turn (no flush: the gateway extracts on its own, and waiting for it is ~30x slower) |
38
+
39
+ Tools: `mem_search`, `mem_context`, `mem_save_fact`, `mem_save_turn`. The
40
+ `everme` skill tells the model when to reach for them.
41
+
42
+ ### Why the buffer
43
+
44
+ `agent_end` is the only event that carries messages, and it can fire several
45
+ times for one user turn — pi may auto-retry, auto-compact and retry, or run a
46
+ queued follow-up. `agent_settled` is the guarantee that nothing more will run
47
+ automatically, but **its payload is empty** (verified in pi's own type
48
+ declarations: `{ type: "agent_settled" }` and nothing else). So the transcript
49
+ is collected at `agent_end` and uploaded at `agent_settled`.
50
+
51
+ ### Failure policy
52
+
53
+ Recall and profile injection are best-effort and bounded by their own
54
+ deadlines (6s / 8s), well inside anything a user would wait for. On timeout or
55
+ error the turn proceeds with no injection: a memory lookup must never be the
56
+ reason a prompt hangs. pi's own provider layer can already stall — an
57
+ `openai-codex` OAuth refresh was observed hanging a whole run — and this
58
+ extension refuses to add to that.
59
+
60
+ Saves are reported, never faked. A failed upload surfaces in `/everme` and in
61
+ the tool result; nothing is silently queued or dropped.
62
+
63
+ ## Notes for maintainers
64
+
65
+ - The extension is plain ESM JavaScript, like every other package here. pi's
66
+ loader accepts `.js` and `.ts` equally; `.js` needs no jiti transform and
67
+ keeps this package consistent with the rest of `plugins/`.
68
+ - `typebox` is a **peer** dependency: pi validates tool parameters with its own
69
+ copy, and bundling a second one is what pi's packaging docs warn against.
70
+ It is also a devDependency so the tests can build schemas.
71
+ - The SDK's `runHook()` is deliberately **not** used. It arms a watchdog that
72
+ calls `process.exit(0)` near the host's kill deadline, which is right for a
73
+ hook process and fatal inside the long-lived pi TUI. This package uses the
74
+ SDK primitives (client, `runInject`, `saveAgentMemory`, rune caps,
75
+ redaction) and owns its own deadlines.
76
+ - Nothing is written to `process.env`. pi spawns tools and user bash commands
77
+ from the same environment, so an agent token placed there would leak into
78
+ every child process.
79
+ - Cold-start import for pi sessions lives in evercli
80
+ (`evercli import conversations run --platform pi`), which reads
81
+ `~/.pi/agent/sessions/**` directly. The extension does not backfill history.
82
+
83
+ ## Tests
84
+
85
+ ```bash
86
+ npm test --workspace @everme/pi # from plugins/
87
+ ```
88
+
89
+ The extension tests drive the real event chain through a stand-in `pi` object
90
+ against a local HTTP backend, asserting the injected block shapes and the
91
+ upload body. The install path itself was verified on a real pi 0.80.10:
92
+ `pi install <path>` is headless, `pi list` shows the package, `pi remove`
93
+ restores `settings.json` byte-for-byte, and a live run reached
94
+ `/mem/context`, `/mem/search` and `/mem/agent-memory` in that order.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@everme/pi",
3
+ "version": "0.6.2",
4
+ "type": "module",
5
+ "description": "EverMe long-term memory for the Pi coding agent: profile and recall injection, automatic turn capture, and four memory tools.",
6
+ "license": "Apache-2.0",
7
+ "files": [
8
+ "src/",
9
+ "skills/",
10
+ "LICENSE",
11
+ "README.md"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./src/extension.js"
16
+ ],
17
+ "skills": [
18
+ "./skills"
19
+ ]
20
+ },
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "scripts": {
25
+ "test": "node --test tests/credentials.test.js tests/extension.test.js"
26
+ },
27
+ "keywords": [
28
+ "pi-package",
29
+ "evermind",
30
+ "everme",
31
+ "pi",
32
+ "memory"
33
+ ],
34
+ "homepage": "https://everme.evermind.ai",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/EverMind-AI/EverMe.git",
38
+ "directory": "plugins/pi"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/EverMind-AI/EverMe/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org"
46
+ },
47
+ "dependencies": {
48
+ "@everme/agent-sdk": "^0.6.2"
49
+ },
50
+ "peerDependencies": {
51
+ "typebox": "*"
52
+ },
53
+ "devDependencies": {
54
+ "typebox": "^1.0.0"
55
+ }
56
+ }
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: everme
3
+ description: |
4
+ Use EverMe long-term memory when the user refers to previous
5
+ conversations, earlier decisions, "last time", "remember when", existing
6
+ project conventions, or previously solved errors, and save durable user
7
+ preferences, habits, and decisions the moment they are stated. This
8
+ package's extension already injects a profile at session start and a
9
+ recall block before each turn, and saves the whole turn when the agent
10
+ settles — so do not repeat work the injected blocks already did.
11
+ ---
12
+
13
+ # What this package gives you
14
+
15
+ `@everme/pi` installs a pi extension that does three things without being
16
+ asked, plus four tools you call yourself.
17
+
18
+ Automatic, via extension events:
19
+
20
+ | when | what happens |
21
+ |---|---|
22
+ | session start | the user's durable Profile is injected as `<everme_profile>` |
23
+ | before each agent turn | relevant memories are injected as `<everme_recall>` |
24
+ | after the agent settles | the whole turn (messages + tool calls) is saved |
25
+
26
+ Tools, for the cases the automatic path does not cover:
27
+
28
+ | tool | input | output |
29
+ |---|---|---|
30
+ | `mem_context` | `{ forceRefresh? }` | the durable Profile only — never a search |
31
+ | `mem_search` | `{ query, topK? }` | search results across all memory buckets |
32
+ | `mem_save_fact` | `{ fact }` | `{ saved, accepted, status, extracted, profileUpdated }` |
33
+ | `mem_save_turn` | `{ role, text }` or `{ messages }` | `{ saved, accepted, status, messageCount, flushed }` |
34
+
35
+ # When to call the tools
36
+
37
+ **Call them autonomously when a trigger fires — not only when the user says
38
+ "remember" or "recall".**
39
+
40
+ **Check the injected blocks first.** When a non-empty, relevant
41
+ `<everme_recall>` block is already in your context this turn, do not
42
+ re-fetch the same thing with `mem_search`. Injection can fail silently
43
+ (network, revoked credentials): recall is best-effort by design, because a
44
+ memory lookup must never block the user's turn. So when the block is
45
+ missing or clearly unrelated and the task depends on history, search.
46
+
47
+ - **`mem_search`** — the user points back at earlier conversations,
48
+ decisions, conventions, or previously solved problems ("what did we
49
+ decide about X", "like last time", "did we fix this before"), and the
50
+ injected recall did not already answer it. Keep `query` SHORT: a few
51
+ keywords naming the topic, never the whole conversation. Do not repeat an
52
+ identical query in the same turn.
53
+ - **`mem_context`** — only when no `<everme_profile>` block was injected
54
+ this session. It returns the Profile alone; it is not a way to recall
55
+ past decisions or task context, which is `mem_search`'s job. Once per
56
+ session; `forceRefresh: true` only when the user explicitly asks.
57
+ - **`mem_save_fact`** — the user states something true about themselves
58
+ that should outlive this conversation (a preference, habit, trait,
59
+ long-term goal, or decision). Only `extracted: true` /
60
+ `profileUpdated: true` means it reached the Profile; on
61
+ `status: "no_extraction"` say so plainly, do not auto-retry, and do not
62
+ claim it was remembered.
63
+ - **`mem_save_turn`** — a conclusion worth reusing that the automatic save
64
+ would not capture as such. The turn is already saved for you when the
65
+ agent settles, so this is for deliberate, self-contained records, not for
66
+ re-saving the conversation.
67
+
68
+ Rows returned under "Recent unextracted transcript" are provisional — not
69
+ yet extracted — so never quote them as established facts.
70
+
71
+ # Errors
72
+
73
+ Tools fail with a short message: `auth` (credentials revoked or expired —
74
+ re-run `evercli plugin install pi`), `network` (backend unreachable, retry
75
+ with backoff), `upstream` (backend returned a non-zero status; the message
76
+ carries the requestId for support). A failed save is reported as a failure;
77
+ there is no fake success and no local queue that silently drops.
78
+
79
+ Cold-start memory — everything the user already had before installing —
80
+ is loaded by `evercli import conversations run`, which reads pi's own
81
+ session files. You do not need to re-upload history from inside the agent.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Credential loading for the Pi extension.
3
+ *
4
+ * `evercli plugin install pi` writes ~/.pi/agent/everme.env at 0600 with the
5
+ * standard three keys. The extension runs inside the pi process, so it must
6
+ * NOT mutate process.env: pi spawns tools and user bash commands from the
7
+ * same environment, and injecting an agent token there would leak it into
8
+ * every child process. The file is read into a plain object instead and
9
+ * handed to the SDK explicitly.
10
+ */
11
+
12
+ import { readFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const KEYS = ["EVERME_API_BASE", "EVERME_AGENT_ID", "EVERME_AGENT_TOKEN"];
17
+
18
+ export function piAgentHome(env = process.env) {
19
+ return env.EVERCLI_PI_CONFIG_DIR || join(homedir(), ".pi", "agent");
20
+ }
21
+
22
+ export function envFilePath(env = process.env) {
23
+ return env.EVERME_ENV_FILE_PATH || join(piAgentHome(env), "everme.env");
24
+ }
25
+
26
+ /**
27
+ * Parse a dotenv-style file. Tolerates `export VAR=`, surrounding quotes and
28
+ * comment lines — the same shapes evercli's own reader accepts, so a file a
29
+ * user hand-edited behaves the same on both sides.
30
+ */
31
+ export function parseEnvFile(text) {
32
+ const out = {};
33
+ for (const rawLine of String(text || "").split("\n")) {
34
+ let line = rawLine.trim();
35
+ if (!line || line.startsWith("#")) continue;
36
+ if (line.startsWith("export ")) line = line.slice("export ".length).trim();
37
+ const eq = line.indexOf("=");
38
+ if (eq <= 0) continue;
39
+ const key = line.slice(0, eq).trim();
40
+ if (!KEYS.includes(key)) continue;
41
+ out[key] = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Load the credentials.
48
+ *
49
+ * Returns { configured, agentId, agentToken, apiBase, path, reason }.
50
+ * `configured` false is a normal state, not an error: a user who installed
51
+ * the pi package from npm without running `evercli plugin install pi` has no
52
+ * env file yet. Events skip silently in that state and the tools return the
53
+ * install instruction, so nothing about the failure is guessed at.
54
+ */
55
+ export async function loadCredentials(env = process.env) {
56
+ const path = envFilePath(env);
57
+ let parsed = {};
58
+ let reason = "";
59
+ try {
60
+ parsed = parseEnvFile(await readFile(path, "utf8"));
61
+ } catch (error) {
62
+ reason = error?.code === "ENOENT" ? "missing" : "unreadable";
63
+ }
64
+ // Explicit process env wins over the file: that is how a CI smoke run or a
65
+ // `pi -e` trial run points the extension at a test backend without writing
66
+ // a credential file onto the machine.
67
+ const agentId = env.EVERME_AGENT_ID || parsed.EVERME_AGENT_ID || "";
68
+ const agentToken = env.EVERME_AGENT_TOKEN || parsed.EVERME_AGENT_TOKEN || "";
69
+ const apiBase = env.EVERME_API_BASE || parsed.EVERME_API_BASE || "";
70
+ if (!reason && !(agentId && agentToken)) reason = "incomplete";
71
+ return {
72
+ configured: Boolean(agentId && agentToken),
73
+ agentId,
74
+ agentToken,
75
+ apiBase,
76
+ path,
77
+ reason: agentId && agentToken ? "" : reason || "missing",
78
+ };
79
+ }
80
+
81
+ export function notConfiguredMessage(credentials) {
82
+ const detail = credentials?.reason === "unreadable"
83
+ ? `could not be read at ${credentials.path}`
84
+ : `is missing or incomplete at ${credentials?.path || envFilePath()}`;
85
+ return `EverMe is not configured for pi: the credential file ${detail}. Run \`evercli plugin install pi\` to provision it, then start a new pi session.`;
86
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * EverMe memory extension for the Pi coding agent.
3
+ *
4
+ * Verified against pi 0.80.10. Event contract (from pi's own type
5
+ * declarations, not inferred):
6
+ *
7
+ * session_start { reason: "startup"|"reload"|"new"|"resume"|"fork" }
8
+ * before_agent_start { prompt, systemPrompt, ... } -> may return
9
+ * { message: { customType, content, display } }
10
+ * which pi persists as a `custom_message` entry AND
11
+ * sends to the model. This is the injection channel.
12
+ * agent_end { messages } <- the ONLY event carrying messages
13
+ * agent_settled { } <- no payload at all
14
+ *
15
+ * That split is the whole reason this file buffers: pi may auto-retry,
16
+ * auto-compact, or run queued follow-ups after agent_end, so agent_end can
17
+ * fire several times for one user turn. agent_settled is the guarantee that
18
+ * nothing more runs automatically — but it carries no messages, so the
19
+ * transcript has to be collected at agent_end and uploaded at agent_settled.
20
+ * (Reading it back from ctx.sessionManager at settle time was the
21
+ * alternative; buffering keeps exactly the messages of this turn instead of
22
+ * re-deriving a branch.)
23
+ *
24
+ * Failure policy: recall and profile injection are best-effort and never
25
+ * block a turn — a memory lookup that cannot answer must not stop the user
26
+ * from working. Saves report their failure (through /everme and the tool
27
+ * result), never a fake success.
28
+ */
29
+
30
+ import { mkdir, writeFile } from "node:fs/promises";
31
+ import path from "node:path";
32
+ import { Type } from "typebox";
33
+ import { loadCredentials, notConfiguredMessage, piAgentHome } from "./credentials.js";
34
+ import {
35
+ describeFailure,
36
+ fetchProfileBlock,
37
+ fetchRecallBlock,
38
+ profile,
39
+ saveFact,
40
+ saveTurn,
41
+ search,
42
+ } from "./memory.js";
43
+
44
+ // Marks a session as already carrying the profile block. Persisted as a pi
45
+ // `custom` entry, which survives a reload/resume and does NOT enter the LLM
46
+ // context — exactly what a bookkeeping flag should be.
47
+ const PROFILE_MARK = "everme-profile-injected";
48
+ const MESSAGE_TYPE = "everme-memory";
49
+
50
+ export default function evermeExtension(pi) {
51
+ const state = {
52
+ // pi AgentMessages collected from every agent_end of the current turn.
53
+ pending: [],
54
+ profileInjected: false,
55
+ lastSave: null,
56
+ lastError: "",
57
+ saves: 0,
58
+ // Turns whose upload failed and were written to disk instead of being
59
+ // dropped. Never auto-resent — see the agent_settled handler.
60
+ spooled: 0,
61
+ lastSpoolPath: "",
62
+ // Turns whose save timed out. Counted separately from failures: the
63
+ // gateway had already stored them (see the agent_settled handler).
64
+ unconfirmed: 0,
65
+ lastUnconfirmed: null,
66
+ };
67
+
68
+ const credentialsOnce = memoize(() => loadCredentials());
69
+
70
+ pi.on("session_start", async (event, ctx) => {
71
+ state.pending = [];
72
+ state.profileInjected = false;
73
+ // A resumed or reloaded session may already carry the profile from its
74
+ // first run; re-injecting it would duplicate a block the model can
75
+ // already see in its own transcript.
76
+ if (event?.reason === "resume" || event?.reason === "reload" || event?.reason === "fork") {
77
+ state.profileInjected = hasProfileMark(ctx);
78
+ }
79
+ });
80
+
81
+ pi.on("before_agent_start", async (event, ctx) => {
82
+ const credentials = await credentialsOnce();
83
+ if (!credentials.configured) return undefined;
84
+
85
+ const blocks = [];
86
+ if (!state.profileInjected) {
87
+ const block = await attempt(state, () => fetchProfileBlock(credentials));
88
+ if (block) {
89
+ blocks.push(block);
90
+ state.profileInjected = true;
91
+ try {
92
+ pi.appendEntry(PROFILE_MARK, { at: new Date().toISOString() });
93
+ } catch {
94
+ // The mark is an optimization; failing to persist it only costs a
95
+ // duplicate profile fetch on the next resume.
96
+ }
97
+ }
98
+ }
99
+ const recall = await attempt(state, () => fetchRecallBlock(credentials, event?.prompt || ""));
100
+ if (recall) blocks.push(recall);
101
+ if (!blocks.length) return undefined;
102
+
103
+ return {
104
+ message: {
105
+ customType: MESSAGE_TYPE,
106
+ content: blocks.join("\n\n"),
107
+ // Not shown in the TUI: this is context for the model, and printing
108
+ // the user's whole profile above their own prompt is noise.
109
+ display: false,
110
+ },
111
+ };
112
+ });
113
+
114
+ // Every agent_end of the turn contributes its messages; a retry or an
115
+ // auto-compaction adds another batch rather than replacing this one.
116
+ pi.on("agent_end", (event) => {
117
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
118
+ if (messages.length) state.pending.push(...messages);
119
+ });
120
+
121
+ pi.on("agent_settled", async (_event, ctx) => {
122
+ const messages = state.pending;
123
+ state.pending = [];
124
+ if (!messages.length) return;
125
+ const credentials = await credentialsOnce();
126
+ if (!credentials.configured) return;
127
+ const conversationId = conversationIdOf(ctx);
128
+ if (!conversationId) return;
129
+ try {
130
+ // flush:false deliberately. A flushing write blocks on the upstream
131
+ // extraction: measured 29.8s against a real backend versus 0.9s for
132
+ // the same payload without it, which is past every hook budget here
133
+ // and made EVERY turn report a failure it had not actually suffered.
134
+ // The server promotes an un-flushed session on its own (verified: a
135
+ // session written with flush:false and never flushed successfully was
136
+ // extracted into an episode ~100s later), so nothing is lost by
137
+ // letting it do that instead of waiting.
138
+ const result = await saveTurn(credentials, {
139
+ conversationId,
140
+ messages,
141
+ flush: false,
142
+ timeoutMs: saveTimeoutMs(),
143
+ });
144
+ state.saves += 1;
145
+ state.lastSave = {
146
+ at: new Date().toISOString(),
147
+ messages: messages.length,
148
+ status: result?.status || "",
149
+ requestId: result?.requestId || "",
150
+ };
151
+ state.lastError = "";
152
+ } catch (error) {
153
+ // A timeout is NOT a failed save. The gateway adds the messages before
154
+ // it flushes, and nothing on that path detaches the request context,
155
+ // so by the time a client-side deadline fires the messages are already
156
+ // stored — verified against two real timed-out sessions, both present
157
+ // and extracted server-side afterwards. Spooling those would keep a
158
+ // duplicate of data that is already saved, and reporting them as
159
+ // failures is a lie in the conservative direction.
160
+ if (error?.type === "timeout") {
161
+ state.unconfirmed += 1;
162
+ state.lastUnconfirmed = {
163
+ at: new Date().toISOString(),
164
+ messages: messages.length,
165
+ detail: describeFailure(error),
166
+ };
167
+ return;
168
+ }
169
+ state.lastError = describeFailure(error);
170
+ // A real failure is different: the request was refused, rejected or
171
+ // never arrived, so the turn IS gone unless it is kept here.
172
+ //
173
+ // No automatic resend: a POST that died mid-flight may already have
174
+ // been applied upstream, and the write path carries no
175
+ // idempotency key, so re-sending the same turn can double-ingest it.
176
+ //
177
+ // No silent discard either: `evercli import` excludes every session
178
+ // started inside this platform's install-mark window, because the
179
+ // live extension is supposed to own that era. A turn dropped here is
180
+ // therefore NOT recoverable by a later cold-start import - it is gone.
181
+ //
182
+ // So the turn is written to disk as an explicit, recoverable artifact
183
+ // and reported by /everme. Replaying it is a deliberate act.
184
+ const spoolPath = await spoolFailedTurn(conversationId, messages, state.lastError);
185
+ if (spoolPath) {
186
+ state.spooled += 1;
187
+ state.lastSpoolPath = spoolPath;
188
+ } else {
189
+ // The spool write failed too (read-only home, full disk). Say so
190
+ // rather than leaving the user believing the turn was kept.
191
+ state.lastError += " (and the failed turn could not be spooled)";
192
+ }
193
+ }
194
+ });
195
+
196
+ pi.registerTool({
197
+ name: "mem_search",
198
+ label: "EverMe search",
199
+ description:
200
+ "Search the user's EverMe long-term memory. Use when the user refers to earlier conversations, decisions, conventions, or previously solved problems and the injected <everme_recall> block did not already answer it. Keep the query short - a few keywords naming the topic, never the whole conversation.",
201
+ promptSnippet: "mem_search - search the user's long-term memory",
202
+ // TypeBox, not a hand-written JSON Schema: pi validates tool arguments
203
+ // through typebox's compiler, which keys off the Kind symbol its own
204
+ // builders attach. typebox is a peer dependency so pi's copy is used.
205
+ parameters: Type.Object({
206
+ query: Type.String({ description: "Short keyword query naming the topic." }),
207
+ topK: Type.Optional(Type.Integer({ description: "Maximum results (default 10)." })),
208
+ }),
209
+ async execute(_toolCallId, params) {
210
+ const credentials = await requireCredentials(credentialsOnce);
211
+ const result = await search(credentials, { query: params?.query || "", topK: params?.topK });
212
+ return textResult(renderSearch(result));
213
+ },
214
+ });
215
+
216
+ pi.registerTool({
217
+ name: "mem_context",
218
+ label: "EverMe profile",
219
+ description:
220
+ "Return the user's durable EverMe profile. Only needed when no <everme_profile> block was injected this session. This is NOT a search: it never returns past decisions or task context - use mem_search for that.",
221
+ promptSnippet: "mem_context - read the user's durable profile",
222
+ parameters: Type.Object({
223
+ forceRefresh: Type.Optional(
224
+ Type.Boolean({ description: "Rebuild the profile instead of using the cached one." }),
225
+ ),
226
+ }),
227
+ async execute(_toolCallId, params) {
228
+ const credentials = await requireCredentials(credentialsOnce);
229
+ const result = await profile(credentials, { forceRefresh: Boolean(params?.forceRefresh) });
230
+ return textResult(result?.context || "(no profile stored yet)");
231
+ },
232
+ });
233
+
234
+ pi.registerTool({
235
+ name: "mem_save_fact",
236
+ label: "EverMe save fact",
237
+ description:
238
+ "Save one durable fact about the user (a preference, habit, trait, long-term goal, or decision) to their profile. Call it as soon as such a fact is stated, without waiting to be asked. Only an extracted/profileUpdated result means the profile actually changed.",
239
+ promptSnippet: "mem_save_fact - remember a durable fact about the user",
240
+ parameters: Type.Object({
241
+ fact: Type.String({ description: "The fact, as one self-contained sentence." }),
242
+ }),
243
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
244
+ const credentials = await requireCredentials(credentialsOnce);
245
+ const fact = String(params?.fact || "").trim();
246
+ if (!fact) throw new Error("fact must not be empty");
247
+ const conversationId = conversationIdOf(ctx) || `pi-${Date.now()}`;
248
+ const result = await saveFact(credentials, { fact, conversationId });
249
+ return textResult(JSON.stringify({
250
+ saved: Boolean(result),
251
+ status: result?.status || "",
252
+ extracted: result?.extracted ?? null,
253
+ profileUpdated: result?.profileUpdated ?? null,
254
+ requestId: result?.requestId || "",
255
+ }));
256
+ },
257
+ });
258
+
259
+ pi.registerTool({
260
+ name: "mem_save_turn",
261
+ label: "EverMe save turn",
262
+ description:
263
+ "Save a conversation turn to EverMe. The extension already saves each turn automatically when the agent settles, so use this only for a deliberate, self-contained record worth reusing - not to re-save the conversation.",
264
+ promptSnippet: "mem_save_turn - record a reusable trajectory",
265
+ parameters: Type.Object({
266
+ // A plain string rather than an enum on purpose: Type.Union/Type.Literal
267
+ // does not survive Google's tool schema, and pi's StringEnum helper
268
+ // would add a second peer dependency for two values.
269
+ role: Type.Optional(Type.String({ description: "user or assistant (default assistant)." })),
270
+ text: Type.String({ description: "The content to save." }),
271
+ }),
272
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
273
+ const credentials = await requireCredentials(credentialsOnce);
274
+ const text = String(params?.text || "").trim();
275
+ if (!text) throw new Error("text must not be empty");
276
+ const role = params?.role === "user" ? "user" : "assistant";
277
+ const conversationId = conversationIdOf(ctx) || `pi-${Date.now()}`;
278
+ const result = await saveTurn(credentials, {
279
+ conversationId,
280
+ messages: [{ role, content: text, timestamp: Date.now() }],
281
+ flush: true,
282
+ });
283
+ return textResult(JSON.stringify({
284
+ saved: Boolean(result),
285
+ status: result?.status || "",
286
+ flushed: Boolean(result?.flushed),
287
+ requestId: result?.requestId || "",
288
+ }));
289
+ },
290
+ });
291
+
292
+ pi.registerCommand("everme", {
293
+ description: "EverMe memory status for this session",
294
+ async handler(_args, ctx) {
295
+ const credentials = await credentialsOnce();
296
+ const lines = [];
297
+ if (!credentials.configured) {
298
+ lines.push(notConfiguredMessage(credentials));
299
+ } else {
300
+ lines.push(`agent: ${credentials.agentId}`);
301
+ lines.push(`credentials: ${credentials.path}`);
302
+ lines.push(`conversation: ${conversationIdOf(ctx) || "(none yet)"}`);
303
+ lines.push(`profile injected: ${state.profileInjected ? "yes" : "no"}`);
304
+ lines.push(`turns saved: ${state.saves}`);
305
+ if (state.lastSave) {
306
+ lines.push(
307
+ `last save: ${state.lastSave.at} messages=${state.lastSave.messages}` +
308
+ ` status=${state.lastSave.status || "-"} requestId=${state.lastSave.requestId || "-"}`,
309
+ );
310
+ }
311
+ }
312
+ if (state.unconfirmed) {
313
+ lines.push(
314
+ `turns awaiting confirmation: ${state.unconfirmed}` +
315
+ (state.lastUnconfirmed ? ` (latest ${state.lastUnconfirmed.at})` : "") +
316
+ " - the gateway stored them; only the response timed out",
317
+ );
318
+ }
319
+ if (state.spooled) {
320
+ lines.push(
321
+ `turns spooled after a failed save: ${state.spooled}` +
322
+ ` (latest: ${state.lastSpoolPath}) - not resent automatically`,
323
+ );
324
+ }
325
+ if (state.lastError) lines.push(`last error: ${state.lastError}`);
326
+ lines.push(`pi home: ${piAgentHome()}`);
327
+ pi.sendMessage({
328
+ customType: MESSAGE_TYPE + "-status",
329
+ content: lines.join("\n"),
330
+ // The user asked, so this one IS shown.
331
+ display: true,
332
+ });
333
+ },
334
+ });
335
+ }
336
+
337
+ /**
338
+ * pi's session id is the conversation id on the EverMe side. When the session
339
+ * is ephemeral (`--no-session`) there is none, and an invented id would
340
+ * scatter one conversation across many — so the caller skips instead.
341
+ */
342
+ // spoolFailedTurn persists one unsaved turn under the pi agent home so a
343
+ // backend failure cannot silently lose it. Returns the path, or "" when even
344
+ // this could not be written. 0600 / 0700: the transcript is user content.
345
+ async function spoolFailedTurn(conversationId, messages, reason) {
346
+ try {
347
+ const dir = path.join(piAgentHome(), "everme-unsaved-turns");
348
+ await mkdir(dir, { recursive: true, mode: 0o700 });
349
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
350
+ const file = path.join(dir, `${stamp}-${sanitizeForFilename(conversationId)}.json`);
351
+ await writeFile(
352
+ file,
353
+ JSON.stringify({ conversationId, failedAt: new Date().toISOString(), reason, messages }, null, 2),
354
+ { mode: 0o600 },
355
+ );
356
+ return file;
357
+ } catch {
358
+ return "";
359
+ }
360
+ }
361
+
362
+ function sanitizeForFilename(value) {
363
+ return String(value || "session").replace(/[^\w.-]/g, "_").slice(0, 80);
364
+ }
365
+
366
+ // saveTimeoutMs lets an operator (and the tests) move the write deadline
367
+ // without a release. The default lives in memory.js.
368
+ function saveTimeoutMs() {
369
+ const raw = Number(process.env.EVERME_PI_SAVE_TIMEOUT_MS);
370
+ return Number.isFinite(raw) && raw > 0 ? raw : undefined;
371
+ }
372
+
373
+ function conversationIdOf(ctx) {
374
+ try {
375
+ return ctx?.sessionManager?.getSessionId?.() || "";
376
+ } catch {
377
+ return "";
378
+ }
379
+ }
380
+
381
+ function hasProfileMark(ctx) {
382
+ try {
383
+ const entries = ctx?.sessionManager?.getEntries?.() || [];
384
+ return entries.some((entry) => entry?.type === "custom" && entry?.customType === PROFILE_MARK);
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Run a best-effort memory read. A failure is recorded for /everme and
392
+ * swallowed: the user's turn proceeds either way.
393
+ */
394
+ async function attempt(state, operation) {
395
+ try {
396
+ return await operation();
397
+ } catch (error) {
398
+ state.lastError = describeFailure(error);
399
+ return "";
400
+ }
401
+ }
402
+
403
+ async function requireCredentials(credentialsOnce) {
404
+ const credentials = await credentialsOnce();
405
+ if (!credentials.configured) throw new Error(notConfiguredMessage(credentials));
406
+ return credentials;
407
+ }
408
+
409
+ function textResult(text) {
410
+ return { content: [{ type: "text", text }], details: undefined };
411
+ }
412
+
413
+ function renderSearch(result) {
414
+ const memories = result?.memories || [];
415
+ const profiles = result?.profiles || [];
416
+ const cases = result?.agentMemory?.cases || [];
417
+ const skills = result?.agentMemory?.skills || [];
418
+ const raw = result?.rawMessages || [];
419
+ if (!memories.length && !profiles.length && !cases.length && !skills.length && !raw.length) {
420
+ return "(no matching memories)";
421
+ }
422
+ const lines = [];
423
+ const section = (title, rows, render) => {
424
+ if (!rows.length) return;
425
+ lines.push(`## ${title}`);
426
+ for (const row of rows) lines.push(`- ${render(row)}`);
427
+ };
428
+ section("Episodes", memories, (m) => m?.summary || m?.content || JSON.stringify(m));
429
+ section("Profile", profiles, (p) => p?.description || p?.evidence || JSON.stringify(p));
430
+ section("Cases", cases, (c) => c?.title || c?.summary || JSON.stringify(c));
431
+ section("Skills", skills, (s) => s?.title || s?.summary || JSON.stringify(s));
432
+ // Provisional: not extracted yet, so it is labelled rather than presented
433
+ // as established fact.
434
+ section("Recent unextracted transcript", raw, (r) => `${r?.role || "?"}: ${r?.content || ""}`);
435
+ return lines.join("\n");
436
+ }
437
+
438
+ function memoize(factory) {
439
+ let promise;
440
+ return () => {
441
+ if (!promise) promise = factory();
442
+ return promise;
443
+ };
444
+ }
package/src/memory.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * EverMe operations for the Pi extension, over @everme/agent-sdk.
3
+ *
4
+ * Why the SDK's runHook() is NOT used here, even though every other native
5
+ * plugin does: runHook is built for a hook PROCESS. It arms a watchdog that
6
+ * calls process.exit(0) when the host's kill deadline nears, which is correct
7
+ * for a short-lived hook and fatal here — this code runs inside the long-lived
8
+ * pi TUI, and exiting would take the user's session down mid-turn. So the
9
+ * lower-level SDK primitives are used directly and this module owns its own
10
+ * deadlines. Everything the SDK does own is still reused: the wire protocol,
11
+ * rune caps, redaction, the recall query sanitizer, and the block renderers.
12
+ */
13
+
14
+ import {
15
+ createClient,
16
+ getContext,
17
+ redactError,
18
+ renderProfileBlock,
19
+ resolveConfig,
20
+ runInject,
21
+ saveAgentMemory,
22
+ savePersonalMemory,
23
+ searchMemory,
24
+ } from "@everme/agent-sdk";
25
+
26
+ // Recall and profile are best-effort decorations on the user's turn, so they
27
+ // get their own short deadlines rather than the SDK's 30s default. A memory
28
+ // lookup must never be the reason a prompt feels stuck — and pi's own
29
+ // provider layer can already hang (an openai-codex OAuth refresh was observed
30
+ // hanging a whole run), so this layer refuses to add to that.
31
+ export const RECALL_TIMEOUT_MS = 6000;
32
+ export const PROFILE_TIMEOUT_MS = 8000;
33
+ // Saves are worth waiting longer for: losing the turn is worse than a pause
34
+ // after the agent already finished answering.
35
+ export const SAVE_TIMEOUT_MS = 20000;
36
+
37
+ const noopLog = { info() {}, warn() {} };
38
+
39
+ export function buildConfig(credentials) {
40
+ return resolveConfig({
41
+ ...(credentials.apiBase ? { apiBase: credentials.apiBase } : {}),
42
+ agentId: credentials.agentId,
43
+ agentToken: credentials.agentToken,
44
+ });
45
+ }
46
+
47
+ export function buildClient(credentials, log = noopLog) {
48
+ const config = buildConfig(credentials);
49
+ return { client: createClient(config, log), config };
50
+ }
51
+
52
+ /**
53
+ * Race an operation against a deadline. The SDK client has its own timeout,
54
+ * but it is per-request and longer than a turn should ever wait; this bounds
55
+ * the whole operation including a retry inside the client.
56
+ */
57
+ export async function withDeadline(promise, ms, label) {
58
+ let timer;
59
+ try {
60
+ return await Promise.race([
61
+ promise,
62
+ new Promise((_, reject) => {
63
+ timer = setTimeout(() => {
64
+ // Tagged, not just worded: callers have to tell a deadline apart
65
+ // from a real failure, because the two mean opposite things for a
66
+ // write. A timeout says "we stopped waiting" - the gateway adds
67
+ // the messages before it flushes and does not detach the request
68
+ // context, so the turn is already stored. A refused or rejected
69
+ // request says the turn never landed. Matching on the message text
70
+ // would break the moment the wording changed.
71
+ const err = new Error(`${label} exceeded ${ms}ms`);
72
+ err.type = "timeout";
73
+ reject(err);
74
+ }, ms);
75
+ timer.unref?.();
76
+ }),
77
+ ]);
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The durable profile block, or "" when there is nothing to inject.
85
+ */
86
+ export async function fetchProfileBlock(credentials, log = noopLog) {
87
+ const { client } = buildClient(credentials, log);
88
+ const { context } = await withDeadline(getContext(client, "", {}, log), PROFILE_TIMEOUT_MS, "mem/context");
89
+ // getContext returns the server-rendered markdown when it has one; the
90
+ // profile object path is what renderProfileBlock exists for. Either way the
91
+ // block is wrapped so the model sees the same tag as on every other host.
92
+ if (typeof context === "string" && context.trim()) {
93
+ return context.trim().startsWith("<everme_profile>")
94
+ ? context.trim()
95
+ : `<everme_profile>\n${context.trim()}\n</everme_profile>`;
96
+ }
97
+ return "";
98
+ }
99
+
100
+ /**
101
+ * The recall block for one prompt, or "" when nothing is relevant. Reuses the
102
+ * SDK's runInject so query sanitization, topK, the min-score filter and the
103
+ * block layout are identical to the hook-based hosts.
104
+ */
105
+ export async function fetchRecallBlock(credentials, prompt, log = noopLog) {
106
+ const { client, config } = buildClient(credentials, log);
107
+ const { block } = await withDeadline(
108
+ runInject({ input: { prompt }, client, config, search: searchMemory, log }),
109
+ RECALL_TIMEOUT_MS,
110
+ "mem/search",
111
+ );
112
+ return block || "";
113
+ }
114
+
115
+ /**
116
+ * Upload a turn. `messages` are pi AgentMessages, which the SDK's converter
117
+ * already understands: role user/assistant/toolResult, content as a string or
118
+ * as text/toolCall blocks, toolCallId on results. Thinking blocks are dropped
119
+ * by the converter, which is what we want — reasoning is not memory.
120
+ */
121
+ export async function saveTurn(
122
+ credentials,
123
+ { conversationId, messages, flush = false, timeoutMs = SAVE_TIMEOUT_MS },
124
+ log = noopLog,
125
+ ) {
126
+ const { client } = buildClient(credentials, log);
127
+ return withDeadline(
128
+ saveAgentMemory(client, { conversationId, messages, flush }, log),
129
+ timeoutMs,
130
+ "mem/agent-memory",
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Write a durable fact to the profile. conversationId is REQUIRED: the SDK
136
+ * returns null without one, which would look like a silent success at the
137
+ * call site.
138
+ */
139
+ export async function saveFact(credentials, { fact, conversationId }, log = noopLog) {
140
+ if (!conversationId) throw new Error("saveFact requires a conversationId");
141
+ const { client } = buildClient(credentials, log);
142
+ return withDeadline(
143
+ savePersonalMemory(client, { conversationId, messages: [{ role: "user", content: fact }], flush: true }, log),
144
+ SAVE_TIMEOUT_MS,
145
+ "mem/personal",
146
+ );
147
+ }
148
+
149
+ export async function search(credentials, { query, topK }, log = noopLog) {
150
+ const { client, config } = buildClient(credentials, log);
151
+ return withDeadline(
152
+ searchMemory(client, { query, topK: topK || config.topK }, log),
153
+ RECALL_TIMEOUT_MS,
154
+ "mem/search",
155
+ );
156
+ }
157
+
158
+ export async function profile(credentials, { forceRefresh = false } = {}, log = noopLog) {
159
+ const { client } = buildClient(credentials, log);
160
+ return withDeadline(getContext(client, "", { forceRefresh }, log), PROFILE_TIMEOUT_MS, "mem/context");
161
+ }
162
+
163
+ /**
164
+ * A one-line, credential-free description of a failure, for the /everme
165
+ * command and for tool errors.
166
+ */
167
+ export function describeFailure(error) {
168
+ return redactError(error).replace(/\s+/g, " ").trim();
169
+ }
170
+
171
+ export { renderProfileBlock };