Merge pull request #262 from getcompanion-ai/memory-staging

Add first-class memory management plumbing
This commit is contained in:
Hari 2026-03-08 19:54:01 -04:00 committed by GitHub
commit fb782fa025
17 changed files with 1982 additions and 3885 deletions

View file

@ -61,6 +61,15 @@ import {
type ToolHtmlRenderer,
} from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import {
RuntimeMemoryManager,
type RuntimeMemoryForgetInput,
type RuntimeMemoryRebuildResult,
type RuntimeMemoryRememberInput,
type RuntimeMemorySearchResult,
type RuntimeMemoryStatus,
type RuntimeMemoryRecord,
} from "./memory/runtime-memory.js";
import {
type ContextUsage,
type ExtensionCommandContextActions,
@ -337,6 +346,9 @@ export class AgentSession {
// Base system prompt (without extension appends) - used to apply fresh appends each turn
private _baseSystemPrompt = "";
private _memoryManager: RuntimeMemoryManager;
private _memoryWriteQueue: Promise<void> = Promise.resolve();
private _memoryDisposePromise: Promise<void> | null = null;
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
@ -350,6 +362,10 @@ export class AgentSession {
this._extensionRunnerRef = config.extensionRunnerRef;
this._initialActiveToolNames = config.initialActiveToolNames;
this._baseToolsOverride = config.baseToolsOverride;
this._memoryManager = new RuntimeMemoryManager({
sessionManager: this.sessionManager,
settingsManager: this.settingsManager,
});
// Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic)
@ -499,6 +515,16 @@ export class AgentSession {
this._resolveRetry();
}
}
if (event.message.role === "user" || event.message.role === "assistant") {
try {
this._memoryManager.recordMessage(event.message);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
console.error(`[memory] episode write failed: ${message}`);
}
}
}
// Check auto-retry and auto-compaction after agent completes
@ -513,6 +539,10 @@ export class AgentSession {
}
await this._checkCompaction(msg);
if (msg.stopReason !== "error") {
this._enqueueMemoryPromotion([...this.agent.state.messages]);
}
}
}
@ -667,6 +697,7 @@ export class AgentSession {
dispose(): void {
this._disconnectFromAgent();
this._eventListeners = [];
void this._disposeMemoryManager();
}
// =========================================================================
@ -804,6 +835,107 @@ export class AgentSession {
return this._resourceLoader.getPrompts().prompts;
}
async transformRuntimeContext(
messages: AgentMessage[],
signal?: AbortSignal,
): Promise<AgentMessage[]> {
await this._awaitMemoryWrites();
return this._memoryManager.injectContext(messages, { signal });
}
async getMemoryStatus(): Promise<RuntimeMemoryStatus> {
await this._awaitMemoryWrites();
return this._memoryManager.getStatus();
}
async getCoreMemories(): Promise<RuntimeMemoryRecord[]> {
await this._awaitMemoryWrites();
return this._memoryManager.listCoreMemories();
}
async searchMemory(
query: string,
limit?: number,
): Promise<RuntimeMemorySearchResult> {
await this._awaitMemoryWrites();
return this._memoryManager.search(query, limit);
}
async rememberMemory(
input: RuntimeMemoryRememberInput,
): Promise<RuntimeMemoryRecord | null> {
await this._awaitMemoryWrites();
return this._memoryManager.remember(input);
}
async forgetMemory(
input: RuntimeMemoryForgetInput,
): Promise<{ ok: true; forgotten: boolean }> {
await this._awaitMemoryWrites();
return this._memoryManager.forget(input);
}
async rebuildMemory(): Promise<RuntimeMemoryRebuildResult> {
await this._awaitMemoryWrites();
return this._memoryManager.rebuild();
}
private async _awaitMemoryWrites(): Promise<void> {
try {
await this._memoryWriteQueue;
} catch {
// Memory writes are best-effort; failures should not block chat.
}
}
private async _disposeMemoryManager(): Promise<void> {
if (this._memoryDisposePromise) {
await this._memoryDisposePromise;
return;
}
this._memoryDisposePromise = (async () => {
try {
await this._agentEventQueue;
} catch {
// Event processing failures should not block shutdown.
}
try {
await this._memoryWriteQueue;
} catch {
// Memory writes are best-effort during shutdown too.
}
this._memoryManager.dispose();
})();
await this._memoryDisposePromise;
}
private _enqueueMemoryPromotion(messages: AgentMessage[]): void {
this._memoryWriteQueue = this._memoryWriteQueue
.catch(() => undefined)
.then(async () => {
if (!this.model) {
return;
}
const apiKey = await this._modelRegistry.getApiKey(this.model);
if (!apiKey) {
return;
}
await this._memoryManager.promoteTurn({
model: this.model,
apiKey,
messages,
});
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[memory] promotion failed: ${message}`);
});
}
private _normalizePromptSnippet(
text: string | undefined,
): string | undefined {

View file

@ -297,6 +297,16 @@ export class GatewayRuntime {
return managedSession;
}
private async resolveMemorySession(
sessionKey: string | null | undefined,
): Promise<AgentSession> {
if (!sessionKey || sessionKey === this.primarySessionKey) {
return this.primarySession;
}
const managedSession = await this.ensureSession(sessionKey);
return managedSession.session;
}
private async processNext(
managedSession: ManagedGatewaySession,
): Promise<void> {
@ -624,6 +634,102 @@ export class GatewayRuntime {
return;
}
if (method === "GET" && path === "/memory/status") {
const sessionKey = url.searchParams.get("sessionKey");
const memorySession = await this.resolveMemorySession(sessionKey);
const memory = await memorySession.getMemoryStatus();
this.writeJson(response, 200, { memory });
return;
}
if (method === "GET" && path === "/memory/core") {
const sessionKey = url.searchParams.get("sessionKey");
const memorySession = await this.resolveMemorySession(sessionKey);
const memories = await memorySession.getCoreMemories();
this.writeJson(response, 200, { memories });
return;
}
if (method === "POST" && path === "/memory/search") {
const body = await this.readJsonBody(request);
const query = typeof body.query === "string" ? body.query : "";
const limit =
typeof body.limit === "number" && Number.isFinite(body.limit)
? Math.max(1, Math.floor(body.limit))
: undefined;
const sessionKey =
typeof body.sessionKey === "string" ? body.sessionKey : undefined;
const memorySession = await this.resolveMemorySession(sessionKey);
const result = await memorySession.searchMemory(query, limit);
this.writeJson(response, 200, result);
return;
}
if (method === "POST" && path === "/memory/remember") {
const body = await this.readJsonBody(request);
const content = typeof body.content === "string" ? body.content : "";
if (!content.trim()) {
this.writeJson(response, 400, { error: "Missing memory content" });
return;
}
const sessionKey =
typeof body.sessionKey === "string" ? body.sessionKey : undefined;
const memorySession = await this.resolveMemorySession(sessionKey);
const memory = await memorySession.rememberMemory({
bucket:
body.bucket === "core" || body.bucket === "archival"
? body.bucket
: undefined,
kind:
body.kind === "profile" ||
body.kind === "preference" ||
body.kind === "relationship" ||
body.kind === "fact" ||
body.kind === "secret"
? body.kind
: undefined,
key: typeof body.key === "string" ? body.key : undefined,
content,
source: "manual",
});
this.writeJson(response, 200, { ok: true, memory });
return;
}
if (method === "POST" && path === "/memory/forget") {
const body = await this.readJsonBody(request);
const id =
typeof body.id === "number" && Number.isFinite(body.id)
? Math.floor(body.id)
: undefined;
const key = typeof body.key === "string" ? body.key : undefined;
if (id === undefined && !key) {
this.writeJson(response, 400, {
error: "Memory forget requires an id or key",
});
return;
}
const sessionKey =
typeof body.sessionKey === "string" ? body.sessionKey : undefined;
const memorySession = await this.resolveMemorySession(sessionKey);
const result = await memorySession.forgetMemory({
id,
key,
});
this.writeJson(response, 200, result);
return;
}
if (method === "POST" && path === "/memory/rebuild") {
const body = await this.readJsonBody(request);
const sessionKey =
typeof body.sessionKey === "string" ? body.sessionKey : undefined;
const memorySession = await this.resolveMemorySession(sessionKey);
const result = await memorySession.rebuildMemory();
this.writeJson(response, 200, result);
return;
}
const sessionMatch = path.match(
/^\/sessions\/([^/]+)(?:\/(events|messages|abort|reset|chat|history|model|reload))?$/,
);

File diff suppressed because it is too large Load diff

View file

@ -320,6 +320,7 @@ export async function createAgentSession(
};
const extensionRunnerRef: { current?: ExtensionRunner } = {};
const sessionRef: { current?: AgentSession } = {};
agent = new Agent({
initialState: {
@ -331,9 +332,15 @@ export async function createAgentSession(
convertToLlm: convertToLlmWithBlockImages,
sessionId: sessionManager.getSessionId(),
transformContext: async (messages) => {
const currentSession = sessionRef.current;
let transformedMessages = messages;
if (currentSession) {
transformedMessages =
await currentSession.transformRuntimeContext(transformedMessages);
}
const runner = extensionRunnerRef.current;
if (!runner) return messages;
return runner.emitContext(messages);
if (!runner) return transformedMessages;
return runner.emitContext(transformedMessages);
},
steeringMode: settingsManager.getSteeringMode(),
followUpMode: settingsManager.getFollowUpMode(),
@ -393,6 +400,7 @@ export async function createAgentSession(
initialActiveToolNames,
extensionRunnerRef,
});
sessionRef.current = session;
const extensionsResult = resourceLoader.getExtensions();
return {

View file

@ -63,6 +63,17 @@ export interface GatewaySettings {
webhook?: GatewayWebhookSettings;
}
export interface CompanionMemorySettings {
enabled?: boolean;
storageDir?: string;
maxCoreTokens?: number;
maxRecallResults?: number;
writer?: {
enabled?: boolean;
maxTokens?: number;
};
}
export type TransportSetting = Transport;
/**
@ -125,6 +136,7 @@ export interface Settings {
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
markdown?: MarkdownSettings;
gateway?: GatewaySettings;
companionMemory?: CompanionMemorySettings;
}
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Vandee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,199 +0,0 @@
# pi-memory-md
Letta-like memory management for [pi](https://github.com/badlogic/pi-mono) using GitHub-backed markdown files.
## Features
- **Persistent Memory**: Store context, preferences, and knowledge across sessions
- **Git-backed**: Version control with full history
- **Prompt append**: Memory index automatically appended to conversation at session start
- **On-demand access**: LLM reads full content via tools when needed
- **Multi-project**: Separate memory spaces per project
## Quick Start
```bash
# 1. Install
pi install npm:pi-memory-md
# Or for latest from GitHub:
pi install git:github.com/VandeeFeng/pi-memory-md
# 2. Create a GitHub repository (private recommended)
# 3. Configure pi
# Add to ~/.pi/agent/settings.json:
{
"pi-memory-md": {
"enabled": true,
"repoUrl": "git@github.com:username/repo.git",
"localPath": "~/.pi/memory-md"
}
}
# 4. Start a new pi session
# The extension will auto-initialize and sync on first run
```
**Commands available in pi:**
- `:memory-init` - Initialize repository structure
- `:memory-status` - Show repository status
## How It Works
```
Session Start
1. Git pull (sync latest changes)
2. Scan all .md files in memory directory
3. Build index (descriptions + tags only - NOT full content)
4. Append index to conversation via prompt append (not system prompt)
5. LLM reads full file content via tools when needed
```
**Why index-only via prompt append?** Keeps token usage low while making full content accessible on-demand. The index is appended to the conversation, not injected into the system prompt.
## Available Tools
The LLM can use these tools to interact with memory:
| Tool | Parameters | Description |
| --------------- | ------------------------------------- | ------------------------------------- | ---------- | -------------- |
| `memory_init` | `{force?: boolean}` | Initialize or reinitialize repository |
| `memory_sync` | `{action: "pull" | "push" | "status"}` | Git operations |
| `memory_read` | `{path: string}` | Read a memory file |
| `memory_write` | `{path, content, description, tags?}` | Create/update memory file |
| `memory_list` | `{directory?: string}` | List all memory files |
| `memory_search` | `{query, searchIn}` | Search by content/tags/description |
## Memory File Format
```markdown
---
description: "User identity and background"
tags: ["user", "identity"]
created: "2026-02-14"
updated: "2026-02-14"
---
# Your Content Here
Markdown content...
```
## Directory Structure
```
~/.pi/memory-md/
└── project-name/
├── core/
│ ├── user/ # Your preferences
│ │ ├── identity.md
│ │ └── prefer.md
│ └── project/ # Project context
│ └── tech-stack.md
└── reference/ # On-demand docs
```
## Configuration
```json
{
"pi-memory-md": {
"enabled": true,
"repoUrl": "git@github.com:username/repo.git",
"localPath": "~/.pi/memory-md",
"injection": "message-append",
"autoSync": {
"onSessionStart": true
}
}
}
```
| Setting | Default | Description |
| ------------------------- | ------------------ | -------------------------------------------------------------- |
| `enabled` | `true` | Enable extension |
| `repoUrl` | Required | GitHub repository URL |
| `localPath` | `~/.pi/memory-md` | Local clone path |
| `injection` | `"message-append"` | Memory injection mode: `"message-append"` or `"system-prompt"` |
| `autoSync.onSessionStart` | `true` | Git pull on session start |
### Memory Injection Modes
The extension supports two modes for injecting memory into the conversation:
#### 1. Message Append (Default)
```json
{
"pi-memory-md": {
"injection": "message-append"
}
}
```
- Memory is sent as a custom message before the user's first message
- Not visible in the TUI (`display: false`)
- Persists in the session history
- Injected only once per session (on first agent turn)
- **Pros**: Lower token usage, memory persists naturally in conversation
- **Cons**: Only visible when the model scrolls back to earlier messages
#### 2. System Prompt
```json
{
"pi-memory-md": {
"injection": "system-prompt"
}
}
```
- Memory is appended to the system prompt
- Rebuilt and injected on every agent turn
- Always visible to the model in the system context
- **Pros**: Memory always present in system context, no need to scroll back
- **Cons**: Higher token usage (repeated on every prompt)
**Recommendation**: Use `message-append` (default) for optimal token efficiency. Switch to `system-prompt` if you notice the model not accessing memory consistently.
## Usage Examples
Simply talk to pi - the LLM will automatically use memory tools when appropriate:
```
You: Save my preference for 2-space indentation in TypeScript files to memory.
Pi: [Uses memory_write tool to save your preference]
```
You can also explicitly request operations:
```
You: List all memory files for this project.
You: Search memory for "typescript" preferences.
You: Read core/user/identity.md
You: Sync my changes to the repository.
```
The LLM automatically:
- Reads memory index at session start (appended to conversation)
- Writes new information when you ask to remember something
- Syncs changes when needed
## Commands
Use these directly in pi:
- `:memory-status` - Show repository status
- `:memory-init` - Initialize repository structure
## Reference
- [Introducing Context Repositories: Git-based Memory for Coding Agents | Letta](https://www.letta.com/blog/context-repositories)

View file

@ -1,641 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type {
ExtensionAPI,
ExtensionContext,
} from "@mariozechner/pi-coding-agent";
import type { GrayMatterFile } from "gray-matter";
import matter from "gray-matter";
import { registerAllTools } from "./tools.js";
/**
* Type definitions for memory files, settings, and git operations.
*/
export interface MemoryFrontmatter {
description: string;
limit?: number;
tags?: string[];
created?: string;
updated?: string;
}
export interface MemoryFile {
path: string;
frontmatter: MemoryFrontmatter;
content: string;
}
export interface MemoryMdSettings {
enabled?: boolean;
repoUrl?: string;
localPath?: string;
autoSync?: {
onSessionStart?: boolean;
};
injection?: "system-prompt" | "message-append";
systemPrompt?: {
maxTokens?: number;
includeProjects?: string[];
};
}
export interface GitResult {
stdout: string;
success: boolean;
}
export interface SyncResult {
success: boolean;
message: string;
updated?: boolean;
}
export type ParsedFrontmatter = GrayMatterFile<string>["data"];
/**
* Helper functions for paths, dates, and settings.
*/
const DEFAULT_LOCAL_PATH = path.join(os.homedir(), ".pi", "memory-md");
export function getCurrentDate(): string {
return new Date().toISOString().split("T")[0];
}
function expandPath(p: string): string {
if (p.startsWith("~")) {
return path.join(os.homedir(), p.slice(1));
}
return p;
}
export function getMemoryDir(
settings: MemoryMdSettings,
ctx: ExtensionContext,
): string {
const basePath = settings.localPath || DEFAULT_LOCAL_PATH;
return path.join(basePath, path.basename(ctx.cwd));
}
function getRepoName(settings: MemoryMdSettings): string {
if (!settings.repoUrl) return "memory-md";
const match = settings.repoUrl.match(/\/([^/]+?)(\.git)?$/);
return match ? match[1] : "memory-md";
}
function loadSettings(): MemoryMdSettings {
const DEFAULT_SETTINGS: MemoryMdSettings = {
enabled: true,
repoUrl: "",
localPath: DEFAULT_LOCAL_PATH,
autoSync: { onSessionStart: true },
injection: "message-append",
systemPrompt: {
maxTokens: 10000,
includeProjects: ["current"],
},
};
const globalSettings = path.join(
os.homedir(),
".pi",
"agent",
"settings.json",
);
if (!fs.existsSync(globalSettings)) {
return DEFAULT_SETTINGS;
}
try {
const content = fs.readFileSync(globalSettings, "utf-8");
const parsed = JSON.parse(content);
const loadedSettings = {
...DEFAULT_SETTINGS,
...(parsed["pi-memory-md"] as MemoryMdSettings),
};
if (loadedSettings.localPath) {
loadedSettings.localPath = expandPath(loadedSettings.localPath);
}
return loadedSettings;
} catch (error) {
console.warn("Failed to load memory settings:", error);
return DEFAULT_SETTINGS;
}
}
/**
* Git sync operations (fetch, pull, push, status).
*/
export async function gitExec(
pi: ExtensionAPI,
cwd: string,
...args: string[]
): Promise<GitResult> {
try {
const result = await pi.exec("git", args, { cwd });
return {
stdout: result.stdout || "",
success: true,
};
} catch {
return { stdout: "", success: false };
}
}
export async function syncRepository(
pi: ExtensionAPI,
settings: MemoryMdSettings,
isRepoInitialized: { value: boolean },
): Promise<SyncResult> {
const localPath = settings.localPath;
const repoUrl = settings.repoUrl;
if (!repoUrl || !localPath) {
return {
success: false,
message: "GitHub repo URL or local path not configured",
};
}
if (fs.existsSync(localPath)) {
const gitDir = path.join(localPath, ".git");
if (!fs.existsSync(gitDir)) {
return {
success: false,
message: `Directory exists but is not a git repo: ${localPath}`,
};
}
const pullResult = await gitExec(
pi,
localPath,
"pull",
"--rebase",
"--autostash",
);
if (!pullResult.success) {
return {
success: false,
message: "Pull failed - try manual git operations",
};
}
isRepoInitialized.value = true;
const updated =
pullResult.stdout.includes("Updating") ||
pullResult.stdout.includes("Fast-forward");
const repoName = getRepoName(settings);
return {
success: true,
message: updated
? `Pulled latest changes from [${repoName}]`
: `[${repoName}] is already latest`,
updated,
};
}
fs.mkdirSync(localPath, { recursive: true });
const memoryDirName = path.basename(localPath);
const parentDir = path.dirname(localPath);
const cloneResult = await gitExec(
pi,
parentDir,
"clone",
repoUrl,
memoryDirName,
);
if (cloneResult.success) {
isRepoInitialized.value = true;
const repoName = getRepoName(settings);
return {
success: true,
message: `Cloned [${repoName}] successfully`,
updated: true,
};
}
return { success: false, message: "Clone failed - check repo URL and auth" };
}
/**
* Memory file read/write/list operations.
*/
function validateFrontmatter(data: ParsedFrontmatter): {
valid: boolean;
error?: string;
} {
if (!data) {
return {
valid: false,
error: "No frontmatter found (requires --- delimiters)",
};
}
const frontmatter = data as MemoryFrontmatter;
if (!frontmatter.description || typeof frontmatter.description !== "string") {
return {
valid: false,
error: "Frontmatter must have a 'description' field (string)",
};
}
if (
frontmatter.limit !== undefined &&
(typeof frontmatter.limit !== "number" || frontmatter.limit <= 0)
) {
return { valid: false, error: "'limit' must be a positive number" };
}
if (frontmatter.tags !== undefined && !Array.isArray(frontmatter.tags)) {
return { valid: false, error: "'tags' must be an array of strings" };
}
return { valid: true };
}
export function readMemoryFile(filePath: string): MemoryFile | null {
try {
const content = fs.readFileSync(filePath, "utf-8");
const parsed = matter(content);
const validation = validateFrontmatter(parsed.data);
if (!validation.valid) {
throw new Error(validation.error);
}
return {
path: filePath,
frontmatter: parsed.data as MemoryFrontmatter,
content: parsed.content,
};
} catch (error) {
console.error(
`Failed to read memory file ${filePath}:`,
error instanceof Error ? error.message : error,
);
return null;
}
}
export function listMemoryFiles(memoryDir: string): string[] {
const files: string[] = [];
function walkDir(dir: string) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
}
walkDir(memoryDir);
return files;
}
export function writeMemoryFile(
filePath: string,
content: string,
frontmatter: MemoryFrontmatter,
): void {
const fileDir = path.dirname(filePath);
fs.mkdirSync(fileDir, { recursive: true });
const frontmatterStr = matter.stringify(content, frontmatter);
fs.writeFileSync(filePath, frontmatterStr);
}
/**
* Build memory context for agent prompt.
*/
function ensureDirectoryStructure(memoryDir: string): void {
const dirs = [
path.join(memoryDir, "core", "user"),
path.join(memoryDir, "core", "project"),
path.join(memoryDir, "reference"),
];
for (const dir of dirs) {
fs.mkdirSync(dir, { recursive: true });
}
}
function createDefaultFiles(memoryDir: string): void {
const identityFile = path.join(memoryDir, "core", "user", "identity.md");
if (!fs.existsSync(identityFile)) {
writeMemoryFile(
identityFile,
"# User Identity\n\nCustomize this file with your information.",
{
description: "User identity and background",
tags: ["user", "identity"],
created: getCurrentDate(),
},
);
}
const preferFile = path.join(memoryDir, "core", "user", "prefer.md");
if (!fs.existsSync(preferFile)) {
writeMemoryFile(
preferFile,
"# User Preferences\n\n## Communication Style\n- Be concise\n- Show code examples\n\n## Code Style\n- 2 space indentation\n- Prefer const over var\n- Functional programming preferred",
{
description: "User habits and code style preferences",
tags: ["user", "preferences"],
created: getCurrentDate(),
},
);
}
}
function buildMemoryContext(
settings: MemoryMdSettings,
ctx: ExtensionContext,
): string {
const coreDir = path.join(getMemoryDir(settings, ctx), "core");
if (!fs.existsSync(coreDir)) {
return "";
}
const files = listMemoryFiles(coreDir);
if (files.length === 0) {
return "";
}
const memoryDir = getMemoryDir(settings, ctx);
const lines: string[] = [
"# Project Memory",
"",
"Available memory files (use memory_read to view full content):",
"",
];
for (const filePath of files) {
const memory = readMemoryFile(filePath);
if (memory) {
const relPath = path.relative(memoryDir, filePath);
const { description, tags } = memory.frontmatter;
const tagStr = tags?.join(", ") || "none";
lines.push(`- ${relPath}`);
lines.push(` Description: ${description}`);
lines.push(` Tags: ${tagStr}`);
lines.push("");
}
}
return lines.join("\n");
}
/**
* Main extension initialization.
*
* Lifecycle:
* 1. session_start: Start async sync (non-blocking), build memory context
* 2. before_agent_start: Wait for sync, then inject memory on first agent turn
* 3. Register tools and commands for memory operations
*
* Memory injection modes:
* - message-append (default): Send as custom message with display: false, not visible in TUI but persists in session
* - system-prompt: Append to system prompt on each agent turn (rebuilds every prompt)
*
* Key optimization:
* - Sync runs asynchronously without blocking user input
* - Memory is injected after user sends first message (before_agent_start)
*
* Configuration:
* Set injection in settings to choose between "message-append" or "system-prompt"
*
* Commands:
* - /memory-status: Show repository status
* - /memory-init: Initialize memory repository
* - /memory-refresh: Manually refresh memory context
*/
export default function memoryMdExtension(pi: ExtensionAPI) {
let settings: MemoryMdSettings = loadSettings();
const repoInitialized = { value: false };
let syncPromise: Promise<SyncResult> | null = null;
let cachedMemoryContext: string | null = null;
let memoryInjected = false;
pi.on("session_start", async (_event, ctx) => {
settings = loadSettings();
if (!settings.enabled) {
return;
}
const memoryDir = getMemoryDir(settings, ctx);
const coreDir = path.join(memoryDir, "core");
if (!fs.existsSync(coreDir)) {
ctx.ui.notify(
"Memory-md not initialized. Use /memory-init to set up project memory.",
"info",
);
return;
}
if (settings.autoSync?.onSessionStart && settings.localPath) {
syncPromise = syncRepository(pi, settings, repoInitialized).then(
(syncResult) => {
if (settings.repoUrl) {
ctx.ui.notify(
syncResult.message,
syncResult.success ? "info" : "error",
);
}
return syncResult;
},
);
}
cachedMemoryContext = buildMemoryContext(settings, ctx);
memoryInjected = false;
});
pi.on("before_agent_start", async (event, ctx) => {
if (syncPromise) {
await syncPromise;
syncPromise = null;
}
if (!cachedMemoryContext) {
return undefined;
}
const mode = settings.injection || "message-append";
const isFirstInjection = !memoryInjected;
if (isFirstInjection) {
memoryInjected = true;
const fileCount = cachedMemoryContext
.split("\n")
.filter((l) => l.startsWith("-")).length;
ctx.ui.notify(`Memory injected: ${fileCount} files (${mode})`, "info");
}
if (mode === "message-append" && isFirstInjection) {
return {
message: {
customType: "pi-memory-md",
content: `# Project Memory\n\n${cachedMemoryContext}`,
display: false,
},
};
}
if (mode === "system-prompt") {
return {
systemPrompt: `${event.systemPrompt}\n\n# Project Memory\n\n${cachedMemoryContext}`,
};
}
return undefined;
});
registerAllTools(pi, settings, repoInitialized);
pi.registerCommand("memory-status", {
description: "Show memory repository status",
handler: async (_args, ctx) => {
const projectName = path.basename(ctx.cwd);
const memoryDir = getMemoryDir(settings, ctx);
const coreUserDir = path.join(memoryDir, "core", "user");
if (!fs.existsSync(coreUserDir)) {
ctx.ui.notify(
`Memory: ${projectName} | Not initialized | Use /memory-init to set up`,
"info",
);
return;
}
const result = await gitExec(
pi,
settings.localPath!,
"status",
"--porcelain",
);
const isDirty = result.stdout.trim().length > 0;
ctx.ui.notify(
`Memory: ${projectName} | Repo: ${isDirty ? "Uncommitted changes" : "Clean"} | Path: ${memoryDir}`,
isDirty ? "warning" : "info",
);
},
});
pi.registerCommand("memory-init", {
description: "Initialize memory repository",
handler: async (_args, ctx) => {
const memoryDir = getMemoryDir(settings, ctx);
const alreadyInitialized = fs.existsSync(
path.join(memoryDir, "core", "user"),
);
const result = await syncRepository(pi, settings, repoInitialized);
if (!result.success) {
ctx.ui.notify(`Initialization failed: ${result.message}`, "error");
return;
}
ensureDirectoryStructure(memoryDir);
createDefaultFiles(memoryDir);
if (alreadyInitialized) {
ctx.ui.notify(`Memory already exists: ${result.message}`, "info");
} else {
ctx.ui.notify(
`Memory initialized: ${result.message}\n\nCreated:\n - core/user\n - core/project\n - reference`,
"info",
);
}
},
});
pi.registerCommand("memory-refresh", {
description: "Refresh memory context from files",
handler: async (_args, ctx) => {
const memoryContext = buildMemoryContext(settings, ctx);
if (!memoryContext) {
ctx.ui.notify("No memory files found to refresh", "warning");
return;
}
cachedMemoryContext = memoryContext;
memoryInjected = false;
const mode = settings.injection || "message-append";
const fileCount = memoryContext
.split("\n")
.filter((l) => l.startsWith("-")).length;
if (mode === "message-append") {
pi.sendMessage({
customType: "pi-memory-md-refresh",
content: `# Project Memory (Refreshed)\n\n${memoryContext}`,
display: false,
});
ctx.ui.notify(
`Memory refreshed: ${fileCount} files injected (${mode})`,
"info",
);
} else {
ctx.ui.notify(
`Memory cache refreshed: ${fileCount} files (will be injected on next prompt)`,
"info",
);
}
},
});
pi.registerCommand("memory-check", {
description: "Check memory folder structure",
handler: async (_args, ctx) => {
const memoryDir = getMemoryDir(settings, ctx);
if (!fs.existsSync(memoryDir)) {
ctx.ui.notify(`Memory directory not found: ${memoryDir}`, "error");
return;
}
const { execSync } = await import("node:child_process");
let treeOutput = "";
try {
treeOutput = execSync(`tree -L 3 -I "node_modules" "${memoryDir}"`, {
encoding: "utf-8",
});
} catch {
try {
treeOutput = execSync(
`find "${memoryDir}" -type d -not -path "*/node_modules/*"`,
{ encoding: "utf-8" },
);
} catch {
treeOutput = "Unable to generate directory tree.";
}
}
ctx.ui.notify(treeOutput.trim(), "info");
},
});
}

View file

@ -1,56 +0,0 @@
{
"name": "pi-memory-md",
"version": "0.1.1",
"description": "Letta-like memory management for pi using structured markdown files in a GitHub repository",
"type": "module",
"license": "MIT",
"author": "VandeePunk",
"repository": {
"type": "git",
"url": "git+https://github.com/VandeeFeng/pi-memory-md.git"
},
"keywords": [
"pi-package",
"pi-extension",
"pi-skill",
"memory",
"markdown",
"git",
"letta",
"persistent-memory",
"ai-memory",
"coding-agent"
],
"dependencies": {
"gray-matter": "^4.0.3"
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "latest",
"@types/node": "^20.0.0",
"husky": "^9.1.7",
"typescript": "^5.0.0"
},
"pi": {
"extensions": [
"./memory-md.ts"
],
"skills": [
"./skills/memory-init/SKILL.md",
"./skills/memory-management/SKILL.md",
"./skills/memory-sync/SKILL.md",
"./skills/memory-search/SKILL.md"
]
},
"files": [
"memory-md.ts",
"tools.ts",
"skills",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"prepare": "husky",
"check": "biome check --write --error-on-warnings . && tsgo --noEmit"
}
}

View file

@ -1,281 +0,0 @@
---
name: memory-init
description: Initial setup and bootstrap for pi-memory-md repository
---
# Memory Init
Use this skill to set up pi-memory-md for the first time or reinitialize an existing installation.
## Prerequisites
1. **GitHub repository** - Create a new empty repository on GitHub
2. **Git access** - Configure SSH keys or personal access token
3. **Node.js & npm** - For installing the package
## Step 1: Install Package
```bash
pi install npm:pi-memory-md
```
## Step 2: Create GitHub Repository
Create a new repository on GitHub:
- Name it something like `memory-md` or `pi-memory`
- Make it private (recommended)
- Don't initialize with README (we'll do that)
**Clone URL will be:** `git@github.com:username/repo-name.git`
## Step 3: Configure Settings
Add to your settings file (global: `~/.pi/agent/settings.json`, project: `.pi/settings.json`):
```json
{
"pi-memory-md": {
"enabled": true,
"repoUrl": "git@github.com:username/repo-name.git",
"localPath": "~/.pi/memory-md",
"autoSync": {
"onSessionStart": true
}
}
}
```
**Settings explained:**
| Setting | Purpose | Default |
| ------------------------- | ----------------------------------- | ----------------- |
| `enabled` | Enable/disable extension | `true` |
| `repoUrl` | GitHub repository URL | Required |
| `localPath` | Local clone location (supports `~`) | `~/.pi/memory-md` |
| `autoSync.onSessionStart` | Auto-pull on session start | `true` |
## Step 4: Initialize Repository
Start pi and run:
```
memory_init()
```
**This does:**
1. Clones the GitHub repository
2. Creates directory structure:
- `core/user/` - Your identity and preferences
- `core/project/` - Project-specific info
3. Creates default files:
- `core/user/identity.md` - User identity template
- `core/user/prefer.md` - User preferences template
**Example output:**
```
Memory repository initialized:
Cloned repository successfully
Created directory structure:
- core/user
- core/project
- reference
```
## Step 5: Import Preferences from AGENTS.md
After initialization, extract relevant preferences from your `AGENTS.md` file to populate `prefer.md`:
1. **Read AGENTS.md** (typically at `.pi/agent/AGENTS.md` or project root)
2. **Extract relevant sections** such as:
- IMPORTANT Rules
- Code Quality Principles
- Coding Style Preferences
- Architecture Principles
- Development Workflow
- Technical Preferences
3. **Present extracted content** to the user in a summarized format
4. **Ask first confirmation**: Include these extracted preferences in `prefer.md`?
```
Found these preferences in AGENTS.md:
- IMPORTANT Rules: [summary]
- Code Quality Principles: [summary]
- Coding Style: [summary]
Include these in core/user/prefer.md? (yes/no)
```
5. **Ask for additional content**: Is there anything else you want to add to your preferences?
```
Any additional preferences you'd like to include? (e.g., communication style, specific tools, workflows)
```
6. **Update prefer.md** with:
- Extracted content from AGENTS.md (if user confirmed)
- Any additional preferences provided by user
## Step 6: Verify Setup
Check status with command:
```
/memory-status
```
Should show: `Memory: project-name | Repo: Clean | Path: {localPath}/project-name`
List files:
```
memory_list()
```
Should show: `core/user/identity.md`, `core/user/prefer.md`
## Project Structure
**Base path**: Configured via `settings["pi-memory-md"].localPath` (default: `~/.pi/memory-md`)
Each project gets its own folder in the repository:
```
{localPath}/
├── project-a/
│ ├── core/
│ │ ├── user/
│ │ │ ├── identity.md
│ │ │ └── prefer.md
│ │ └── project/
│ └── reference/
├── project-b/
│ └── ...
└── project-c/
└── ...
```
Project name is derived from:
- Git repository name (if in a git repo)
- Or current directory name
## First-Time Setup Script
Automate setup with this script:
```bash
#!/bin/bash
# setup-memory-md.sh
REPO_URL="git@github.com:username/memory-repo.git"
SETTINGS_FILE="$HOME/.pi/agent/settings.json"
# Backup existing settings
cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak"
# Add pi-memory-md configuration
node -e "
const fs = require('fs');
const path = require('path');
const settingsPath = '$SETTINGS_FILE';
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
settings['pi-memory-md'] = {
enabled: true,
repoUrl: '$REPO_URL',
localPath: path.join(require('os').homedir(), '.pi', 'memory-md'),
autoSync: {
onSessionStart: true,
onMessageCreate: false
}
};
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
"
echo "Settings configured. Now run: memory_init()"
```
## Reinitializing
To reset everything:
```
memory_init(force=true)
```
**Warning:** This will re-clone the repository, potentially losing local uncommitted changes.
## Troubleshooting
### Clone Failed
**Error:** `Clone failed: Permission denied`
**Solution:**
1. Verify SSH keys are configured: `ssh -T git@github.com`
2. Check repo URL is correct in settings
3. Ensure repo exists on GitHub
### Settings Not Found
**Error:** `GitHub repo URL not configured in settings["pi-memory-md"].repoUrl`
**Solution:**
1. Edit settings file (global or project)
2. Add `pi-memory-md` section (see Step 3)
3. Run `/reload` in pi
### Directory Already Exists
**Error:** `Directory exists but is not a git repo`
**Solution:**
1. Remove existing directory: `rm -rf {localPath}` (use your configured path)
2. Run `memory_init()` again
### No Write Permission
**Error:** `EACCES: permission denied`
**Solution:**
1. Check directory permissions: `ls -la {localPath}/..` (use your configured path)
2. Fix ownership: `sudo chown -R $USER:$USER {localPath}` (use your configured path)
## Verification Checklist
After setup, verify:
- [ ] Package installed: `pi install npm:pi-memory-md`
- [ ] Settings configured in settings file
- [ ] GitHub repository exists and is accessible
- [ ] Repository cloned to configured `localPath`
- [ ] Directory structure created
- [ ] `/memory-status` shows correct info
- [ ] `memory_list()` returns files
- [ ] `prefer.md` populated (either from AGENTS.md or default template)
## Next Steps
After initialization:
1. **Import preferences** - Agent will prompt to extract from AGENTS.md
2. Edit your identity: `memory_read(path="core/user/identity.md")` then `memory_write(...)` to update
3. Review preferences: `memory_read(path="core/user/prefer.md")`
4. Add project context: `memory_write(path="core/project/overview.md", ...)`
5. Learn more: See `memory-management` skill
## Related Skills
- `memory-management` - Creating and managing memory files
- `memory-sync` - Git synchronization
- `memory-search` - Finding information

View file

@ -1,308 +0,0 @@
---
name: memory-management
description: Core memory operations for pi-memory-md - create, read, update, and delete memory files
---
# Memory Management
Use this skill when working with pi-memory-md memory files. Memory is stored as markdown files with YAML frontmatter in a git repository.
## Design Philosophy
Inspired by Letta memory filesystem:
- **File-based memory**: Each memory is a `.md` file with YAML frontmatter
- **Git-backed**: Full version control and cross-device sync
- **Auto-injection**: Files in `core/` are automatically injected to context
- **Organized by purpose**: Fixed structure for core info, flexible for everything else
## Directory Structure
**Base path**: Configured via `settings["pi-memory-md"].localPath` (default: `~/.pi/memory-md`)
```
{localPath}/
└── {project-name}/ # Project memory root
├── core/ # Auto-injected to context every session
│ ├── user/ # 【FIXED】User information
│ │ ├── identity.md # Who the user is
│ │ └── prefer.md # User habits and code style preferences
│ │
│ └── project/ # 【FIXED】Project information (pre-created)
│ ├── overview.md # Project overview
│ ├── architecture.md # Architecture and design
│ ├── conventions.md # Code conventions
│ └── commands.md # Common commands
├── docs/ # 【AGENT-CREATED】Reference documentation
├── archive/ # 【AGENT-CREATED】Historical information
├── research/ # 【AGENT-CREATED】Research findings
└── notes/ # 【AGENT-CREATED】Standalone notes
```
**Important:** `core/project/` is a pre-defined folder under `core/`. Do NOT create another `project/` folder at the project root level.
## Core Design: Fixed vs Flexible
### 【FIXED】core/user/ and core/project/
These are **pre-defined** and **auto-injected** into every session:
**core/user/** - User information (2 fixed files)
- `identity.md` - Who the user is (name, role, background)
- `prefer.md` - User habits and code style preferences
**core/project/** - Project information
- `overview.md` - Project overview
- `architecture.md` - Architecture and design
- `conventions.md` - Code conventions
- `commands.md` - Common commands
- `changelog.md` - Development history
**Why fixed?**
- Always in context, no need to remember to load
- Core identity that defines every interaction
- Project context needed for all decisions
**Rule:** ONLY `user/` and `project/` exist under `core/`. No other folders.
## Decision Tree
### Does this need to be in EVERY conversation?
**Yes** → Place under `core/`
- User-related → `core/user/`
- Project-related → `core/project/`
**No** → Place at project root level (same level as `core/`)
- Reference docs → `docs/`
- Historical → `archive/`
- Research → `research/`
- Notes → `notes/`
- Other? → Create appropriate folder
**Important:** `core/project/` is a FIXED subdirectory under `core/`. Always use `core/project/` for project-specific memory files, NEVER create a `project/` folder at the root level.
## YAML Frontmatter Schema
Every memory file MUST have YAML frontmatter:
```yaml
---
description: "Human-readable description of this memory file"
tags: ["user", "identity"]
created: "2026-02-14"
updated: "2026-02-14"
---
```
**Required fields:**
- `description` (string) - Human-readable description
**Optional fields:**
- `tags` (array of strings) - For searching and categorization
- `created` (date) - File creation date (auto-added on create)
- `updated` (date) - Last modification date (auto-updated on update)
## Examples
### Example 1: User Identity (core/user/identity.md)
```bash
memory_write(
path="core/user/identity.md",
description="User identity and background",
tags=["user", "identity"],
content="# User Identity\n\nName: Vandee\nRole: Developer..."
)
```
### Example 2: User Preferences (core/user/prefer.md)
```bash
memory_write(
path="core/user/prefer.md",
description="User habits and code style preferences",
tags=["user", "preferences"],
content="# User Preferences\n\n## Communication Style\n- Be concise\n- Show code examples\n\n## Code Style\n- 2 space indentation\n- Prefer const over var\n- Functional programming"
)
```
### Example 3: Project Architecture (core/project/)
```bash
memory_write(
path="core/project/architecture.md",
description="Project architecture and design",
tags=["project", "architecture"],
content="# Architecture\n\n..."
)
```
### Example 3: Reference Docs (root level)
```bash
memory_write(
path="docs/api/rest-endpoints.md",
description="REST API reference documentation",
tags=["docs", "api"],
content="# REST Endpoints\n\n..."
)
```
### Example 4: Archived Decision (root level)
```bash
memory_write(
path="archive/decisions/2024-01-15-auth-redesign.md",
description="Auth redesign decision from January 2024",
tags=["archive", "decision"],
content="# Auth Redesign\n\n..."
)
```
## Reading Memory Files
Use the `memory_read` tool:
```bash
memory_read(path="core/user/identity.md")
```
## Listing Memory Files
Use the `memory_list` tool:
```bash
# List all files
memory_list()
# List files in specific directory
memory_list(directory="core/project")
# List only core/ files
memory_list(directory="system")
```
## Updating Memory Files
To update a file, use `memory_write` with the same path:
```bash
memory_write(
path="core/user/identity.md",
description="Updated user identity",
content="New content..."
)
```
The extension preserves existing `created` date and updates `updated` automatically.
## Folder Creation Guidelines
### core/ directory - FIXED structure
**Only two folders exist under `core/`:**
- `user/` - User identity and preferences
- `project/` - Project-specific information
**Do NOT create any other folders under `core/`.**
### Root level (same level as core/) - COMPLETE freedom
**Agent can create any folder structure at project root level (same level as `core/`):**
- `docs/` - Reference documentation
- `archive/` - Historical information
- `research/` - Research findings
- `notes/` - Standalone notes
- `examples/` - Code examples
- `guides/` - How-to guides
**Rule:** Organize root level in a way that makes sense for the project.
**WARNING:** Do NOT create a `project/` folder at root level. Use `core/project/` instead.
## Best Practices
### DO:
- Use `core/user/identity.md` for user identity
- Use `core/user/prefer.md` for user habits and code style
- Use `core/project/` for project-specific information
- Use root level for reference, historical, and research content
- Keep files focused on a single topic
- Organize root level folders by content type
### DON'T:
- Create folders under `core/` other than `user/` and `project/`
- Create other files under `core/user/` (only `identity.md` and `prefer.md`)
- Create a `project/` folder at root level (use `core/project/` instead)
- Put reference docs in `core/` (use root `docs/`)
- Create giant files (split into focused topics)
- Mix unrelated content in same file
## Maintenance
### Session Wrap-up
After completing work, archive to root level:
```bash
memory_write(
path="archive/sessions/2025-02-14-bug-fix.md",
description="Session summary: fixed database connection bug",
tags=["archive", "session"],
content="..."
)
```
### Regular Cleanup
- Consolidate duplicate information
- Update descriptions to stay accurate
- Remove information that's no longer relevant
- Archive old content to appropriate root level folders
## When to Use This Skill
Use `memory-management` when:
- User asks to remember something for future sessions
- Creating or updating project documentation
- Setting preferences or guidelines
- Storing reference material
- Building knowledge base about the project
- Organizing information by type or domain
- Creating reusable patterns and solutions
- Documenting troubleshooting steps
## Related Skills
- `memory-sync` - Git synchronization operations
- `memory-init` - Initial repository setup
- `memory-search` - Finding specific information
- `memory-check` - Validate folder structure before syncing
## Before Syncing
**IMPORTANT**: Before running `memory_sync(action="push")`, ALWAYS run `memory_check()` first to verify the folder structure is correct:
```bash
# Check structure first
memory_check()
# Then push if structure is correct
memory_sync(action="push")
```
This prevents accidentally pushing files in wrong locations (e.g., root `project/` instead of `core/project/`).

View file

@ -1,69 +0,0 @@
---
name: memory-search
description: Search and retrieve information from pi-memory-md memory files
---
# Memory Search
Use this skill to find information stored in pi-memory-md memory files.
## Search Types
### Search by Content
Search within markdown content:
```
memory_search(query="typescript", searchIn="content")
```
Returns matching files with content excerpts.
### Search by Tags
Find files with specific tags:
```
memory_search(query="user", searchIn="tags")
```
Best for finding files by category or topic.
### Search by Description
Find files by their frontmatter description:
```
memory_search(query="identity", searchIn="description")
```
Best for discovering files by purpose.
## Common Search Patterns
| Goal | Command |
| ---------------- | ------------------------------------------------------------- |
| User preferences | `memory_search(query="user", searchIn="tags")` |
| Project info | `memory_search(query="architecture", searchIn="description")` |
| Code style | `memory_search(query="typescript", searchIn="content")` |
| Reference docs | `memory_search(query="reference", searchIn="tags")` |
## Search Tips
- **Case insensitive**: `typescript` and `TYPESCRIPT` work the same
- **Partial matches**: `auth` matches "auth", "authentication", "author"
- **Be specific**: "JWT token validation" > "token"
- **Try different types**: If content search fails, try tags or description
## When Results Are Empty
1. Check query spelling
2. Try different `searchIn` type
3. List all files: `memory_list()`
4. Sync repository: `memory_sync(action="pull")`
## Related Skills
- `memory-management` - Read and write files
- `memory-sync` - Ensure latest data
- `memory-init` - Setup repository

View file

@ -1,74 +0,0 @@
---
name: memory-sync
description: Git synchronization operations for pi-memory-md repository
---
# Memory Sync
Git synchronization for pi-memory-md repository.
## Configuration
Configure `pi-memory-md.repoUrl` in settings file (global: `~/.pi/agent/settings.json`, project: `.pi/settings.json`)
## Sync Operations
### Pull
Fetch latest changes from GitHub:
```
memory_sync(action="pull")
```
Use before starting work or switching machines.
### Push
Upload local changes to GitHub:
```
memory_sync(action="push")
```
Auto-commits changes before pushing.
**Before pushing, ALWAYS run memory_check first:**
```
memory_check()
```
This verifies that the folder structure is correct (e.g., files are in `core/project/` not in a root `project/` folder).
### Status
Check uncommitted changes:
```
memory_sync(action="status")
```
Shows modified/added/deleted files.
## Typical Workflow
| Action | Command |
| -------------- | ------------------------------ |
| Get updates | `memory_sync(action="pull")` |
| Check changes | `memory_sync(action="status")` |
| Upload changes | `memory_sync(action="push")` |
## Troubleshooting
| Error | Solution |
| ----------------- | --------------------------------------- |
| Non-fast-forward | Pull first, then push |
| Conflicts | Manual resolution via bash git commands |
| Not a git repo | Run `memory_init(force=true)` |
| Permission denied | Check SSH keys or repo URL |
## Related Skills
- `memory-management` - Read and write files
- `memory-init` - Setup repository

View file

@ -1,732 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
import { keyHint } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import type { MemoryFrontmatter, MemoryMdSettings } from "./memory-md.js";
import {
getCurrentDate,
getMemoryDir,
gitExec,
listMemoryFiles,
readMemoryFile,
syncRepository,
writeMemoryFile,
} from "./memory-md.js";
function renderWithExpandHint(
text: string,
theme: Theme,
lineCount: number,
): Text {
const remaining = lineCount - 1;
if (remaining > 0) {
text +=
"\n" +
theme.fg("muted", `... (${remaining} more lines,`) +
" " +
keyHint("expandTools", "to expand") +
theme.fg("muted", ")");
}
return new Text(text, 0, 0);
}
export function registerMemorySync(
pi: ExtensionAPI,
settings: MemoryMdSettings,
isRepoInitialized: { value: boolean },
): void {
pi.registerTool({
name: "memory_sync",
label: "Memory Sync",
description: "Synchronize memory repository with git (pull/push/status)",
parameters: Type.Object({
action: Type.Union(
[Type.Literal("pull"), Type.Literal("push"), Type.Literal("status")],
{
description: "Action to perform",
},
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { action } = params as { action: "pull" | "push" | "status" };
const localPath = settings.localPath!;
const memoryDir = getMemoryDir(settings, ctx);
const coreUserDir = path.join(memoryDir, "core", "user");
if (action === "status") {
const initialized =
isRepoInitialized.value && fs.existsSync(coreUserDir);
if (!initialized) {
return {
content: [
{
type: "text",
text: "Memory repository not initialized. Use memory_init to set up.",
},
],
details: { initialized: false },
};
}
const result = await gitExec(pi, localPath, "status", "--porcelain");
const dirty = result.stdout.trim().length > 0;
return {
content: [
{
type: "text",
text: dirty
? `Changes detected:\n${result.stdout}`
: "No uncommitted changes",
},
],
details: { initialized: true, dirty },
};
}
if (action === "pull") {
const result = await syncRepository(pi, settings, isRepoInitialized);
return {
content: [{ type: "text", text: result.message }],
details: { success: result.success },
};
}
if (action === "push") {
const statusResult = await gitExec(
pi,
localPath,
"status",
"--porcelain",
);
const hasChanges = statusResult.stdout.trim().length > 0;
if (hasChanges) {
await gitExec(pi, localPath, "add", ".");
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
const commitMessage = `Update memory - ${timestamp}`;
const commitResult = await gitExec(
pi,
localPath,
"commit",
"-m",
commitMessage,
);
if (!commitResult.success) {
return {
content: [
{ type: "text", text: "Commit failed - nothing pushed" },
],
details: { success: false },
};
}
}
const result = await gitExec(pi, localPath, "push");
if (result.success) {
return {
content: [
{
type: "text",
text: hasChanges
? `Committed and pushed changes to repository`
: `No changes to commit, repository up to date`,
},
],
details: { success: true, committed: hasChanges },
};
}
return {
content: [{ type: "text", text: "Push failed - check git status" }],
details: { success: false },
};
}
return {
content: [{ type: "text", text: "Unknown action" }],
details: {},
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("memory_sync "));
text += theme.fg("accent", args.action);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
const content = result.content[0];
if (content?.type !== "text") {
return new Text(theme.fg("dim", "Empty result"), 0, 0);
}
if (isPartial) {
return new Text(theme.fg("warning", "Syncing..."), 0, 0);
}
if (!expanded) {
const lines = content.text.split("\n");
const summary = lines[0];
return renderWithExpandHint(
theme.fg("success", summary),
theme,
lines.length,
);
}
return new Text(theme.fg("toolOutput", content.text), 0, 0);
},
});
}
export function registerMemoryRead(
pi: ExtensionAPI,
settings: MemoryMdSettings,
): void {
pi.registerTool({
name: "memory_read",
label: "Memory Read",
description: "Read a memory file by path",
parameters: Type.Object({
path: Type.String({
description:
"Relative path to memory file (e.g., 'core/user/identity.md')",
}),
}) as any,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { path: relPath } = params as { path: string };
const memoryDir = getMemoryDir(settings, ctx);
const fullPath = path.join(memoryDir, relPath);
const memory = readMemoryFile(fullPath);
if (!memory) {
return {
content: [
{ type: "text", text: `Failed to read memory file: ${relPath}` },
],
details: { error: true },
};
}
return {
content: [
{
type: "text",
text: `# ${memory.frontmatter.description}\n\nTags: ${memory.frontmatter.tags?.join(", ") || "none"}\n\n${memory.content}`,
},
],
details: { frontmatter: memory.frontmatter },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("memory_read "));
text += theme.fg("accent", args.path);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
const details = result.details as
| { error?: boolean; frontmatter?: MemoryFrontmatter }
| undefined;
const content = result.content[0];
if (isPartial) {
return new Text(theme.fg("warning", "Reading..."), 0, 0);
}
if (details?.error) {
const text = content?.type === "text" ? content.text : "Error";
return new Text(theme.fg("error", text), 0, 0);
}
const desc = details?.frontmatter?.description || "Memory file";
const tags = details?.frontmatter?.tags?.join(", ") || "none";
const text = content?.type === "text" ? content.text : "";
if (!expanded) {
const lines = text.split("\n");
const summary = `${theme.fg("success", desc)}\n${theme.fg("muted", `Tags: ${tags}`)}`;
return renderWithExpandHint(summary, theme, lines.length + 2);
}
let resultText = theme.fg("success", desc);
resultText += `\n${theme.fg("muted", `Tags: ${tags}`)}`;
if (text) {
resultText += `\n${theme.fg("toolOutput", text)}`;
}
return new Text(resultText, 0, 0);
},
});
}
export function registerMemoryWrite(
pi: ExtensionAPI,
settings: MemoryMdSettings,
): void {
pi.registerTool({
name: "memory_write",
label: "Memory Write",
description: "Create or update a memory file with YAML frontmatter",
parameters: Type.Object({
path: Type.String({
description:
"Relative path to memory file (e.g., 'core/user/identity.md')",
}),
content: Type.String({ description: "Markdown content" }),
description: Type.String({ description: "Description for frontmatter" }),
tags: Type.Optional(Type.Array(Type.String())),
}) as any,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const {
path: relPath,
content,
description,
tags,
} = params as {
path: string;
content: string;
description: string;
tags?: string[];
};
const memoryDir = getMemoryDir(settings, ctx);
const fullPath = path.join(memoryDir, relPath);
const existing = readMemoryFile(fullPath);
const existingFrontmatter = existing?.frontmatter || { description };
const frontmatter: MemoryFrontmatter = {
...existingFrontmatter,
description,
updated: getCurrentDate(),
...(tags && { tags }),
};
writeMemoryFile(fullPath, content, frontmatter);
return {
content: [{ type: "text", text: `Memory file written: ${relPath}` }],
details: { path: fullPath, frontmatter },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("memory_write "));
text += theme.fg("accent", args.path);
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
const content = result.content[0];
if (content?.type !== "text") {
return new Text(theme.fg("dim", "Empty result"), 0, 0);
}
if (isPartial) {
return new Text(theme.fg("warning", "Writing..."), 0, 0);
}
if (!expanded) {
const details = result.details as
| { frontmatter?: MemoryFrontmatter }
| undefined;
const lineCount = details?.frontmatter ? 3 : 1;
return renderWithExpandHint(
theme.fg("success", `Written: ${content.text}`),
theme,
lineCount,
);
}
const details = result.details as
| { path?: string; frontmatter?: MemoryFrontmatter }
| undefined;
let text = theme.fg("success", content.text);
if (details?.frontmatter) {
const fm = details.frontmatter;
text += `\n${theme.fg("muted", `Description: ${fm.description}`)}`;
if (fm.tags) {
text += `\n${theme.fg("muted", `Tags: ${fm.tags.join(", ")}`)}`;
}
}
return new Text(text, 0, 0);
},
});
}
export function registerMemoryList(
pi: ExtensionAPI,
settings: MemoryMdSettings,
): void {
pi.registerTool({
name: "memory_list",
label: "Memory List",
description: "List all memory files in the repository",
parameters: Type.Object({
directory: Type.Optional(
Type.String({ description: "Filter by directory (e.g., 'core/user')" }),
),
}) as any,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { directory } = params as { directory?: string };
const memoryDir = getMemoryDir(settings, ctx);
const searchDir = directory ? path.join(memoryDir, directory) : memoryDir;
const files = listMemoryFiles(searchDir);
const relPaths = files.map((f) => path.relative(memoryDir, f));
return {
content: [
{
type: "text",
text: `Memory files (${relPaths.length}):\n\n${relPaths.map((p) => ` - ${p}`).join("\n")}`,
},
],
details: { files: relPaths, count: relPaths.length },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("memory_list"));
if (args.directory) {
text += ` ${theme.fg("accent", args.directory)}`;
}
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
const details = result.details as { count?: number } | undefined;
if (isPartial) {
return new Text(theme.fg("warning", "Listing..."), 0, 0);
}
if (!expanded) {
const count = details?.count ?? 0;
const content = result.content[0];
const lines = content?.type === "text" ? content.text.split("\n") : [];
return renderWithExpandHint(
theme.fg("success", `${count} memory files`),
theme,
lines.length,
);
}
const content = result.content[0];
const text = content?.type === "text" ? content.text : "";
return new Text(theme.fg("toolOutput", text), 0, 0);
},
});
}
export function registerMemorySearch(
pi: ExtensionAPI,
settings: MemoryMdSettings,
): void {
pi.registerTool({
name: "memory_search",
label: "Memory Search",
description: "Search memory files by content or tags",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
searchIn: Type.Union(
[
Type.Literal("content"),
Type.Literal("tags"),
Type.Literal("description"),
],
{
description: "Where to search",
},
),
}) as any,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { query, searchIn } = params as {
query: string;
searchIn: "content" | "tags" | "description";
};
const memoryDir = getMemoryDir(settings, ctx);
const files = listMemoryFiles(memoryDir);
const results: Array<{ path: string; match: string }> = [];
const queryLower = query.toLowerCase();
for (const filePath of files) {
const memory = readMemoryFile(filePath);
if (!memory) continue;
const relPath = path.relative(memoryDir, filePath);
const { frontmatter, content } = memory;
if (searchIn === "content") {
if (content.toLowerCase().includes(queryLower)) {
const lines = content.split("\n");
const matchLine = lines.find((line) =>
line.toLowerCase().includes(queryLower),
);
results.push({
path: relPath,
match: matchLine || content.substring(0, 100),
});
}
} else if (searchIn === "tags") {
if (
frontmatter.tags?.some((tag) =>
tag.toLowerCase().includes(queryLower),
)
) {
results.push({
path: relPath,
match: `Tags: ${frontmatter.tags?.join(", ")}`,
});
}
} else if (searchIn === "description") {
if (frontmatter.description.toLowerCase().includes(queryLower)) {
results.push({ path: relPath, match: frontmatter.description });
}
}
}
return {
content: [
{
type: "text",
text: `Found ${results.length} result(s):\n\n${results.map((r) => ` ${r.path}\n ${r.match}`).join("\n\n")}`,
},
],
details: { results, count: results.length },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("memory_search "));
text += theme.fg("accent", `"${args.query}"`);
text += ` ${theme.fg("muted", args.searchIn)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
const details = result.details as { count?: number } | undefined;
if (isPartial) {
return new Text(theme.fg("warning", "Searching..."), 0, 0);
}
if (!expanded) {
const count = details?.count ?? 0;
const content = result.content[0];
const lines = content?.type === "text" ? content.text.split("\n") : [];
return renderWithExpandHint(
theme.fg("success", `${count} result(s)`),
theme,
lines.length,
);
}
const content = result.content[0];
const text = content?.type === "text" ? content.text : "";
return new Text(theme.fg("toolOutput", text), 0, 0);
},
});
}
export function registerMemoryInit(
pi: ExtensionAPI,
settings: MemoryMdSettings,
isRepoInitialized: { value: boolean },
): void {
pi.registerTool({
name: "memory_init",
label: "Memory Init",
description:
"Initialize memory repository (clone or create initial structure)",
parameters: Type.Object({
force: Type.Optional(
Type.Boolean({ description: "Reinitialize even if already set up" }),
),
}) as any,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { force = false } = params as { force?: boolean };
if (isRepoInitialized.value && !force) {
return {
content: [
{
type: "text",
text: "Memory repository already initialized. Use force: true to reinitialize.",
},
],
details: { initialized: true },
};
}
const result = await syncRepository(pi, settings, isRepoInitialized);
return {
content: [
{
type: "text",
text: result.success
? `Memory repository initialized:\n${result.message}\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}`
: `Initialization failed: ${result.message}`,
},
],
details: { success: result.success },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("memory_init"));
if (args.force) {
text += ` ${theme.fg("warning", "--force")}`;
}
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
const details = result.details as
| { initialized?: boolean; success?: boolean }
| undefined;
const content = result.content[0];
if (isPartial) {
return new Text(theme.fg("warning", "Initializing..."), 0, 0);
}
if (details?.initialized) {
return new Text(theme.fg("muted", "Already initialized"), 0, 0);
}
if (!expanded) {
const success = details?.success;
const contentText = content?.type === "text" ? content.text : "";
const lines = contentText.split("\n");
const summary = success
? theme.fg("success", "Initialized")
: theme.fg("error", "Initialization failed");
return renderWithExpandHint(summary, theme, lines.length);
}
const text = content?.type === "text" ? content.text : "";
return new Text(theme.fg("toolOutput", text), 0, 0);
},
});
}
export function registerMemoryCheck(
pi: ExtensionAPI,
settings: MemoryMdSettings,
): void {
pi.registerTool({
name: "memory_check",
label: "Memory Check",
description: "Check current project memory folder structure",
parameters: Type.Object({}) as any,
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const memoryDir = getMemoryDir(settings, ctx);
if (!fs.existsSync(memoryDir)) {
return {
content: [
{
type: "text",
text: `Memory directory not found: ${memoryDir}\n\nProject memory may not be initialized yet.`,
},
],
details: { exists: false },
};
}
const { execSync } = await import("node:child_process");
let treeOutput = "";
try {
treeOutput = execSync(`tree -L 3 -I "node_modules" "${memoryDir}"`, {
encoding: "utf-8",
});
} catch {
try {
treeOutput = execSync(
`find "${memoryDir}" -type d -not -path "*/node_modules/*" | head -20`,
{
encoding: "utf-8",
},
);
} catch {
treeOutput =
"Unable to generate directory tree. Please check permissions.";
}
}
const files = listMemoryFiles(memoryDir);
const relPaths = files.map((f) => path.relative(memoryDir, f));
return {
content: [
{
type: "text",
text: `Memory directory structure for project: ${path.basename(ctx.cwd)}\n\nPath: ${memoryDir}\n\n${treeOutput}\n\nMemory files (${relPaths.length}):\n${relPaths.map((p) => ` ${p}`).join("\n")}`,
},
],
details: { path: memoryDir, fileCount: relPaths.length },
};
},
renderCall(_args, theme) {
return new Text(theme.fg("toolTitle", theme.bold("memory_check")), 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
const details = result.details as
| { exists?: boolean; path?: string; fileCount?: number }
| undefined;
const content = result.content[0];
if (isPartial) {
return new Text(theme.fg("warning", "Checking..."), 0, 0);
}
if (!expanded) {
const exists = details?.exists ?? true;
const fileCount = details?.fileCount ?? 0;
const contentText = content?.type === "text" ? content.text : "";
const lines = contentText.split("\n");
const summary = exists
? theme.fg("success", `Structure: ${fileCount} files`)
: theme.fg("error", "Not initialized");
return renderWithExpandHint(summary, theme, lines.length);
}
const text = content?.type === "text" ? content.text : "";
return new Text(theme.fg("toolOutput", text), 0, 0);
},
});
}
export function registerAllTools(
pi: ExtensionAPI,
settings: MemoryMdSettings,
isRepoInitialized: { value: boolean },
): void {
registerMemorySync(pi, settings, isRepoInitialized);
registerMemoryRead(pi, settings);
registerMemoryWrite(pi, settings);
registerMemoryList(pi, settings);
registerMemorySearch(pi, settings);
registerMemoryInit(pi, settings, isRepoInitialized);
registerMemoryCheck(pi, settings);
}