mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
/**
|
|
* memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
|
|
*
|
|
* Memory scopes:
|
|
* - "user" → getAgentDir()/agent-memory/{agent-name}/ (default ~/.pi/agent/agent-memory/, honors $PI_CODING_AGENT_DIR)
|
|
* - "project" → .pi/agent-memory/{agent-name}/
|
|
* - "local" → .pi/agent-memory-local/{agent-name}/
|
|
*
|
|
* The user scope previously hardcoded ~/.pi/agent-memory/. That legacy location
|
|
* is still honored (read + write) when it exists and the new location doesn't,
|
|
* so existing memories aren't orphaned.
|
|
*/
|
|
|
|
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join, } from "node:path";
|
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
import type { MemoryScope } from "./types.js";
|
|
|
|
/** Maximum lines to read from MEMORY.md */
|
|
const MAX_MEMORY_LINES = 200;
|
|
|
|
/**
|
|
* Returns true if a name contains characters not allowed in agent/skill names.
|
|
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
|
|
*/
|
|
export function isUnsafeName(name: string): boolean {
|
|
if (!name || name.length > 128) return true;
|
|
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given path is a symlink (defense against symlink attacks).
|
|
*/
|
|
export function isSymlink(filePath: string): boolean {
|
|
try {
|
|
return lstatSync(filePath).isSymbolicLink();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safely read a file, rejecting symlinks.
|
|
* Returns undefined if the file doesn't exist, is a symlink, or can't be read.
|
|
*/
|
|
export function safeReadFile(filePath: string): string | undefined {
|
|
if (!existsSync(filePath)) return undefined;
|
|
if (isSymlink(filePath)) return undefined;
|
|
try {
|
|
return readFileSync(filePath, "utf-8");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the memory directory path for a given agent + scope + cwd.
|
|
* Throws if agentName contains path traversal characters.
|
|
*/
|
|
export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
if (isUnsafeName(agentName)) {
|
|
throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
|
|
}
|
|
switch (scope) {
|
|
case "user": {
|
|
const current = join(getAgentDir(), "agent-memory", agentName);
|
|
// Legacy location from when this path was hardcoded. Keep using it if it
|
|
// already holds this agent's memory and the new location hasn't been
|
|
// created yet — otherwise existing memories would be silently orphaned.
|
|
const legacy = join(homedir(), ".pi", "agent-memory", agentName);
|
|
if (!existsSync(current) && existsSync(legacy) && !isSymlink(legacy)) {
|
|
return legacy;
|
|
}
|
|
return current;
|
|
}
|
|
case "project":
|
|
return join(cwd, ".pi", "agent-memory", agentName);
|
|
case "local":
|
|
return join(cwd, ".pi", "agent-memory-local", agentName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure the memory directory exists, creating it if needed.
|
|
* Refuses to create directories if any component in the path is a symlink
|
|
* to prevent symlink-based directory traversal attacks.
|
|
*/
|
|
export function ensureMemoryDir(memoryDir: string): void {
|
|
// If the directory already exists, verify it's not a symlink
|
|
if (existsSync(memoryDir)) {
|
|
if (isSymlink(memoryDir)) {
|
|
throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
|
|
}
|
|
return;
|
|
}
|
|
mkdirSync(memoryDir, { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* Read the first N lines of MEMORY.md from the memory directory, if it exists.
|
|
* Returns undefined if no MEMORY.md exists or if the path is a symlink.
|
|
*/
|
|
export function readMemoryIndex(memoryDir: string): string | undefined {
|
|
// Reject symlinked memory directories
|
|
if (isSymlink(memoryDir)) return undefined;
|
|
|
|
const memoryFile = join(memoryDir, "MEMORY.md");
|
|
const content = safeReadFile(memoryFile);
|
|
if (content === undefined) return undefined;
|
|
|
|
const lines = content.split("\n");
|
|
if (lines.length > MAX_MEMORY_LINES) {
|
|
return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)";
|
|
}
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Build the memory block to inject into the agent's system prompt.
|
|
* Also ensures the memory directory exists (creates it if needed).
|
|
*/
|
|
export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
|
// Create the memory directory so the agent can immediately write to it
|
|
ensureMemoryDir(memoryDir);
|
|
|
|
const existingMemory = readMemoryIndex(memoryDir);
|
|
|
|
const header = `# Agent Memory
|
|
|
|
You have a persistent memory directory at: ${memoryDir}/
|
|
Memory scope: ${scope}
|
|
|
|
This memory persists across sessions. Use it to build up knowledge over time.`;
|
|
|
|
const memoryContent = existingMemory
|
|
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
|
: `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`;
|
|
|
|
const instructions = `
|
|
|
|
## Memory Instructions
|
|
- MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated.
|
|
- Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md.
|
|
- Each memory file should use this frontmatter format:
|
|
\`\`\`markdown
|
|
---
|
|
name: <memory name>
|
|
description: <one-line description>
|
|
type: <user|feedback|project|reference>
|
|
---
|
|
<memory content>
|
|
\`\`\`
|
|
- Update or remove memories that become outdated. Check for existing memories before creating duplicates.
|
|
- You have Read, Write, and Edit tools available for managing memory files.`;
|
|
|
|
return header + memoryContent + instructions;
|
|
}
|
|
|
|
/**
|
|
* Build a read-only memory block for agents that lack write/edit tools.
|
|
* Does NOT create the memory directory — agents can only consume existing memory.
|
|
*/
|
|
export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
|
const existingMemory = readMemoryIndex(memoryDir);
|
|
|
|
const header = `# Agent Memory (read-only)
|
|
|
|
Memory scope: ${scope}
|
|
You have read-only access to memory. You can reference existing memories but cannot create or modify them.`;
|
|
|
|
const memoryContent = existingMemory
|
|
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
|
: `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`;
|
|
|
|
return header + memoryContent;
|
|
}
|