pi-cmem 0.1.0
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/LICENSE +19 -0
- package/README.md +86 -0
- package/cmem.js +394 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ezoushen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# pi-cmem
|
|
2
|
+
|
|
3
|
+
Connect Pi to a shared claude-mem worker through its HTTP API. The package registers
|
|
4
|
+
one `memory_recall` tool, optionally captures Pi observations, and can inject a memory
|
|
5
|
+
digest before each turn.
|
|
6
|
+
|
|
7
|
+
claude-mem is licensed under Apache-2.0. `pi-cmem` is an independent MIT-licensed HTTP
|
|
8
|
+
client; it does not import claude-mem code.
|
|
9
|
+
|
|
10
|
+
`@husniadil/pi-mem` is the fuller alternative: it can spawn the worker and offers a
|
|
11
|
+
three-tool recall workflow. This package deliberately stays smaller. In particular,
|
|
12
|
+
it does not prefix project names with `pi-`, so Pi, Claude Code, and Codex share one
|
|
13
|
+
repository namespace while `platformSource` still distinguishes the producing agent.
|
|
14
|
+
|
|
15
|
+
## External contract
|
|
16
|
+
|
|
17
|
+
A claude-mem worker must expose its health, search, context-injection, session, and
|
|
18
|
+
observation HTTP endpoints at the configured host and port. The default is the
|
|
19
|
+
worker's local endpoint at `127.0.0.1:37777`.
|
|
20
|
+
|
|
21
|
+
The optional fallback is a Python helper script that can search the same Chroma data
|
|
22
|
+
when the worker is unavailable. Capture and context injection always require the
|
|
23
|
+
worker; the fallback supports recall only.
|
|
24
|
+
|
|
25
|
+
## If the contract is unmet
|
|
26
|
+
|
|
27
|
+
At session start, an unreachable worker produces one warning without preventing Pi
|
|
28
|
+
from loading. Capture and context injection are disabled for that outage. Recall uses
|
|
29
|
+
the configured fallback helper when it exists; otherwise `memory_recall` reports that
|
|
30
|
+
recall is unavailable. A reachable worker returning no matches produces “No matching
|
|
31
|
+
memories found.”
|
|
32
|
+
|
|
33
|
+
In pi's print (`-p`) and json modes there is no UI to notify, so warnings that would
|
|
34
|
+
otherwise be silent there are written to stderr instead, once per distinct reason per
|
|
35
|
+
process.
|
|
36
|
+
|
|
37
|
+
`/memory-status` reports worker reachability, project scope, session identity, and the
|
|
38
|
+
capture/injection state.
|
|
39
|
+
|
|
40
|
+
## Settings
|
|
41
|
+
|
|
42
|
+
Settings resolve from `pi-cmem.json` in the global Pi config directory, then from a
|
|
43
|
+
trusted project's Pi config directory, then from the environment. Later sources win.
|
|
44
|
+
Project settings are ignored when the project is untrusted.
|
|
45
|
+
|
|
46
|
+
`workerHost` and `workerPort` add one more layer below those: when neither an explicit
|
|
47
|
+
setting nor the environment names them, they are discovered from claude-mem's own
|
|
48
|
+
published settings (`~/.claude-mem/settings.json`, or
|
|
49
|
+
`$CLAUDE_MEM_DATA_DIR/settings.json`), which is where the worker it started actually
|
|
50
|
+
listens. claude-mem writes the port as a JSON string; discovery accepts both a string
|
|
51
|
+
and a number. Full precedence for these two keys: documented default -> claude-mem's
|
|
52
|
+
published settings -> global `pi-cmem.json` -> trusted project `pi-cmem.json` ->
|
|
53
|
+
environment. An unreachable-worker warning names which of `host`/`port` came from
|
|
54
|
+
discovery.
|
|
55
|
+
|
|
56
|
+
| JSON key | Default | Environment override | Meaning |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `disabled` | `false` | `PI_CMEM_DISABLED=1` | Disable bridge activity. |
|
|
59
|
+
| `capture` | `true` | `PI_CMEM_CAPTURE=0` | Write prompts, tool observations, and summaries through the worker. |
|
|
60
|
+
| `inject` | `false` | `PI_CMEM_INJECT=1` | Inject a worker-produced context digest before each turn. |
|
|
61
|
+
| `workerHost` | `"127.0.0.1"` | `PI_CMEM_WORKER_HOST` | claude-mem worker host; discovered from claude-mem's own settings when not set explicitly. |
|
|
62
|
+
| `workerPort` | `37777` | `PI_CMEM_WORKER_PORT` | claude-mem worker port; discovered from claude-mem's own settings when not set explicitly. |
|
|
63
|
+
| `project` | `""` | `PI_CMEM_PROJECT` | Project override; empty uses the current working-directory basename. |
|
|
64
|
+
| `fallbackPath` | `""` | `PI_CMEM_FALLBACK_PATH` | Optional Chroma search helper; empty means no fallback. |
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"capture": true,
|
|
71
|
+
"inject": false,
|
|
72
|
+
"project": "shared-repository"
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Install and verify
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
pi install npm:pi-cmem
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Start a session and run `/memory-status`. With a reachable worker, call
|
|
83
|
+
`memory_recall` with a known query and confirm the result belongs to the current bare
|
|
84
|
+
repository namespace. With the worker stopped, confirm that startup warns once and
|
|
85
|
+
that recall either uses the configured helper or reports the missing fallback. This
|
|
86
|
+
check exercises the external contract without depending on any particular deployment.
|
package/cmem.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// extensions/cmem/cmem.ts
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, join as join2 } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
// shared/settings.ts
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
// shared/announce.ts
|
|
15
|
+
var stderrAnnounced = /* @__PURE__ */ new Set();
|
|
16
|
+
function announce(ctx, message, level, reason = message) {
|
|
17
|
+
if (ctx?.hasUI === false) {
|
|
18
|
+
if (stderrAnnounced.has(reason)) return;
|
|
19
|
+
stderrAnnounced.add(reason);
|
|
20
|
+
try {
|
|
21
|
+
process.stderr.write(`${message}
|
|
22
|
+
`);
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
ctx?.ui?.notify?.(message, level);
|
|
29
|
+
} catch {
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// shared/settings.ts
|
|
34
|
+
var announcedConfigErrors = /* @__PURE__ */ new Set();
|
|
35
|
+
function readConfig(path, ctx) {
|
|
36
|
+
if (!existsSync(path)) return {};
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!announcedConfigErrors.has(path)) {
|
|
41
|
+
announcedConfigErrors.add(path);
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
announce(ctx, `settings: could not parse ${path} (${message}); using defaults.`, "warning", `settings-parse:${path}`);
|
|
44
|
+
}
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function resolveSettings(name, definitions, context, runtime = {}) {
|
|
49
|
+
const environment = runtime.environment ?? process.env;
|
|
50
|
+
const globalPath = join(runtime.agentDir ?? getAgentDir(), `${name}.json`);
|
|
51
|
+
const projectPath = join(context.cwd, CONFIG_DIR_NAME, `${name}.json`);
|
|
52
|
+
const globalConfig = readConfig(globalPath, context);
|
|
53
|
+
const projectConfig = context.isProjectTrusted() ? readConfig(projectPath, context) : {};
|
|
54
|
+
const resolved = {};
|
|
55
|
+
for (const key of Object.keys(definitions)) {
|
|
56
|
+
const definition = definitions[key];
|
|
57
|
+
let value = definition.default;
|
|
58
|
+
let provenance = { source: "default" };
|
|
59
|
+
if (definition.discover) {
|
|
60
|
+
try {
|
|
61
|
+
const discovered = definition.discover();
|
|
62
|
+
if (discovered !== void 0) {
|
|
63
|
+
value = discovered.value;
|
|
64
|
+
provenance = { source: "discovered", name: definition.discoverName ?? "discovery" };
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (Object.hasOwn(globalConfig, key)) {
|
|
70
|
+
value = globalConfig[key];
|
|
71
|
+
provenance = { source: "global", path: globalPath };
|
|
72
|
+
}
|
|
73
|
+
if (Object.hasOwn(projectConfig, key)) {
|
|
74
|
+
value = projectConfig[key];
|
|
75
|
+
provenance = { source: "project", path: projectPath };
|
|
76
|
+
}
|
|
77
|
+
const environmentValue = environment[definition.env];
|
|
78
|
+
if (environmentValue !== void 0) {
|
|
79
|
+
value = definition.parseEnv ? definition.parseEnv(environmentValue) : environmentValue;
|
|
80
|
+
provenance = { source: "environment", name: definition.env };
|
|
81
|
+
}
|
|
82
|
+
resolved[key] = { value, provenance };
|
|
83
|
+
}
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// extensions/cmem/cmem.ts
|
|
88
|
+
var execFileAsync = promisify(execFile);
|
|
89
|
+
var PLATFORM_SOURCE = "pi-agent";
|
|
90
|
+
var DEFAULT_WORKER_PORT = 37777;
|
|
91
|
+
var WORKER_TIMEOUT_MS = 8e3;
|
|
92
|
+
var CHROMA_TIMEOUT_MS = 3e4;
|
|
93
|
+
var MAX_OBSERVATION_CHARS = 1e3;
|
|
94
|
+
var MAX_SEARCH_LIMIT = 100;
|
|
95
|
+
var SESSION_COMPLETE_DELAY_MS = 3e3;
|
|
96
|
+
var CLAUDE_MEM_SETTINGS_DESCRIPTION = "claude-mem's own settings (CLAUDE_MEM_WORKER_";
|
|
97
|
+
function readClaudeMemSetting(key) {
|
|
98
|
+
const dir = process.env.CLAUDE_MEM_DATA_DIR || join2(homedir(), ".claude-mem");
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(readFileSync2(join2(dir, "settings.json"), "utf8"));
|
|
101
|
+
return raw[key];
|
|
102
|
+
} catch {
|
|
103
|
+
return void 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function discoverWorkerHost() {
|
|
107
|
+
const value = readClaudeMemSetting("CLAUDE_MEM_WORKER_HOST");
|
|
108
|
+
return typeof value === "string" && value ? { value } : void 0;
|
|
109
|
+
}
|
|
110
|
+
function discoverWorkerPort() {
|
|
111
|
+
const value = readClaudeMemSetting("CLAUDE_MEM_WORKER_PORT");
|
|
112
|
+
if (typeof value === "number" && Number.isFinite(value)) return { value };
|
|
113
|
+
if (typeof value === "string") {
|
|
114
|
+
const parsed = Number.parseInt(value, 10);
|
|
115
|
+
if (Number.isFinite(parsed)) return { value: parsed };
|
|
116
|
+
}
|
|
117
|
+
return void 0;
|
|
118
|
+
}
|
|
119
|
+
var SETTING_DEFINITIONS = {
|
|
120
|
+
disabled: { default: false, env: "PI_CMEM_DISABLED", parseEnv: (value) => value === "1" },
|
|
121
|
+
capture: { default: true, env: "PI_CMEM_CAPTURE", parseEnv: (value) => value !== "0" },
|
|
122
|
+
inject: { default: false, env: "PI_CMEM_INJECT", parseEnv: (value) => value === "1" },
|
|
123
|
+
workerHost: {
|
|
124
|
+
default: "127.0.0.1",
|
|
125
|
+
env: "PI_CMEM_WORKER_HOST",
|
|
126
|
+
discover: discoverWorkerHost,
|
|
127
|
+
discoverName: `${CLAUDE_MEM_SETTINGS_DESCRIPTION}HOST)`
|
|
128
|
+
},
|
|
129
|
+
workerPort: {
|
|
130
|
+
default: DEFAULT_WORKER_PORT,
|
|
131
|
+
env: "PI_CMEM_WORKER_PORT",
|
|
132
|
+
parseEnv: Number,
|
|
133
|
+
discover: discoverWorkerPort,
|
|
134
|
+
discoverName: `${CLAUDE_MEM_SETTINGS_DESCRIPTION}PORT)`
|
|
135
|
+
},
|
|
136
|
+
project: { default: "", env: "PI_CMEM_PROJECT" },
|
|
137
|
+
fallbackPath: { default: "", env: "PI_CMEM_FALLBACK_PATH" }
|
|
138
|
+
};
|
|
139
|
+
var disabled = false;
|
|
140
|
+
var captureEnabled = true;
|
|
141
|
+
var injectEnabled = false;
|
|
142
|
+
var host = "127.0.0.1";
|
|
143
|
+
var port = DEFAULT_WORKER_PORT;
|
|
144
|
+
var configuredProject = "";
|
|
145
|
+
var fallbackChromaScript = "";
|
|
146
|
+
function baseUrl() {
|
|
147
|
+
return `http://${host}:${port}`;
|
|
148
|
+
}
|
|
149
|
+
function projectName(cwd) {
|
|
150
|
+
if (configuredProject) return configuredProject;
|
|
151
|
+
return basename(cwd) || "unknown";
|
|
152
|
+
}
|
|
153
|
+
function withTimeout(ms) {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
156
|
+
return { signal: controller.signal, clear: () => clearTimeout(timer) };
|
|
157
|
+
}
|
|
158
|
+
async function workerGetText(path, signal) {
|
|
159
|
+
const timeout = withTimeout(WORKER_TIMEOUT_MS);
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(`${baseUrl()}${path}`, { signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal });
|
|
162
|
+
if (!res.ok) return null;
|
|
163
|
+
return await res.text();
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
} finally {
|
|
167
|
+
timeout.clear();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function workerPost(path, body) {
|
|
171
|
+
const timeout = withTimeout(WORKER_TIMEOUT_MS);
|
|
172
|
+
try {
|
|
173
|
+
const res = await fetch(`${baseUrl()}${path}`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "Content-Type": "application/json" },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
signal: timeout.signal
|
|
178
|
+
});
|
|
179
|
+
if (!res.ok) return null;
|
|
180
|
+
return await res.json();
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
} finally {
|
|
184
|
+
timeout.clear();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function workerPostFireAndForget(path, body) {
|
|
188
|
+
fetch(`${baseUrl()}${path}`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify(body)
|
|
192
|
+
}).catch(() => {
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function workerAlive() {
|
|
196
|
+
return workerGetText("/api/health").then((v) => v !== null);
|
|
197
|
+
}
|
|
198
|
+
async function searchViaWorker(query, limit, project, signal) {
|
|
199
|
+
const params = new URLSearchParams({ query, limit: String(limit), project });
|
|
200
|
+
const text = await workerGetText(`/api/search?${params.toString()}`, signal);
|
|
201
|
+
return text && text.trim() ? text : null;
|
|
202
|
+
}
|
|
203
|
+
async function searchViaChromaScript(query, limit, signal) {
|
|
204
|
+
if (!fallbackChromaScript || !existsSync2(fallbackChromaScript)) {
|
|
205
|
+
throw new Error(`memory recall unavailable: worker unreachable at ${baseUrl()} and no Chroma fallback is configured`);
|
|
206
|
+
}
|
|
207
|
+
const { stdout } = await execFileAsync(
|
|
208
|
+
"python3",
|
|
209
|
+
[fallbackChromaScript, query, "--limit", String(limit)],
|
|
210
|
+
{
|
|
211
|
+
timeout: CHROMA_TIMEOUT_MS,
|
|
212
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
213
|
+
signal,
|
|
214
|
+
// Run from $HOME, not the session cwd: a stray module in the working directory
|
|
215
|
+
// (e.g. an enum.py left in /tmp) shadows Python's stdlib and crashes chromadb's import.
|
|
216
|
+
cwd: homedir()
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
return stdout.trim() || "No matching memories found.";
|
|
220
|
+
}
|
|
221
|
+
var contentSessionId = null;
|
|
222
|
+
var sessionProject = "unknown";
|
|
223
|
+
var sessionCwd = process.cwd();
|
|
224
|
+
var workerHealthy = true;
|
|
225
|
+
function observationPayload(toolName, input, responseText) {
|
|
226
|
+
return {
|
|
227
|
+
contentSessionId,
|
|
228
|
+
tool_name: toolName,
|
|
229
|
+
tool_input: input ?? {},
|
|
230
|
+
tool_response: responseText.length > MAX_OBSERVATION_CHARS ? `${responseText.slice(0, MAX_OBSERVATION_CHARS - 12)} [truncated]` : responseText,
|
|
231
|
+
cwd: sessionCwd,
|
|
232
|
+
platformSource: PLATFORM_SOURCE
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function textOf(content) {
|
|
236
|
+
if (typeof content === "string") return content;
|
|
237
|
+
if (!Array.isArray(content)) return "";
|
|
238
|
+
return content.filter((b) => !!b && typeof b === "object" && b.type === "text").map((b) => b.text ?? "").join("\n");
|
|
239
|
+
}
|
|
240
|
+
function lastAssistantText(messages) {
|
|
241
|
+
if (!Array.isArray(messages)) return "";
|
|
242
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
243
|
+
const msg = messages[i];
|
|
244
|
+
if (msg?.role === "assistant") return textOf(msg.content);
|
|
245
|
+
}
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
function piCmemExtension(pi) {
|
|
249
|
+
let preflightAnnounced = false;
|
|
250
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
251
|
+
const resolved = resolveSettings("pi-cmem", SETTING_DEFINITIONS, ctx);
|
|
252
|
+
disabled = resolved.disabled.value;
|
|
253
|
+
captureEnabled = !disabled && resolved.capture.value;
|
|
254
|
+
injectEnabled = !disabled && resolved.inject.value;
|
|
255
|
+
host = resolved.workerHost.value;
|
|
256
|
+
port = resolved.workerPort.value;
|
|
257
|
+
configuredProject = resolved.project.value;
|
|
258
|
+
fallbackChromaScript = resolved.fallbackPath.value;
|
|
259
|
+
preflightAnnounced = false;
|
|
260
|
+
if (disabled) return;
|
|
261
|
+
sessionCwd = ctx.cwd;
|
|
262
|
+
sessionProject = projectName(ctx.cwd);
|
|
263
|
+
const piSession = typeof ctx.sessionManager?.getSessionId === "function" ? ctx.sessionManager.getSessionId() : null;
|
|
264
|
+
contentSessionId = piSession || `pi-${sessionProject}-${Date.now()}`;
|
|
265
|
+
workerHealthy = await workerAlive();
|
|
266
|
+
if (!workerHealthy && !preflightAnnounced) {
|
|
267
|
+
const recallCost = fallbackChromaScript && existsSync2(fallbackChromaScript) ? "recall uses the slower Chroma fallback" : "recall has no fallback";
|
|
268
|
+
const discoveredKeys = [
|
|
269
|
+
resolved.workerHost.provenance.source === "discovered" && "host",
|
|
270
|
+
resolved.workerPort.provenance.source === "discovered" && "port"
|
|
271
|
+
].filter((key) => typeof key === "string");
|
|
272
|
+
const discoveryNote = discoveredKeys.length ? ` (${discoveredKeys.join(" and ")} discovered from claude-mem's own settings)` : "";
|
|
273
|
+
announce(
|
|
274
|
+
ctx,
|
|
275
|
+
`pi-cmem: claude-mem worker unreachable at ${baseUrl()}${discoveryNote}; capture and context injection are unavailable, and ${recallCost}`,
|
|
276
|
+
"warning",
|
|
277
|
+
"pi-cmem:worker-unreachable"
|
|
278
|
+
);
|
|
279
|
+
preflightAnnounced = true;
|
|
280
|
+
}
|
|
281
|
+
pi.appendEntry("pi-cmem-session", { contentSessionId, project: sessionProject, worker: baseUrl() });
|
|
282
|
+
});
|
|
283
|
+
pi.on("before_agent_start", async (event) => {
|
|
284
|
+
if (!captureEnabled || !contentSessionId) return;
|
|
285
|
+
const res = await workerPost("/api/sessions/init", {
|
|
286
|
+
contentSessionId,
|
|
287
|
+
project: sessionProject,
|
|
288
|
+
prompt: event.prompt || "pi session",
|
|
289
|
+
platformSource: PLATFORM_SOURCE
|
|
290
|
+
});
|
|
291
|
+
workerHealthy = res !== null;
|
|
292
|
+
});
|
|
293
|
+
pi.on("context", async (event) => {
|
|
294
|
+
if (!injectEnabled || !contentSessionId || !workerHealthy) return;
|
|
295
|
+
const digest = await workerGetText(`/api/context/inject?projects=${encodeURIComponent(sessionProject)}`);
|
|
296
|
+
if (!digest || !digest.trim()) return;
|
|
297
|
+
return {
|
|
298
|
+
messages: [
|
|
299
|
+
...event.messages,
|
|
300
|
+
{
|
|
301
|
+
role: "user",
|
|
302
|
+
content: [{ type: "text", text: `<pi-cmem-context>
|
|
303
|
+
${digest}
|
|
304
|
+
</pi-cmem-context>` }]
|
|
305
|
+
}
|
|
306
|
+
]
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
pi.on("tool_result", (event) => {
|
|
310
|
+
if (!captureEnabled || !contentSessionId || !workerHealthy) return;
|
|
311
|
+
const toolName = event.toolName;
|
|
312
|
+
if (!toolName || toolName === "memory_recall") return;
|
|
313
|
+
workerPostFireAndForget("/api/sessions/observations", observationPayload(toolName, event.input, textOf(event.content)));
|
|
314
|
+
});
|
|
315
|
+
pi.on("agent_end", async (event) => {
|
|
316
|
+
if (!captureEnabled || !contentSessionId || !workerHealthy) return;
|
|
317
|
+
await workerPost("/api/sessions/summarize", {
|
|
318
|
+
contentSessionId,
|
|
319
|
+
last_assistant_message: lastAssistantText(event.messages),
|
|
320
|
+
platformSource: PLATFORM_SOURCE
|
|
321
|
+
});
|
|
322
|
+
const sid = contentSessionId;
|
|
323
|
+
setTimeout(() => {
|
|
324
|
+
workerPostFireAndForget("/api/sessions/complete", { contentSessionId: sid, platformSource: PLATFORM_SOURCE });
|
|
325
|
+
}, SESSION_COMPLETE_DELAY_MS);
|
|
326
|
+
});
|
|
327
|
+
pi.on("session_compact", () => {
|
|
328
|
+
});
|
|
329
|
+
pi.on("session_shutdown", () => {
|
|
330
|
+
contentSessionId = null;
|
|
331
|
+
});
|
|
332
|
+
pi.registerTool({
|
|
333
|
+
name: "memory_recall",
|
|
334
|
+
label: "Memory Recall",
|
|
335
|
+
description: "Search cross-session persistent memory (claude-mem: shared across Claude Code, Codex and Pi). Use when the user asks about previous work, or when you need context on how something was done before.",
|
|
336
|
+
promptSnippet: "Search cross-session persistent memory of past work",
|
|
337
|
+
promptGuidelines: [
|
|
338
|
+
"Use memory_recall before asking the user to re-explain earlier work or past decisions.",
|
|
339
|
+
"Batch related questions into one query, then fetch only the relevant results."
|
|
340
|
+
],
|
|
341
|
+
parameters: Type.Object({
|
|
342
|
+
query: Type.String({ description: "Natural language search query" }),
|
|
343
|
+
limit: Type.Optional(
|
|
344
|
+
Type.Number({ description: "Max results to return (default 5, max 100)" })
|
|
345
|
+
),
|
|
346
|
+
project: Type.Optional(
|
|
347
|
+
Type.String({ description: "Restrict to one project name (default: current repo, then all projects)" })
|
|
348
|
+
)
|
|
349
|
+
}),
|
|
350
|
+
async execute(_toolCallId, params, signal) {
|
|
351
|
+
const limit = Math.min(Math.max(1, Math.floor(params.limit ?? 5)), MAX_SEARCH_LIMIT);
|
|
352
|
+
const query = String(params.query ?? "").trim();
|
|
353
|
+
if (!query) throw new Error("memory_recall: query must not be empty");
|
|
354
|
+
const scoped = params.project || sessionProject;
|
|
355
|
+
const scoped_ = await searchViaWorker(query, limit, scoped, signal);
|
|
356
|
+
if (scoped_) return { content: [{ type: "text", text: scoped_ }], details: {} };
|
|
357
|
+
const unscoped = await workerGetText(
|
|
358
|
+
`/api/search?query=${encodeURIComponent(query)}&limit=${limit}`,
|
|
359
|
+
signal
|
|
360
|
+
);
|
|
361
|
+
if (unscoped && unscoped.trim()) return { content: [{ type: "text", text: unscoped }], details: {} };
|
|
362
|
+
if (await workerAlive()) {
|
|
363
|
+
return { content: [{ type: "text", text: "No matching memories found." }], details: {} };
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: await searchViaChromaScript(query, limit, signal) }],
|
|
367
|
+
details: { source: "chroma-fallback" }
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
pi.registerCommand("memory-status", {
|
|
372
|
+
description: "Show claude-mem bridge status (worker, project, capture/inject state)",
|
|
373
|
+
handler: async (_args, ctx) => {
|
|
374
|
+
const health = await workerGetText("/api/health");
|
|
375
|
+
if (!health) {
|
|
376
|
+
announce(ctx, `pi-cmem: claude-mem worker unreachable at ${baseUrl()} (recall falls back to Chroma)`, "warning");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
let version = "?";
|
|
380
|
+
try {
|
|
381
|
+
version = String(JSON.parse(health).version ?? "?");
|
|
382
|
+
} catch {
|
|
383
|
+
}
|
|
384
|
+
announce(
|
|
385
|
+
ctx,
|
|
386
|
+
`pi-cmem: worker v${version} @ ${baseUrl()} | project: ${sessionProject} | session: ${contentSessionId ?? "none"} | capture: ${captureEnabled ? "on" : "off"} | inject: ${injectEnabled ? "on" : "off"}`,
|
|
387
|
+
"info"
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
export {
|
|
393
|
+
piCmemExtension as default
|
|
394
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-cmem",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bridge Pi sessions to a shared claude-mem worker.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./cmem.js",
|
|
7
|
+
"exports": "./cmem.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"cmem.js",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ezoushen/pi-extensions.git",
|
|
20
|
+
"directory": "extensions/cmem"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/ezoushen/pi-extensions/tree/main/extensions/cmem#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/ezoushen/pi-extensions/issues"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
28
|
+
"typebox": "*"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./cmem.js"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|