templates: add qmd semantic memory recall to AGENTS.md

This commit is contained in:
Peter Steinberger
2026-01-03 01:33:04 +00:00
parent 0c013a237f
commit 7e4e9ecdea
8 changed files with 216 additions and 66 deletions

View File

@@ -16,6 +16,7 @@ Key parameters:
- `yieldMs` (default 20000): autobackground after this delay - `yieldMs` (default 20000): autobackground after this delay
- `background` (bool): background immediately - `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout - `timeout` (seconds, default 1800): kill the process after this timeout
- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back)
- `workdir`, `env` - `workdir`, `env`
Behavior: Behavior:

View File

@@ -28,6 +28,16 @@ You wake up fresh each session. These files are your continuity:
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them. Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
### 🧠 Memory Recall - Use qmd!
When you need to remember something from the past, use `qmd` instead of grepping files:
```bash
qmd query "what happened at Christmas" # Semantic search with reranking
qmd search "specific phrase" # BM25 keyword search
qmd vsearch "conceptual question" # Pure vector similarity
```
Index your memory folder: `qmd index memory/`
Vectors + BM25 + reranking finds things even with different wording.
## Safety ## Safety
- Don't exfiltrate private data. Ever. - Don't exfiltrate private data. Ever.

View File

@@ -21,6 +21,7 @@ Core parameters:
- `yieldMs` (auto-background after timeout, default 20000) - `yieldMs` (auto-background after timeout, default 20000)
- `background` (immediate background) - `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800) - `timeout` (seconds; kills the process if exceeded, default 1800)
- `stdinMode` (`pipe` | `pty`; `pty` uses node-pty for a real TTY with fallback warning)
Notes: Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.

View File

@@ -92,6 +92,7 @@
"grammy": "^1.39.2", "grammy": "^1.39.2",
"json5": "^2.2.3", "json5": "^2.2.3",
"long": "5.3.2", "long": "5.3.2",
"node-pty": "^1.1.0",
"playwright-core": "1.57.0", "playwright-core": "1.57.0",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",

15
pnpm-lock.yaml generated
View File

@@ -88,6 +88,9 @@ importers:
long: long:
specifier: 5.3.2 specifier: 5.3.2
version: 5.3.2 version: 5.3.2
node-pty:
specifier: ^1.1.0
version: 1.1.0
playwright-core: playwright-core:
specifier: 1.57.0 specifier: 1.57.0
version: 1.57.0 version: 1.57.0
@@ -2158,6 +2161,9 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-domexception@1.0.0: node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'} engines: {node: '>=10.5.0'}
@@ -2176,6 +2182,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-wav@0.0.2: node-wav@0.0.2:
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==} resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
engines: {node: '>=4.4.0'} engines: {node: '>=4.4.0'}
@@ -4804,6 +4813,8 @@ snapshots:
negotiator@1.0.0: {} negotiator@1.0.0: {}
node-addon-api@7.1.1: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
node-fetch@2.7.0: node-fetch@2.7.0:
@@ -4816,6 +4827,10 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-wav@0.0.2: node-wav@0.0.2:
optional: true optional: true

View File

@@ -20,6 +20,7 @@ describe("bash process registry", () => {
id: "sess", id: "sess",
command: "echo test", command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams, child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(), startedAt: Date.now(),
cwd: "/tmp", cwd: "/tmp",
maxOutputChars: 10, maxOutputChars: 10,
@@ -48,6 +49,7 @@ describe("bash process registry", () => {
id: "sess", id: "sess",
command: "echo test", command: "echo test",
child: { pid: 123 } as ChildProcessWithoutNullStreams, child: { pid: 123 } as ChildProcessWithoutNullStreams,
stdinMode: "pipe",
startedAt: Date.now(), startedAt: Date.now(),
cwd: "/tmp", cwd: "/tmp",
maxOutputChars: 100, maxOutputChars: 100,

View File

@@ -1,4 +1,5 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { IPty } from "node-pty";
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
@@ -15,10 +16,15 @@ let jobTtlMs = clampTtl(
export type ProcessStatus = "running" | "completed" | "failed" | "killed"; export type ProcessStatus = "running" | "completed" | "failed" | "killed";
export type ProcessStdinMode = "pipe" | "pty";
export interface ProcessSession { export interface ProcessSession {
id: string; id: string;
command: string; command: string;
child: ChildProcessWithoutNullStreams; child?: ChildProcessWithoutNullStreams;
pty?: IPty;
pid?: number;
stdinMode: ProcessStdinMode;
startedAt: number; startedAt: number;
cwd?: string; cwd?: string;
maxOutputChars: number; maxOutputChars: number;

View File

@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { IPty } from "node-pty";
import { import {
addSession, addSession,
@@ -14,6 +15,7 @@ import {
listRunningSessions, listRunningSessions,
markBackgrounded, markBackgrounded,
markExited, markExited,
type ProcessStdinMode,
setJobTtlMs, setJobTtlMs,
} from "./bash-process-registry.js"; } from "./bash-process-registry.js";
import { import {
@@ -29,6 +31,19 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
1_000, 1_000,
150_000, 150_000,
); );
const DEFAULT_PTY_NAME = "xterm-256color";
type PtyModule = typeof import("node-pty");
let ptyModulePromise: Promise<PtyModule | null> | null = null;
async function loadPtyModule(): Promise<PtyModule | null> {
if (!ptyModulePromise) {
ptyModulePromise = import("node-pty")
.then((mod) => mod)
.catch(() => null);
}
return ptyModulePromise;
}
const stringEnum = ( const stringEnum = (
values: readonly string[], values: readonly string[],
@@ -72,7 +87,7 @@ const bashSchema = Type.Object({
), ),
stdinMode: Type.Optional( stdinMode: Type.Optional(
stringEnum(["pipe", "pty"] as const, { stringEnum(["pipe", "pty"] as const, {
description: "Only pipe is supported", description: "stdin mode (pipe or pty when node-pty is available)",
}), }),
), ),
}); });
@@ -127,9 +142,6 @@ export function createBashTool(
if (!params.command) { if (!params.command) {
throw new Error("Provide a command to start."); throw new Error("Provide a command to start.");
} }
if (params.stdinMode && params.stdinMode !== "pipe") {
throw new Error('Only stdinMode "pipe" is supported right now.');
}
const yieldWindow = params.background const yieldWindow = params.background
? 0 ? 0
@@ -146,21 +158,56 @@ export function createBashTool(
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
const env = params.env ? { ...process.env, ...params.env } : process.env; const env = params.env ? { ...process.env, ...params.env } : process.env;
const child: ChildProcessWithoutNullStreams = spawn( const requestedStdinMode =
shell, params.stdinMode === "pty" ? "pty" : "pipe";
[...shellArgs, params.command], let stdinMode: ProcessStdinMode = requestedStdinMode;
{ let warning: string | null = null;
let child: ChildProcessWithoutNullStreams | undefined;
let pty: IPty | undefined;
if (stdinMode === "pty") {
const ptyModule = await loadPtyModule();
if (!ptyModule) {
warning =
"Warning: node-pty failed to load; falling back to pipe mode.";
stdinMode = "pipe";
} else {
const ptyEnv = {
...env,
TERM: env.TERM ?? DEFAULT_PTY_NAME,
} as Record<string, string>;
try {
pty = ptyModule.spawn(shell, [...shellArgs, params.command], {
cwd: workdir,
env: ptyEnv,
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
cols: 120,
rows: 30,
});
} catch {
warning =
"Warning: node-pty failed to start; falling back to pipe mode.";
stdinMode = "pipe";
}
}
}
if (stdinMode === "pipe") {
child = spawn(shell, [...shellArgs, params.command], {
cwd: workdir, cwd: workdir,
env, env,
detached: true, detached: true,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}, });
); }
const session = { const session = {
id: sessionId, id: sessionId,
command: params.command, command: params.command,
child, child,
pty,
pid: child?.pid ?? pty?.pid,
stdinMode,
startedAt, startedAt,
cwd: workdir, cwd: workdir,
maxOutputChars: maxOutput, maxOutputChars: maxOutput,
@@ -190,9 +237,7 @@ export function createBashTool(
}; };
const onAbort = () => { const onAbort = () => {
if (child.pid) { killSession(session);
killProcessTree(child.pid);
}
}; };
if (signal?.aborted) onAbort(); if (signal?.aborted) onAbort();
@@ -212,33 +257,46 @@ export function createBashTool(
const emitUpdate = () => { const emitUpdate = () => {
if (!onUpdate) return; if (!onUpdate) return;
const tailText = session.tail || session.aggregated; const tailText = session.tail || session.aggregated;
const warningText = warning ? `${warning}\n\n` : "";
onUpdate({ onUpdate({
content: [{ type: "text", text: tailText || "" }], content: [{ type: "text", text: warningText + (tailText || "") }],
details: { details: {
status: "running", status: "running",
sessionId, sessionId,
pid: child.pid ?? undefined, pid: session.pid ?? undefined,
startedAt, startedAt,
tail: session.tail, tail: session.tail,
}, },
}); });
}; };
child.stdout.on("data", (data) => { if (child) {
const str = sanitizeBinaryOutput(data.toString()); child.stdout.on("data", (data) => {
for (const chunk of chunkString(str)) { const str = sanitizeBinaryOutput(data.toString());
appendOutput(session, "stdout", chunk); for (const chunk of chunkString(str)) {
emitUpdate(); appendOutput(session, "stdout", chunk);
} emitUpdate();
}); }
});
child.stderr.on("data", (data) => { child.stderr.on("data", (data) => {
const str = sanitizeBinaryOutput(data.toString()); const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) { for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk); appendOutput(session, "stderr", chunk);
emitUpdate(); emitUpdate();
} }
}); });
}
if (pty) {
pty.onData((data) => {
const str = sanitizeBinaryOutput(data);
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
});
}
return new Promise<AgentToolResult<BashToolDetails>>( return new Promise<AgentToolResult<BashToolDetails>>(
(resolve, reject) => { (resolve, reject) => {
@@ -249,14 +307,15 @@ export function createBashTool(
{ {
type: "text", type: "text",
text: text:
`Command still running (session ${sessionId}, pid ${child.pid ?? "n/a"}). ` + `${warning ? `${warning}\n\n` : ""}` +
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.", "Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
}, },
], ],
details: { details: {
status: "running", status: "running",
sessionId, sessionId,
pid: child.pid ?? undefined, pid: session.pid ?? undefined,
startedAt, startedAt,
tail: session.tail, tail: session.tail,
}, },
@@ -283,7 +342,10 @@ export function createBashTool(
}, yieldWindow); }, yieldWindow);
} }
child.once("exit", (code, exitSignal) => { const handleExit = (
code: number | null,
exitSignal: NodeJS.Signals | number | null,
) => {
if (yieldTimer) clearTimeout(yieldTimer); if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutTimer) clearTimeout(timeoutTimer);
const durationMs = Date.now() - startedAt; const durationMs = Date.now() - startedAt;
@@ -315,7 +377,14 @@ export function createBashTool(
settle(() => settle(() =>
resolve({ resolve({
content: [{ type: "text", text: aggregated || "(no output)" }], content: [
{
type: "text",
text:
`${warning ? `${warning}\n\n` : ""}` +
(aggregated || "(no output)"),
},
],
details: { details: {
status: "completed", status: "completed",
exitCode: code ?? 0, exitCode: code ?? 0,
@@ -324,14 +393,26 @@ export function createBashTool(
}, },
}), }),
); );
}); };
child.once("error", (err) => { if (child) {
if (yieldTimer) clearTimeout(yieldTimer); child.once("exit", (code, exitSignal) => {
if (timeoutTimer) clearTimeout(timeoutTimer); handleExit(code, exitSignal);
markExited(session, null, null, "failed"); });
settle(() => reject(err));
}); child.once("error", (err) => {
if (yieldTimer) clearTimeout(yieldTimer);
if (timeoutTimer) clearTimeout(timeoutTimer);
markExited(session, null, null, "failed");
settle(() => reject(err));
});
}
if (pty) {
pty.onExit(({ exitCode, signal }) => {
handleExit(exitCode ?? null, signal ?? null);
});
}
}, },
); );
}, },
@@ -383,7 +464,7 @@ export function createProcessTool(
const running = listRunningSessions().map((s) => ({ const running = listRunningSessions().map((s) => ({
sessionId: s.id, sessionId: s.id,
status: "running", status: "running",
pid: s.child.pid ?? undefined, pid: s.pid ?? undefined,
startedAt: s.startedAt, startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt, runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd, cwd: s.cwd,
@@ -627,25 +708,43 @@ export function createProcessTool(
details: { status: "failed" }, details: { status: "failed" },
}; };
} }
if (!session.child.stdin || session.child.stdin.destroyed) { if (session.stdinMode === "pty") {
return { if (!session.pty || session.exited) {
content: [ return {
{ content: [
type: "text", {
text: `Session ${params.sessionId} stdin is not writable.`, type: "text",
}, text: `Session ${params.sessionId} stdin is not writable.`,
], },
details: { status: "failed" }, ],
}; details: { status: "failed" },
} };
await new Promise<void>((resolve, reject) => { }
session.child.stdin.write(params.data ?? "", (err) => { session.pty.write(params.data ?? "");
if (err) reject(err); if (params.eof) {
else resolve(); session.pty.write("\x04");
}
} else {
if (!session.child?.stdin || session.child.stdin.destroyed) {
return {
content: [
{
type: "text",
text: `Session ${params.sessionId} stdin is not writable.`,
},
],
details: { status: "failed" },
};
}
await new Promise<void>((resolve, reject) => {
session.child?.stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
}); });
}); if (params.eof) {
if (params.eof) { session.child.stdin.end();
session.child.stdin.end(); }
} }
return { return {
content: [ content: [
@@ -687,9 +786,7 @@ export function createProcessTool(
details: { status: "failed" }, details: { status: "failed" },
}; };
} }
if (session.child.pid) { killSession(session);
killProcessTree(session.child.pid);
}
markExited(session, null, "SIGKILL", "failed"); markExited(session, null, "SIGKILL", "failed");
return { return {
content: [ content: [
@@ -725,9 +822,7 @@ export function createProcessTool(
case "remove": { case "remove": {
if (session) { if (session) {
if (session.child.pid) { killSession(session);
killProcessTree(session.child.pid);
}
markExited(session, null, "SIGKILL", "failed"); markExited(session, null, "SIGKILL", "failed");
return { return {
content: [ content: [
@@ -772,6 +867,25 @@ export function createProcessTool(
export const processTool = createProcessTool(); export const processTool = createProcessTool();
function killSession(session: {
pid?: number;
stdinMode: ProcessStdinMode;
pty?: IPty;
child?: ChildProcessWithoutNullStreams;
}) {
const pid = session.pid ?? session.child?.pid ?? session.pty?.pid;
if (pid) {
killProcessTree(pid);
}
if (session.stdinMode === "pty") {
try {
session.pty?.kill();
} catch {
// ignore kill failures
}
}
}
function clampNumber( function clampNumber(
value: number | undefined, value: number | undefined,
defaultValue: number, defaultValue: number,