Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ follows [Semantic Versioning](https://semver.org/).
- Migrated Pi runtime imports and peer dependencies from the retired
`@mariozechner` scope to `@earendil-works` 0.81.1+, including its unified
TypeBox exports and compatibility API. The minimum Node.js version is now
22.19.0 to match the current Pi runtime.
22.19.0 to match the current Pi runtime. Refreshed the development pins and
npm/Bun dependency locks against the current 0.84.1 release.

## [0.4.0] — 2026-07-19

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What This Is

A memory extension for the [pi coding agent](https://github.com/mariozechner/pi-mono). It provides persistent memory across coding sessions via plain markdown files, with optional semantic search powered by [qmd](https://github.com/tobi/qmd). Single-file extension (`index.ts`) — no build step, pi loads TypeScript directly.
A memory extension for the [pi coding agent](https://github.com/earendil-works/pi). It provides persistent memory across coding sessions via plain markdown files, with optional semantic search powered by [qmd](https://github.com/tobi/qmd). Single-file extension (`index.ts`) — no build step, pi loads TypeScript directly.

## Commands

Expand Down Expand Up @@ -35,7 +35,7 @@ Key design patterns:

## Peer Dependencies

Uses `@mariozechner/pi-coding-agent` (ExtensionAPI types), `@mariozechner/pi-ai` (StringEnum), and `@sinclair/typebox` (schema definitions). These are peer deps — provided by the pi runtime.
Uses `@earendil-works/pi-coding-agent` for extension APIs and `@earendil-works/pi-ai` for model, schema, and compatibility APIs. These are peer deps — provided by the pi runtime.

## Testing

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![npm downloads](https://img.shields.io/npm/dm/pi-memory?color=cb3837&logo=npm)](https://www.npmjs.com/package/pi-memory)
[![license](https://img.shields.io/npm/l/pi-memory)](LICENSE)

**The most popular memory extension for [pi](https://github.com/mariozechner/pi-mono)** — listed in the [official pi package directory](https://pi.dev/packages?name=pi-memory), with semantic search powered by [qmd](https://github.com/tobi/qmd).
**The most popular memory extension for [pi](https://github.com/earendil-works/pi/)** — listed in the [official pi package directory](https://pi.dev/packages?name=pi-memory), with semantic search powered by [qmd](https://github.com/tobi/qmd).

Thanks to https://github.com/skyfallsin/pi-mem for inspiration.

Expand Down Expand Up @@ -188,6 +188,7 @@ This ensures in-progress context survives compaction and is visible in the next
| `PI_MEMORY_DIR` | path | `~/.pi/agent/memory` | Override the memory storage directory |
| `PI_MEMORY_SNAPSHOT` | `stable`, `per-turn` | `stable` | `stable` snapshots memory at checkpoints for KV cache stability; `per-turn` rebuilds every turn (legacy behavior) |
| `PI_MEMORY_QMD_UPDATE` | `background`, `manual`, `off` | `background` | Controls automatic `qmd update` + `qmd embed` after writes |
| `PI_MEMORY_QMD_SEARCH_TIMEOUT_MS` | positive integer (milliseconds) | `60000` | Sets the timeout for explicit `memory_search` qmd queries |
| `PI_MEMORY_NO_SEARCH` | `1` | unset | Disable selective injection in `per-turn` mode (no effect in `stable` mode) |
| `PI_MEMORY_SUMMARIZE_TRANSITIONS` | `1`, `true`, `yes`, `on` | unset | Also write exit summaries during lifecycle transitions (`/reload`, `/new`, `/resume`, `/fork`). By default these transitions skip summaries for speed. |

Expand Down
401 changes: 102 additions & 299 deletions bun.lock

Large diffs are not rendered by default.

30 changes: 26 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,12 @@ export function buildQmdSpawn(
return { file: "node", args: [qmdJsPath, ...args] };
}

export function buildQmdEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const qmdEnv: NodeJS.ProcessEnv = { ...env, NO_COLOR: "1" };
delete qmdEnv.FORCE_COLOR;
return qmdEnv;
}

const execFileWithQmdOptions: ExecFileFn = ((
file: string,
args: readonly string[],
Expand All @@ -866,7 +872,8 @@ const execFileWithQmdOptions: ExecFileFn = ((
) => {
const qmdJs = process.platform === "win32" && isQmdCommand(file) ? resolveQmdJsPath() : null;
const spawn = buildQmdSpawn(file, args ?? [], process.platform, qmdJs);
return execFile(spawn.file, spawn.args, options, callback as any);
const execOptions = isQmdCommand(file) ? { ...options, env: buildQmdEnv(options.env ?? process.env) } : options;
return execFile(spawn.file, spawn.args, execOptions, callback as any);
}) as ExecFileFn;

let execFileFn: ExecFileFn = execFileWithQmdOptions;
Expand All @@ -878,11 +885,17 @@ let qmdAvailabilityCheckedAt = 0;
// don't have to wait through a long TTL before retries succeed.
const QMD_STATUS_CACHE_TTL_MS = 5 * 60 * 1000;
const QMD_STATUS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
const DEFAULT_QMD_SEARCH_TIMEOUT_MS = 60_000;
const qmdCollectionStatusCache = new Map<string, { checkedAt: number; exists: boolean }>();

function qmdStatusTtl(positive: boolean): number {
return positive ? QMD_STATUS_CACHE_TTL_MS : QMD_STATUS_NEGATIVE_CACHE_TTL_MS;
}

export function getQmdSearchTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const configured = Number(env.PI_MEMORY_QMD_SEARCH_TIMEOUT_MS);
return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_QMD_SEARCH_TIMEOUT_MS;
}
let updateTimer: ReturnType<typeof setTimeout> | null = null;
let exitSummaryReason: ExitSummaryReason | null = null;
let terminalInputUnsubscribe: (() => void) | null = null;
Expand Down Expand Up @@ -1181,8 +1194,9 @@ function getQmdResultText(r: QmdSearchResult): string {
function stripAnsi(text: string): string {
// qmd may emit spinners/progress bars even with --json, especially on first model download.
// Strip ANSI CSI/OSC sequences so we can reliably find and parse JSON payloads.
// CSI parameter bytes include private-mode sequences such as ESC[?25l / ESC[?25h.
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences
return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "").replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, "");
return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, "");
}

function parseQmdJson(stdout: string): unknown {
Expand Down Expand Up @@ -1212,11 +1226,18 @@ export function runQmdSearch(
): Promise<{ results: QmdSearchResult[]; stderr: string }> {
const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
const args = [subcommand, "--json", "-c", "pi-memory", "-n", String(limit), query];
const timeoutMs = getQmdSearchTimeoutMs();

return new Promise((resolve, reject) => {
execFileFn("qmd", args, { timeout: 60_000 }, (err, stdout, stderr) => {
execFileFn("qmd", args, { timeout: timeoutMs }, (err, stdout, stderr) => {
if (err) {
reject(new Error(stderr?.trim() || err.message));
const cleaned = stripAnsi(stderr ?? "").trim();
const cleanedMessage = stripAnsi(err.message).trim();
const timedOut = (err as NodeJS.ErrnoException & { killed?: boolean }).killed === true;
const hint = timedOut
? ` (qmd timed out after ${timeoutMs / 1000}s — first semantic/deep search may download or load models; retry shortly)`
: "";
reject(new Error(`${cleaned || cleanedMessage}${hint}`));
return;
}
try {
Expand Down Expand Up @@ -2306,6 +2327,7 @@ export default function (pi: ExtensionAPI) {
"## Configuration",
`- PI_MEMORY_SNAPSHOT: ${getSnapshotMode()}`,
`- PI_MEMORY_QMD_UPDATE: ${getQmdUpdateMode()}`,
`- PI_MEMORY_QMD_SEARCH_TIMEOUT_MS: ${getQmdSearchTimeoutMs()}`,
`- PI_MEMORY_DIR: ${process.env.PI_MEMORY_DIR ? "set" : "default"}`,
);

Expand Down
Loading
Loading