Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions plugin/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,25 @@ function takeLastFetchTimeoutMethod(): string | undefined {
return method;
}

// Whether the most recent engramFetch actually got a 2xx back from the server. engramFetch returns
// null both for an unreachable server and for a reachable server that answered with a null JSON body
// (an empty result set); this out-of-band flag lets the tool layer tell those two apart.
let lastFetchReachedServer = false;

function takeLastFetchReachedServer(): boolean {
const reached = lastFetchReachedServer;
lastFetchReachedServer = false;
return reached;
}

async function engramFetch<TResponse = unknown>(path: string, opts: FetchOptions = {}): Promise<TResponse | null> {
const method = opts.method ?? "GET";
// This call's outcome supersedes any earlier one. A tool call can issue several fetches
// (mem_save creates the session, then writes the observation); without this reset a timeout
// on the first leg would mislabel an unrelated failure on the second as "may already have
// been applied", telling the agent not to retry a write that never left the machine.
lastFetchTimeoutMethod = undefined;
lastFetchReachedServer = false;
Comment on lines +193 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg '(^|/)plugin/pi/index\.ts$|plugin/pi/'

echo "Outline:"
ast-grep outline plugin/pi/index.ts --view expanded || true

echo "Relevant lines 180-280:"
sed -n '180,280p' plugin/pi/index.ts

echo "Search for shared state/readers:"
rg -n "lastFetchReachedServer|takeLastFetchReachedServer|lastFetchTimeoutMethod|engramFetch|executeMemoryTool" plugin/pi/index.ts

Repository: Gentleman-Programming/engram

Length of output: 13551


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant executeMemoryTool lines 820-890:"
sed -n '820,890p' plugin/pi/index.ts

echo "All occurrences of reachability/timeout markers:"
rg -n "takeLastFetchReachedServer|takeLastFetchTimeoutMethod|lastFetchReachedServer|lastFetchTimeoutMethod|reach.*server|reachable|outage|timeout" plugin/pi/index.ts

echo "Read-only JavaScript probe for interleaving semantics:"
node - <<'JS'
async function interleavingDemo() {
  let lastFetchTimeoutMethod;
  let lastFetchReachedServer = false;

  function takeLastFetchTimeoutMethod() {
    const method = lastFetchTimeoutMethod;
    lastFetchTimeoutMethod = undefined;
    return method;
  }
  function takeLastFetchReachedServer() {
    const reached = lastFetchReachedServer;
    lastFetchReachedServer = false;
    return reached;
  }
  async function engramFetch({ name, timedOut, ok, reached }) {
    lastFetchTimeoutMethod = undefined;
    lastFetchReachedServer = false;
    if (timedOut) lastFetchTimeoutMethod = name;
    if (!ok) return null;
    if (reached) lastFetchReachedServer = true;
    return null;
  }

  // Original async function semantics:
  // request A is unreachable/timeout before it has set A's marker;
  // request B completes a reachable no-hit response after A sets module-wide marker to false.
  const reqALeftOffWithNull = await engramFetch({ name: "A", timedOut: false, ok: false, reached: false });
  const beforeRequestBCompletesReached = takeLastFetchReachedServer();
  await engramFetch({ name: "B", timedOut: false, ok: true, reached: true });
  const afterRequestBCompletesReached = takeLastFetchReachedServer();

  console.log(JSON.stringify({
    reqALeftOffWithNull,
    lastReachedAfterRequestAFailedAndBeforeRequestBCompletes: beforeRequestBCompletesReached,
    lastReachedAfterRequestBCompletes: afterRequestBCompletesReached,
  }));
}
interleavingDemo().catch(console.error);
JS

Repository: Gentleman-Programming/engram

Length of output: 6686


Keep fetch status out of module-global state.

lastFetchReachedServer and lastFetchTimeoutMethod are shared across all tool/lifecycle requests, so a concurrent successful fetch can clear or overwrite the marker for another request before executeMemoryTool reads them. Scope this reachability/timeout metadata to the same call as callMemoryTool, e.g. by returning it through the per-operation result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/pi/index.ts` around lines 193 - 211, Remove the module-global fetch
status variables and make reachability and timeout metadata per-operation.
Update engramFetch and the callMemoryTool/executeMemoryTool flow to return and
consume this metadata through the current operation’s result, ensuring
concurrent requests cannot overwrite each other’s status.

Source: Path instructions

let res: Response | undefined;
let timedOut = false;
for (let attempt = 0; attempt < ENGRAM_FETCH_MAX_ATTEMPTS; attempt += 1) {
Expand Down Expand Up @@ -241,6 +253,10 @@ async function engramFetch<TResponse = unknown>(path: string, opts: FetchOptions
throw new EngramHttpError(message, res.status, data);
}

// The server answered with a 2xx. Record that out-of-band so the tool layer can tell a genuine
// empty response (a JSON null body, e.g. a no-hit search) apart from an unreachable server —
// engramFetch returns null for both, and only the latter should surface as an outage.
lastFetchReachedServer = true;
return data as TResponse;
}

Expand Down Expand Up @@ -821,10 +837,14 @@ async function executeMemoryTool(toolName: string, params: Record<string, unknow

try {
const data = await callMemoryTool(toolName, params, ctx);
if (data === null) {
if (data === null && !takeLastFetchReachedServer()) {
// A null with no 2xx behind it is a genuine transport failure (unreachable/timeout), not an
// empty result. Surface it as an outage; a reachable-but-empty response falls through.
throw new Error(unreachableMessage(takeLastFetchTimeoutMethod()));
}
const result = { content: [{ type: "text" as const, text: textResult(data) }], details: { data } };
// A no-hit search comes back as a null JSON body; render it as an empty list, not "{}".
const rendered = data === null && toolName === "mem_search" ? [] : data;
const result = { content: [{ type: "text" as const, text: textResult(rendered) }], details: { data: rendered } };
Comment on lines +840 to +847

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)plugin/pi/index\.ts$|plugin/.*pi.*' || true

echo "== outline =="
ast-grep outline plugin/pi/index.ts --view expanded | head -200 || true

echo "== relevant sections =="
sed -n '220,260p' plugin/pi/index.ts
echo "---"
sed -n '820,860p' plugin/pi/index.ts

echo "== json-related search =="
rg -n "res\.json|json\(|no-hit|mem_search|unreachable|timeout|Content-Type|parsed|parse" plugin/pi/index.ts

Repository: Gentleman-Programming/engram

Length of output: 12461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== callMemoryTool section =="
sed -n '667,830p' plugin/pi/index.ts

echo "== parse failure shape probe =="
python3 - <<'PY'
values = [
    ("valid null", None),
    ("invalid JSON", "abc123"),  # would raise on res.json() and be coerced to null in current code
]
for name, data in values:
    rendered = data if data is not None else []
    print(f"{name}: input={name} -> rendered={rendered}")
PY

Repository: Gentleman-Programming/engram

Length of output: 7168


Preserve JSON parse failures from search results.

engramFetch maps any res.json() failure for a 2xx response to null, so mem_search can render malformed or empty JSON as [] instead of showing the protocol error. Only treat data === null as an empty no-hit result when it comes from valid parsed JSON.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/pi/index.ts` around lines 840 - 847, Update the mem_search handling
around engramFetch so a null result is converted to [] only when the 2xx
response contained valid parsed JSON representing a no-hit result. Preserve and
surface JSON parse failures instead of allowing them to pass through the data
=== null branch as an empty search result; keep the existing transport-failure
check using takeLastFetchReachedServer and unreachableMessage unchanged.

if (toolName === "mem_doctor" && data && typeof data === "object" && "status" in data && data.status === "error") {
const errorResult = { ...result, isError: true };
ctx.ui?.setStatus?.("engram", `🧠 ${project} · ${compactResultStatus(toolName, errorResult)}`);
Expand Down
3 changes: 2 additions & 1 deletion plugin/pi/test/index-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ function buildEngramFetchForTest({
${extractFunctionBody("isTimeoutError", "{\n return error instanceof Error")}
}
let lastFetchTimeoutMethod;
let lastFetchReachedServer = false;
const engramFetch = async function engramFetch(path, opts = {}) {
${body}
};
return { engramFetch, timedOutMethod: () => lastFetchTimeoutMethod };
return { engramFetch, timedOutMethod: () => lastFetchTimeoutMethod, reachedServer: () => lastFetchReachedServer };
`,
);
return factory(
Expand Down
84 changes: 84 additions & 0 deletions plugin/pi/test/native-tool-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,87 @@ test("registered Pi-native mem_search reports native provider transport failure"
await rm(NODE_MODULES, { recursive: true, force: true });
}
});

function jsonResponse(status, body) {
return { ok: status >= 200 && status < 300, status, json: async () => body };
}

// Runs mem_search against a stubbed Engram HTTP server. `searchResponder` returns the Response-like
// object for the /search request; every other endpoint the extension touches during init/project
// detection gets a permissive 200 so the search path is what the assertions actually exercise.
async function runMemSearch(searchResponder) {
const originalFetch = globalThis.fetch;
const originalUrl = process.env.ENGRAM_URL;
process.env.ENGRAM_URL = "http://127.0.0.1:17437";
globalThis.fetch = async (url) => {
const target = String(url);
if (target.includes("/search")) return searchResponder();
if (target.includes("/project/current")) return jsonResponse(200, { project: "gentle-agent-state" });
return jsonResponse(200, {});
};

try {
await installRuntimeStubs();
const registeredTools = new Map();
const pluginUrl = pathToFileURL(join(ROOT, "index.ts"));
pluginUrl.search = `?contract=${Date.now()}-${Math.random()}`;
const { default: registerEngram } = await import(pluginUrl.href);
registerEngram({
registerTool(tool) {
registeredTools.set(tool.name, tool);
},
on() {},
});

const memSearch = registeredTools.get("mem_search");
assert.ok(memSearch, "mem_search tool should be registered");

return await memSearch.execute(
"tool-call-search",
{ query: "state markers", project: "gentle-agent-state" },
undefined,
undefined,
{
cwd: ROOT,
sessionManager: { getSessionId: () => "test-session" },
ui: { setStatus() {} },
},
);
} finally {
globalThis.fetch = originalFetch;
if (originalUrl === undefined) delete process.env.ENGRAM_URL;
else process.env.ENGRAM_URL = originalUrl;
await rm(NODE_MODULES, { recursive: true, force: true });
Comment on lines +101 to +131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not delete the repository’s node_modules.

This helper overwrites stub package paths and then recursively removes plugin/pi/node_modules at Line 131. Running the test in an installed workspace can destroy unrelated dependencies and interfere with concurrent tests. Use an isolated fixture or back up and restore only the package paths created by this test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugin/pi/test/native-tool-contract.test.mjs` around lines 101 - 131, Update
the test helper around installRuntimeStubs and its finally cleanup so it never
recursively removes the repository’s plugin/pi/node_modules directory. Use an
isolated temporary fixture for stub packages, or track and restore only the
package paths created by this test, while preserving cleanup of the test-owned
resources and restoring environment state.

}
}

test("mem_search treats a JSON null (no-hit) response as an empty result, not an outage", async () => {
const result = await runMemSearch(() => jsonResponse(200, null));

assert.notEqual(result.isError, true);
assert.doesNotMatch(result.content[0].text, /could not reach the Engram HTTP server/);
assert.deepEqual(result.details.data, []);
});

test("mem_search preserves an existing empty array response", async () => {
const result = await runMemSearch(() => jsonResponse(200, []));

assert.notEqual(result.isError, true);
assert.doesNotMatch(result.content[0].text, /could not reach the Engram HTTP server/);
assert.deepEqual(result.details.data, []);
});

test("mem_search returns populated results unchanged", async () => {
const hits = [{ id: 1, title: "state markers", type: "discovery" }];
const result = await runMemSearch(() => jsonResponse(200, hits));

assert.notEqual(result.isError, true);
assert.deepEqual(result.details.data, hits);
});

test("mem_search surfaces an HTTP non-2xx response as an error", async () => {
const result = await runMemSearch(() => jsonResponse(500, { error: "search index offline" }));

assert.equal(result.isError, true);
assert.match(result.content[0].text, /search index offline/);
});