Skip to content

Commit 6fc70fb

Browse files
fix(pi): clarify native Engram provider failures
1 parent 44faeee commit 6fc70fb

4 files changed

Lines changed: 199 additions & 12 deletions

File tree

plugin/pi/index.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const ENGRAM_TOOL_NAMES = new Set<string>(ENGRAM_TOOLS);
4848
const MEMORY_INSTRUCTIONS = `## Engram Persistent Memory — Protocol
4949
5050
You have access to Engram, a persistent memory system that survives across sessions and compactions.
51+
These instructions are injected by gentle-engram, the Pi-native memory provider. Use the memory tools named in this section as the authoritative Pi memory contract. Do not infer alternative Engram tool names from other integrations unless the user explicitly asks you to use them.
5152
5253
### WHEN TO SAVE (mandatory — not optional)
5354
@@ -149,23 +150,32 @@ interface ToolEndEvent {
149150
}
150151

151152
class EngramHttpError extends Error {
152-
constructor(message: string, readonly status: number, readonly data: unknown) {
153+
readonly status: number;
154+
readonly data: unknown;
155+
156+
constructor(message: string, status: number, data: unknown) {
153157
super(message);
154158
this.name = "EngramHttpError";
159+
this.status = status;
160+
this.data = data;
155161
}
156162
}
157163

158164
async function engramFetch<TResponse = unknown>(path: string, opts: FetchOptions = {}): Promise<TResponse | null> {
159-
let res: Response;
160-
try {
161-
res = await fetch(`${ENGRAM_URL}${redactUrlPath(path)}`, {
162-
method: opts.method ?? "GET",
163-
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
164-
body: opts.body ? JSON.stringify(redactValue(opts.body)) : undefined,
165-
});
166-
} catch {
167-
return null;
165+
let res: Response | undefined;
166+
for (let attempt = 0; attempt < 3; attempt += 1) {
167+
try {
168+
res = await fetch(`${ENGRAM_URL}${redactUrlPath(path)}`, {
169+
method: opts.method ?? "GET",
170+
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
171+
body: opts.body ? JSON.stringify(redactValue(opts.body)) : undefined,
172+
});
173+
break;
174+
} catch {
175+
if (attempt < 2) await wait(150);
176+
}
168177
}
178+
if (!res) return null;
169179

170180
let data: unknown = null;
171181
try {
@@ -698,7 +708,9 @@ async function executeMemoryTool(toolName: string, params: Record<string, unknow
698708

699709
try {
700710
const data = await callMemoryTool(toolName, params, ctx);
701-
if (data === null) throw new Error("Engram is unavailable");
711+
if (data === null) {
712+
throw new Error(`gentle-engram could not reach the Engram HTTP server at ${ENGRAM_URL}. The Pi-native mem_* tools are registered, but the native memory provider is not currently responding. Run mem_doctor or restart Engram.`);
713+
}
702714
const result = { content: [{ type: "text" as const, text: textResult(data) }], details: { data } };
703715
if (toolName === "mem_doctor" && data && typeof data === "object" && "status" in data && data.status === "error") {
704716
const errorResult = { ...result, isError: true };

plugin/pi/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gentle-engram",
3-
"version": "0.1.8",
3+
"version": "0.1.9",
44
"description": "Persistent memory for Pi agents — one local-or-cloud brain shared across sessions, compactions, and MCP agents",
55
"type": "module",
66
"license": "MIT",

plugin/pi/test/index-source.test.mjs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,47 @@ import { test } from "node:test";
44

55
const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
66

7+
function extractFunctionBody(name) {
8+
const signatureIndex = source.indexOf(`async function ${name}`);
9+
assert.notEqual(signatureIndex, -1, `${name} signature not found`);
10+
const bodyStart = source.indexOf("{\n let res", signatureIndex);
11+
let depth = 0;
12+
for (let index = bodyStart; index < source.length; index += 1) {
13+
const char = source[index];
14+
if (char === "{") depth += 1;
15+
if (char === "}") depth -= 1;
16+
if (depth === 0) return source.slice(bodyStart + 1, index);
17+
}
18+
throw new Error(`${name} body not found`);
19+
}
20+
21+
function buildEngramFetchForTest() {
22+
const body = extractFunctionBody("engramFetch")
23+
.replace("let res: Response | undefined;", "let res;")
24+
.replace("let data: unknown = null;", "let data = null;")
25+
.replace("return data as TResponse;", "return data;");
26+
const factory = new Function("fetch", "wait", "redactUrlPath", "redactValue", "ENGRAM_URL", `
27+
class EngramHttpError extends Error {
28+
constructor(message, status, data) {
29+
super(message);
30+
this.name = "EngramHttpError";
31+
this.status = status;
32+
this.data = data;
33+
}
34+
}
35+
return async function engramFetch(path, opts = {}) {
36+
${body}
37+
};
38+
`);
39+
return factory(
40+
globalThis.fetch,
41+
() => Promise.resolve(),
42+
(value) => value,
43+
(value) => value,
44+
"http://127.0.0.1:7437",
45+
);
46+
}
47+
748
test("mem_session_summary accepts explicit project fallback", () => {
849
assert.match(source, /mem_session_summary: Type\.Object\(\{[\s\S]*project: optionalString\("Optional project to use when automatic detection is unavailable"\)/);
950
assert.match(source, /case "mem_session_summary":[\s\S]*if \(!requestedProject\) requireResolvedProject\(\);[\s\S]*ensureSession\(activeSessionId, activeProject\)[\s\S]*project: activeProject/);
@@ -28,6 +69,60 @@ test("ambiguous_project error maps to actionable status label, not generic 'erro
2869
assert.doesNotMatch(source, /setStatus\?\.\("engram",\s*`🧠 \$\{project\} · error`\)/);
2970
});
3071

72+
test("memory protocol declares gentle-engram as the Pi-native provider", () => {
73+
assert.match(source, /These instructions are injected by gentle-engram, the Pi-native memory provider/);
74+
assert.match(source, /Use the memory tools named in this section as the authoritative Pi memory contract/);
75+
assert.match(source, /Do not infer alternative Engram tool names from other integrations/);
76+
});
77+
78+
test("native tool fetches retry transient HTTP startup failures", async () => {
79+
const originalFetch = globalThis.fetch;
80+
let calls = 0;
81+
globalThis.fetch = async () => {
82+
calls += 1;
83+
if (calls < 3) throw new Error("connection refused");
84+
return {
85+
ok: true,
86+
async json() {
87+
return { status: "ok" };
88+
},
89+
};
90+
};
91+
try {
92+
const engramFetch = buildEngramFetchForTest();
93+
assert.deepEqual(await engramFetch("/health"), { status: "ok" });
94+
assert.equal(calls, 3);
95+
} finally {
96+
globalThis.fetch = originalFetch;
97+
}
98+
});
99+
100+
test("native tool fetch preserves HTTP error status", async () => {
101+
const originalFetch = globalThis.fetch;
102+
globalThis.fetch = async () => ({
103+
ok: false,
104+
status: 503,
105+
async json() {
106+
return { error: "server warming up" };
107+
},
108+
});
109+
try {
110+
const engramFetch = buildEngramFetchForTest();
111+
await assert.rejects(
112+
() => engramFetch("/search"),
113+
(error) => error.name === "EngramHttpError" && error.status === 503 && error.message === "server warming up",
114+
);
115+
} finally {
116+
globalThis.fetch = originalFetch;
117+
}
118+
});
119+
120+
test("native tool unavailable error names the Pi-native HTTP path", () => {
121+
assert.match(source, /gentle-engram could not reach the Engram HTTP server/);
122+
assert.match(source, /Pi-native mem_\* tools are registered/);
123+
assert.match(source, /Run mem_doctor or restart Engram/);
124+
});
125+
31126
test("mem_review is registered as a Pi-native executable memory tool", () => {
32127
assert.match(source, /const ENGRAM_TOOLS = \[[\s\S]*"mem_review"/);
33128
assert.match(source, /mem_review: Type\.Object\(\{[\s\S]*action: Type\.String\(\{ description: "Action: list \| mark_reviewed" \}\)/);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import assert from "node:assert/strict";
2+
import { mkdir, rm, writeFile } from "node:fs/promises";
3+
import { dirname, join } from "node:path";
4+
import { test } from "node:test";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
6+
7+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8+
const NODE_MODULES = join(ROOT, "node_modules");
9+
10+
async function installRuntimeStubs() {
11+
await mkdir(join(NODE_MODULES, "@earendil-works", "pi-tui"), { recursive: true });
12+
await writeFile(
13+
join(NODE_MODULES, "@earendil-works", "pi-tui", "package.json"),
14+
JSON.stringify({ type: "module", exports: "./index.js" }),
15+
);
16+
await writeFile(
17+
join(NODE_MODULES, "@earendil-works", "pi-tui", "index.js"),
18+
"export class Text { constructor(text) { this.text = text; } }\n",
19+
);
20+
21+
await mkdir(join(NODE_MODULES, "typebox"), { recursive: true });
22+
await writeFile(
23+
join(NODE_MODULES, "typebox", "package.json"),
24+
JSON.stringify({ type: "module", exports: "./index.js" }),
25+
);
26+
await writeFile(
27+
join(NODE_MODULES, "typebox", "index.js"),
28+
`const schema = (kind) => (...args) => ({ kind, args });
29+
export const Type = new Proxy({}, { get: (_target, prop) => schema(String(prop)) });
30+
`,
31+
);
32+
}
33+
34+
test("registered Pi-native mem_search reports native provider transport failure", async () => {
35+
const originalFetch = globalThis.fetch;
36+
const originalUrl = process.env.ENGRAM_URL;
37+
process.env.ENGRAM_URL = "http://127.0.0.1:17437";
38+
globalThis.fetch = async () => {
39+
throw new Error("connection refused");
40+
};
41+
42+
try {
43+
await installRuntimeStubs();
44+
const registeredTools = new Map();
45+
const pluginUrl = pathToFileURL(join(ROOT, "index.ts"));
46+
pluginUrl.search = `?contract=${Date.now()}`;
47+
const { default: registerEngram } = await import(pluginUrl.href);
48+
registerEngram({
49+
registerTool(tool) {
50+
registeredTools.set(tool.name, tool);
51+
},
52+
on() {},
53+
});
54+
55+
const memSearch = registeredTools.get("mem_search");
56+
assert.ok(memSearch, "mem_search tool should be registered");
57+
58+
const result = await memSearch.execute(
59+
"tool-call-1",
60+
{ query: "state markers", project: "gentle-agent-state" },
61+
undefined,
62+
undefined,
63+
{
64+
cwd: ROOT,
65+
sessionManager: { getSessionId: () => "test-session" },
66+
ui: { setStatus() {} },
67+
},
68+
);
69+
70+
assert.equal(result.isError, true);
71+
assert.match(result.content[0].text, /gentle-engram could not reach the Engram HTTP server/);
72+
assert.match(result.content[0].text, /Pi-native mem_\* tools are registered/);
73+
assert.match(result.details.error, /native memory provider is not currently responding/);
74+
} finally {
75+
globalThis.fetch = originalFetch;
76+
if (originalUrl === undefined) delete process.env.ENGRAM_URL;
77+
else process.env.ENGRAM_URL = originalUrl;
78+
await rm(NODE_MODULES, { recursive: true, force: true });
79+
}
80+
});

0 commit comments

Comments
 (0)