@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 +94 -0
- package/package.json +56 -0
- package/skills/everme/SKILL.md +81 -0
- package/src/credentials.js +86 -0
- package/src/extension.js +444 -0
- package/src/memory.js +171 -0
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
|
+
}
|
package/src/extension.js
ADDED
|
@@ -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 };
|