mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-20 22:02:39 +00:00
Make memory runtime-native for companion chat
Replace the old project-scoped file memory plumbing with runtime-native conversational memory and remove obsolete pi-memory-md shipping/wiring. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
9765576c0a
commit
5c389efcf9
10 changed files with 1859 additions and 1536 deletions
|
|
@ -60,7 +60,7 @@ This installer:
|
||||||
Preinstalled package sources are:
|
Preinstalled package sources are:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
["npm:@e9n/pi-channels", "npm:pi-memory-md", "npm:pi-teams"]
|
["npm:@e9n/pi-channels", "npm:pi-teams"]
|
||||||
```
|
```
|
||||||
|
|
||||||
If `npm` is available, it also installs these packages during install.
|
If `npm` is available, it also installs these packages during install.
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,15 @@ import {
|
||||||
type ToolHtmlRenderer,
|
type ToolHtmlRenderer,
|
||||||
} from "./export-html/index.js";
|
} from "./export-html/index.js";
|
||||||
import { createToolHtmlRenderer } from "./export-html/tool-renderer.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 {
|
import {
|
||||||
type ContextUsage,
|
type ContextUsage,
|
||||||
type ExtensionCommandContextActions,
|
type ExtensionCommandContextActions,
|
||||||
|
|
@ -337,6 +346,8 @@ export class AgentSession {
|
||||||
|
|
||||||
// Base system prompt (without extension appends) - used to apply fresh appends each turn
|
// Base system prompt (without extension appends) - used to apply fresh appends each turn
|
||||||
private _baseSystemPrompt = "";
|
private _baseSystemPrompt = "";
|
||||||
|
private _memoryManager: RuntimeMemoryManager;
|
||||||
|
private _memoryWriteQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
constructor(config: AgentSessionConfig) {
|
constructor(config: AgentSessionConfig) {
|
||||||
this.agent = config.agent;
|
this.agent = config.agent;
|
||||||
|
|
@ -350,6 +361,10 @@ export class AgentSession {
|
||||||
this._extensionRunnerRef = config.extensionRunnerRef;
|
this._extensionRunnerRef = config.extensionRunnerRef;
|
||||||
this._initialActiveToolNames = config.initialActiveToolNames;
|
this._initialActiveToolNames = config.initialActiveToolNames;
|
||||||
this._baseToolsOverride = config.baseToolsOverride;
|
this._baseToolsOverride = config.baseToolsOverride;
|
||||||
|
this._memoryManager = new RuntimeMemoryManager({
|
||||||
|
sessionManager: this.sessionManager,
|
||||||
|
settingsManager: this.settingsManager,
|
||||||
|
});
|
||||||
|
|
||||||
// Always subscribe to agent events for internal handling
|
// Always subscribe to agent events for internal handling
|
||||||
// (session persistence, extensions, auto-compaction, retry logic)
|
// (session persistence, extensions, auto-compaction, retry logic)
|
||||||
|
|
@ -499,6 +514,16 @@ export class AgentSession {
|
||||||
this._resolveRetry();
|
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
|
// Check auto-retry and auto-compaction after agent completes
|
||||||
|
|
@ -513,6 +538,10 @@ export class AgentSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._checkCompaction(msg);
|
await this._checkCompaction(msg);
|
||||||
|
|
||||||
|
if (msg.stopReason !== "error") {
|
||||||
|
this._enqueueMemoryPromotion(event.messages);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -667,6 +696,9 @@ export class AgentSession {
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this._disconnectFromAgent();
|
this._disconnectFromAgent();
|
||||||
this._eventListeners = [];
|
this._eventListeners = [];
|
||||||
|
void this._memoryWriteQueue.finally(() => {
|
||||||
|
this._memoryManager.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -804,6 +836,82 @@ export class AgentSession {
|
||||||
return this._resourceLoader.getPrompts().prompts;
|
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 _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(
|
private _normalizePromptSnippet(
|
||||||
text: string | undefined,
|
text: string | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|
|
||||||
|
|
@ -1,963 +0,0 @@
|
||||||
import {
|
|
||||||
type Dirent,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
renameSync,
|
|
||||||
statSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
||||||
import { execCommand } from "../exec.js";
|
|
||||||
import type { SettingsManager } from "../settings-manager.js";
|
|
||||||
import { parseFrontmatter } from "../../utils/frontmatter.js";
|
|
||||||
import { HttpError } from "./internal-types.js";
|
|
||||||
|
|
||||||
export interface MemoryFrontmatter {
|
|
||||||
description: string;
|
|
||||||
limit?: number;
|
|
||||||
tags?: string[];
|
|
||||||
created?: string;
|
|
||||||
updated?: 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 MemoryStatus {
|
|
||||||
enabled: boolean;
|
|
||||||
cwd: string;
|
|
||||||
project: string;
|
|
||||||
directory: string;
|
|
||||||
localPath: string;
|
|
||||||
repoUrl: string | null;
|
|
||||||
repoConfigured: boolean;
|
|
||||||
repositoryReady: boolean;
|
|
||||||
initialized: boolean;
|
|
||||||
dirty: boolean | null;
|
|
||||||
fileCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryFileSummary {
|
|
||||||
path: string;
|
|
||||||
description: string | null;
|
|
||||||
tags: string[];
|
|
||||||
created?: string;
|
|
||||||
updated?: string;
|
|
||||||
valid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryFileRecord {
|
|
||||||
path: string;
|
|
||||||
frontmatter: MemoryFrontmatter;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemorySearchResult {
|
|
||||||
path: string;
|
|
||||||
match: string;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemorySyncResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
configured: boolean;
|
|
||||||
initialized: boolean;
|
|
||||||
dirty: boolean | null;
|
|
||||||
updated?: boolean;
|
|
||||||
committed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MemorySearchScope = "content" | "tags" | "description";
|
|
||||||
export type MemorySyncAction = "pull" | "push" | "status";
|
|
||||||
|
|
||||||
const DEFAULT_LOCAL_PATH = join(homedir(), ".pi", "memory-md");
|
|
||||||
const DEFAULT_MEMORY_SETTINGS: MemoryMdSettings = {
|
|
||||||
enabled: true,
|
|
||||||
repoUrl: "",
|
|
||||||
localPath: DEFAULT_LOCAL_PATH,
|
|
||||||
autoSync: { onSessionStart: true },
|
|
||||||
injection: "message-append",
|
|
||||||
systemPrompt: {
|
|
||||||
maxTokens: 10000,
|
|
||||||
includeProjects: ["current"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getCurrentDate(): string {
|
|
||||||
return new Date().toISOString().split("T")[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLegacyProjectDirName(cwd: string): string {
|
|
||||||
return basename(cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProjectDirName(cwd: string): string {
|
|
||||||
const projectName = getLegacyProjectDirName(cwd);
|
|
||||||
const hash = createHash("sha256")
|
|
||||||
.update(resolve(cwd))
|
|
||||||
.digest("hex")
|
|
||||||
.slice(0, 12);
|
|
||||||
return `${projectName}-${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemoryDirCandidates(
|
|
||||||
settings: MemoryMdSettings,
|
|
||||||
cwd: string,
|
|
||||||
): {
|
|
||||||
preferred: string;
|
|
||||||
legacy: string;
|
|
||||||
} {
|
|
||||||
const basePath = settings.localPath ?? DEFAULT_LOCAL_PATH;
|
|
||||||
return {
|
|
||||||
preferred: join(basePath, getProjectDirName(cwd)),
|
|
||||||
legacy: join(basePath, getLegacyProjectDirName(cwd)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateLegacyMemoryDir(preferred: string, legacy: string): string {
|
|
||||||
try {
|
|
||||||
renameSync(legacy, preferred);
|
|
||||||
return preferred;
|
|
||||||
} catch {
|
|
||||||
return legacy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePathSeparators(value: string): string {
|
|
||||||
return value.replaceAll("\\", "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandPath(value: string): string {
|
|
||||||
if (!value.startsWith("~")) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return join(homedir(), value.slice(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readScopedMemorySettings(
|
|
||||||
settings: Record<string, unknown>,
|
|
||||||
): MemoryMdSettings {
|
|
||||||
const scoped = asRecord(settings["pi-memory-md"]);
|
|
||||||
return scoped ? (scoped as MemoryMdSettings) : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemorySettings(settingsManager: SettingsManager): MemoryMdSettings {
|
|
||||||
const globalSettings = readScopedMemorySettings(
|
|
||||||
settingsManager.getGlobalSettings() as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
const projectSettings = readScopedMemorySettings(
|
|
||||||
settingsManager.getProjectSettings() as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
const merged: MemoryMdSettings = {
|
|
||||||
...DEFAULT_MEMORY_SETTINGS,
|
|
||||||
...globalSettings,
|
|
||||||
...projectSettings,
|
|
||||||
autoSync: {
|
|
||||||
...DEFAULT_MEMORY_SETTINGS.autoSync,
|
|
||||||
...globalSettings.autoSync,
|
|
||||||
...projectSettings.autoSync,
|
|
||||||
},
|
|
||||||
systemPrompt: {
|
|
||||||
...DEFAULT_MEMORY_SETTINGS.systemPrompt,
|
|
||||||
...globalSettings.systemPrompt,
|
|
||||||
...projectSettings.systemPrompt,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (merged.localPath) {
|
|
||||||
merged.localPath = expandPath(merged.localPath);
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemoryDir(settings: MemoryMdSettings, cwd: string): string {
|
|
||||||
const { preferred, legacy } = getMemoryDirCandidates(settings, cwd);
|
|
||||||
if (existsSync(preferred)) {
|
|
||||||
return preferred;
|
|
||||||
}
|
|
||||||
if (existsSync(legacy)) {
|
|
||||||
return migrateLegacyMemoryDir(preferred, legacy);
|
|
||||||
}
|
|
||||||
return preferred;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProjectRepoPath(settings: MemoryMdSettings, cwd: string): string {
|
|
||||||
const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH;
|
|
||||||
return normalizePathSeparators(relative(localPath, getMemoryDir(settings, cwd)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateFrontmatter(frontmatter: Record<string, unknown>): {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (
|
|
||||||
typeof frontmatter.description !== "string" ||
|
|
||||||
frontmatter.description.trim().length === 0
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Frontmatter must contain a non-empty description",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
frontmatter.limit !== undefined &&
|
|
||||||
(typeof frontmatter.limit !== "number" || frontmatter.limit <= 0)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Frontmatter limit must be a positive number",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (frontmatter.tags !== undefined) {
|
|
||||||
if (!Array.isArray(frontmatter.tags)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Frontmatter tags must be an array of strings",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
frontmatter.tags.some((tag) => typeof tag !== "string" || tag.length === 0)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Frontmatter tags must contain only non-empty strings",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFrontmatter(frontmatter: Record<string, unknown>): MemoryFrontmatter {
|
|
||||||
return {
|
|
||||||
description: frontmatter.description as string,
|
|
||||||
...(typeof frontmatter.limit === "number"
|
|
||||||
? { limit: frontmatter.limit }
|
|
||||||
: {}),
|
|
||||||
...(Array.isArray(frontmatter.tags)
|
|
||||||
? {
|
|
||||||
tags: frontmatter.tags.filter(
|
|
||||||
(tag): tag is string => typeof tag === "string",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(typeof frontmatter.created === "string"
|
|
||||||
? { created: frontmatter.created }
|
|
||||||
: {}),
|
|
||||||
...(typeof frontmatter.updated === "string"
|
|
||||||
? { updated: frontmatter.updated }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMemoryFile(filePath: string): MemoryFileRecord | null {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(filePath, "utf8");
|
|
||||||
const parsed = parseFrontmatter<Record<string, unknown>>(content);
|
|
||||||
const validation = validateFrontmatter(parsed.frontmatter);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
path: filePath,
|
|
||||||
frontmatter: normalizeFrontmatter(parsed.frontmatter),
|
|
||||||
content: parsed.body,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeYamlString(value: string): string {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeFrontmatter(frontmatter: MemoryFrontmatter): string {
|
|
||||||
const lines = ["---", `description: ${escapeYamlString(frontmatter.description)}`];
|
|
||||||
if (typeof frontmatter.limit === "number") {
|
|
||||||
lines.push(`limit: ${frontmatter.limit}`);
|
|
||||||
}
|
|
||||||
if (frontmatter.tags && frontmatter.tags.length > 0) {
|
|
||||||
lines.push("tags:");
|
|
||||||
for (const tag of frontmatter.tags) {
|
|
||||||
lines.push(` - ${escapeYamlString(tag)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (frontmatter.created) {
|
|
||||||
lines.push(`created: ${escapeYamlString(frontmatter.created)}`);
|
|
||||||
}
|
|
||||||
if (frontmatter.updated) {
|
|
||||||
lines.push(`updated: ${escapeYamlString(frontmatter.updated)}`);
|
|
||||||
}
|
|
||||||
lines.push("---");
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeMemoryFile(
|
|
||||||
filePath: string,
|
|
||||||
content: string,
|
|
||||||
frontmatter: MemoryFrontmatter,
|
|
||||||
): void {
|
|
||||||
mkdirSync(dirname(filePath), { recursive: true });
|
|
||||||
const normalizedContent = content.replace(/\r\n/g, "\n");
|
|
||||||
const body = normalizedContent.endsWith("\n")
|
|
||||||
? normalizedContent
|
|
||||||
: `${normalizedContent}\n`;
|
|
||||||
const output = `${serializeFrontmatter(frontmatter)}\n\n${body}`;
|
|
||||||
writeFileSync(filePath, output, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function listMemoryFiles(memoryDir: string): string[] {
|
|
||||||
if (!existsSync(memoryDir)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const files: string[] = [];
|
|
||||||
const stack = [memoryDir];
|
|
||||||
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const currentDir = stack.pop();
|
|
||||||
if (!currentDir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let entries: Dirent[];
|
|
||||||
try {
|
|
||||||
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = join(currentDir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
stack.push(fullPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files.sort((left, right) => left.localeCompare(right));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDirectoryStructure(memoryDir: string): void {
|
|
||||||
const directories = [
|
|
||||||
join(memoryDir, "core", "user"),
|
|
||||||
join(memoryDir, "core", "project"),
|
|
||||||
join(memoryDir, "reference"),
|
|
||||||
];
|
|
||||||
for (const directory of directories) {
|
|
||||||
mkdirSync(directory, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDefaultFiles(memoryDir: string): void {
|
|
||||||
const identityPath = join(memoryDir, "core", "user", "identity.md");
|
|
||||||
if (!existsSync(identityPath)) {
|
|
||||||
writeMemoryFile(
|
|
||||||
identityPath,
|
|
||||||
"# User Identity\n\nCustomize this file with your information.",
|
|
||||||
{
|
|
||||||
description: "User identity and background",
|
|
||||||
tags: ["user", "identity"],
|
|
||||||
created: getCurrentDate(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferencesPath = join(memoryDir, "core", "user", "prefer.md");
|
|
||||||
if (!existsSync(preferencesPath)) {
|
|
||||||
writeMemoryFile(
|
|
||||||
preferencesPath,
|
|
||||||
"# 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 resolveWithinMemoryDir(memoryDir: string, relativePath: string): string {
|
|
||||||
const trimmed = relativePath.trim();
|
|
||||||
if (trimmed.length === 0) {
|
|
||||||
throw new HttpError(400, "Memory path is required");
|
|
||||||
}
|
|
||||||
const resolvedPath = resolve(memoryDir, trimmed);
|
|
||||||
const resolvedRoot = resolve(memoryDir);
|
|
||||||
if (
|
|
||||||
resolvedPath !== resolvedRoot &&
|
|
||||||
!resolvedPath.startsWith(`${resolvedRoot}${sep}`)
|
|
||||||
) {
|
|
||||||
throw new HttpError(400, `Memory path escapes root: ${relativePath}`);
|
|
||||||
}
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeMemoryFile(
|
|
||||||
memoryDir: string,
|
|
||||||
filePath: string,
|
|
||||||
): MemoryFileSummary {
|
|
||||||
const relativePath = normalizePathSeparators(relative(memoryDir, filePath));
|
|
||||||
const memoryFile = readMemoryFile(filePath);
|
|
||||||
if (!memoryFile) {
|
|
||||||
return {
|
|
||||||
path: relativePath,
|
|
||||||
description: null,
|
|
||||||
tags: [],
|
|
||||||
valid: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
path: relativePath,
|
|
||||||
description: memoryFile.frontmatter.description,
|
|
||||||
tags: memoryFile.frontmatter.tags ?? [],
|
|
||||||
created: memoryFile.frontmatter.created,
|
|
||||||
updated: memoryFile.frontmatter.updated,
|
|
||||||
valid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMemoryFileOrThrow(
|
|
||||||
memoryDir: string,
|
|
||||||
relativePath: string,
|
|
||||||
): MemoryFileRecord {
|
|
||||||
const fullPath = resolveWithinMemoryDir(memoryDir, relativePath);
|
|
||||||
if (!existsSync(fullPath)) {
|
|
||||||
throw new HttpError(404, `Memory file not found: ${relativePath}`);
|
|
||||||
}
|
|
||||||
const memoryFile = readMemoryFile(fullPath);
|
|
||||||
if (!memoryFile) {
|
|
||||||
throw new HttpError(422, `Invalid memory file: ${relativePath}`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...memoryFile,
|
|
||||||
path: normalizePathSeparators(relative(memoryDir, fullPath)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runGit(
|
|
||||||
cwd: string,
|
|
||||||
...args: string[]
|
|
||||||
): Promise<{ success: boolean; stdout: string; stderr: string }> {
|
|
||||||
const result = await execCommand("git", args, cwd, { timeout: 30_000 });
|
|
||||||
return {
|
|
||||||
success: result.code === 0 && !result.killed,
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRepoName(settings: MemoryMdSettings): string {
|
|
||||||
if (!settings.repoUrl) {
|
|
||||||
return "memory-md";
|
|
||||||
}
|
|
||||||
const match = settings.repoUrl.match(/\/([^/]+?)(\.git)?$/);
|
|
||||||
return match?.[1] ?? "memory-md";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getGitHead(cwd: string): Promise<string | null> {
|
|
||||||
const result = await runGit(cwd, "rev-parse", "HEAD");
|
|
||||||
if (!result.success) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const head = result.stdout.trim();
|
|
||||||
return head.length > 0 ? head : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRepositoryDirtyState(
|
|
||||||
localPath: string,
|
|
||||||
projectPath?: string,
|
|
||||||
): Promise<boolean | null> {
|
|
||||||
if (!existsSync(join(localPath, ".git"))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const args = projectPath
|
|
||||||
? ["status", "--porcelain", "--", projectPath]
|
|
||||||
: ["status", "--porcelain"];
|
|
||||||
const result = await runGit(localPath, ...args);
|
|
||||||
if (!result.success) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return result.stdout.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncRepository(
|
|
||||||
settings: MemoryMdSettings,
|
|
||||||
): Promise<{ success: boolean; message: string; updated?: boolean }> {
|
|
||||||
const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH;
|
|
||||||
const repoUrl = settings.repoUrl?.trim() ?? "";
|
|
||||||
|
|
||||||
if (!repoUrl) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Memory repo URL is not configured",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(localPath)) {
|
|
||||||
if (!existsSync(join(localPath, ".git"))) {
|
|
||||||
let existingEntries: string[];
|
|
||||||
try {
|
|
||||||
existingEntries = readdirSync(localPath);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `Path exists but is not a directory: ${localPath}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (existingEntries.length === 0) {
|
|
||||||
const cloneIntoEmptyDir = await runGit(localPath, "clone", repoUrl, ".");
|
|
||||||
if (!cloneIntoEmptyDir.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
cloneIntoEmptyDir.stderr.trim() ||
|
|
||||||
"Clone failed. Check repo URL and auth.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Cloned ${getRepoName(settings)} successfully`,
|
|
||||||
updated: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `Directory exists but is not a git repo: ${localPath}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const previousHead = await getGitHead(localPath);
|
|
||||||
const pullResult = await runGit(localPath, "pull", "--rebase", "--autostash");
|
|
||||||
if (!pullResult.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
pullResult.stderr.trim() || "Pull failed. Check repository state.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const currentHead = await getGitHead(localPath);
|
|
||||||
const updated =
|
|
||||||
previousHead !== null &&
|
|
||||||
currentHead !== null &&
|
|
||||||
previousHead !== currentHead;
|
|
||||||
const message =
|
|
||||||
previousHead === null || currentHead === null
|
|
||||||
? `Synchronized ${getRepoName(settings)}`
|
|
||||||
: updated
|
|
||||||
? `Pulled latest changes from ${getRepoName(settings)}`
|
|
||||||
: `${getRepoName(settings)} is already up to date`;
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message,
|
|
||||||
updated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(localPath), { recursive: true });
|
|
||||||
const cloneResult = await runGit(
|
|
||||||
dirname(localPath),
|
|
||||||
"clone",
|
|
||||||
repoUrl,
|
|
||||||
basename(localPath),
|
|
||||||
);
|
|
||||||
if (!cloneResult.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: cloneResult.stderr.trim() || "Clone failed. Check repo URL and auth.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Cloned ${getRepoName(settings)} successfully`,
|
|
||||||
updated: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMemoryStatus(
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
cwd: string,
|
|
||||||
): Promise<MemoryStatus> {
|
|
||||||
const settings = getMemorySettings(settingsManager);
|
|
||||||
const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH;
|
|
||||||
const memoryDir = getMemoryDir(settings, cwd);
|
|
||||||
const initialized = existsSync(join(memoryDir, "core", "user"));
|
|
||||||
const fileCount = listMemoryFiles(memoryDir).length;
|
|
||||||
const dirty = await getRepositoryDirtyState(
|
|
||||||
localPath,
|
|
||||||
getProjectRepoPath(settings, cwd),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
enabled: settings.enabled ?? true,
|
|
||||||
cwd,
|
|
||||||
project: basename(cwd),
|
|
||||||
directory: memoryDir,
|
|
||||||
localPath,
|
|
||||||
repoUrl: settings.repoUrl?.trim() || null,
|
|
||||||
repoConfigured: Boolean(settings.repoUrl?.trim()),
|
|
||||||
repositoryReady: existsSync(join(localPath, ".git")),
|
|
||||||
initialized,
|
|
||||||
dirty,
|
|
||||||
fileCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initializeMemory(
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
cwd: string,
|
|
||||||
options: { force?: boolean } = {},
|
|
||||||
): Promise<{
|
|
||||||
ok: true;
|
|
||||||
created: boolean;
|
|
||||||
message: string;
|
|
||||||
memory: MemoryStatus;
|
|
||||||
}> {
|
|
||||||
const settings = getMemorySettings(settingsManager);
|
|
||||||
const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH;
|
|
||||||
const memoryDir = getMemoryDir(settings, cwd);
|
|
||||||
const initialized = existsSync(join(memoryDir, "core", "user"));
|
|
||||||
|
|
||||||
if (settings.repoUrl?.trim()) {
|
|
||||||
const repositoryReady = existsSync(join(localPath, ".git"));
|
|
||||||
if (!initialized || options.force || !repositoryReady) {
|
|
||||||
const syncResult = await syncRepository(settings);
|
|
||||||
if (!syncResult.success) {
|
|
||||||
throw new HttpError(409, syncResult.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mkdirSync(localPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureDirectoryStructure(memoryDir);
|
|
||||||
ensureDefaultFiles(memoryDir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
created: !initialized,
|
|
||||||
message:
|
|
||||||
settings.repoUrl?.trim()
|
|
||||||
? initialized
|
|
||||||
? "Memory repository refreshed and project memory verified"
|
|
||||||
: "Memory repository initialized for this project"
|
|
||||||
: initialized
|
|
||||||
? "Local memory verified"
|
|
||||||
: "Local memory initialized",
|
|
||||||
memory: await getMemoryStatus(settingsManager, cwd),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listProjectMemoryFiles(
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
cwd: string,
|
|
||||||
directory?: string,
|
|
||||||
): Promise<{
|
|
||||||
directory: string;
|
|
||||||
files: MemoryFileSummary[];
|
|
||||||
}> {
|
|
||||||
const settings = getMemorySettings(settingsManager);
|
|
||||||
const memoryDir = getMemoryDir(settings, cwd);
|
|
||||||
const searchDir = directory
|
|
||||||
? resolveWithinMemoryDir(memoryDir, directory)
|
|
||||||
: memoryDir;
|
|
||||||
const files = listMemoryFiles(searchDir).map((filePath) =>
|
|
||||||
summarizeMemoryFile(memoryDir, filePath),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
directory: directory?.trim() || "",
|
|
||||||
files,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readProjectMemoryFile(
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
cwd: string,
|
|
||||||
relativePath: string,
|
|
||||||
): Promise<{ file: MemoryFileRecord }> {
|
|
||||||
const settings = getMemorySettings(settingsManager);
|
|
||||||
const memoryDir = getMemoryDir(settings, cwd);
|
|
||||||
return {
|
|
||||||
file: readMemoryFileOrThrow(memoryDir, relativePath),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function writeProjectMemoryFile(
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
cwd: string,
|
|
||||||
params: {
|
|
||||||
path: string;
|
|
||||||
content: string;
|
|
||||||
description: string;
|
|
||||||
tags?: string[];
|
|
||||||
},
|
|
||||||
): Promise<{ ok: true; file: MemoryFileRecord }> {
|
|
||||||
const relativePath = params.path.trim();
|
|
||||||
if (!relativePath.endsWith(".md")) {
|
|
||||||
throw new HttpError(400, "Memory files must use the .md extension");
|
|
||||||
}
|
|
||||||
if (params.description.trim().length === 0) {
|
|
||||||
throw new HttpError(400, "Memory description is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = getMemorySettings(settingsManager);
|
|
||||||
const memoryDir = getMemoryDir(settings, cwd);
|
|
||||||
const fullPath = resolveWithinMemoryDir(memoryDir, relativePath);
|
|
||||||
if (existsSync(fullPath) && statSync(fullPath).isDirectory()) {
|
|
||||||
throw new HttpError(400, `Memory path points to a directory: ${relativePath}`);
|
|
||||||
}
|
|
||||||
const existing = existsSync(fullPath) ? readMemoryFile(fullPath) : null;
|
|
||||||
|
|
||||||
const hasTagsInput = params.tags !== undefined;
|
|
||||||
const tags = (params.tags ?? []).map((tag) => tag.trim()).filter(Boolean);
|
|
||||||
const frontmatter: MemoryFrontmatter = {
|
|
||||||
description: params.description.trim(),
|
|
||||||
created: existing?.frontmatter.created ?? getCurrentDate(),
|
|
||||||
updated: getCurrentDate(),
|
|
||||||
...(existing?.frontmatter.limit !== undefined
|
|
||||||
? { limit: existing.frontmatter.limit }
|
|
||||||
: {}),
|
|
||||||
...(hasTagsInput
|
|
||||||
? { tags }
|
|
||||||
: existing?.frontmatter.tags
|
|
||||||
? { tags: existing.frontmatter.tags }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
writeMemoryFile(fullPath, params.content, frontmatter);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
file: {
|
|
||||||
path: normalizePathSeparators(relative(memoryDir, fullPath)),
|
|
||||||
frontmatter,
|
|
||||||
content: params.content.replace(/\r\n/g, "\n"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchProjectMemory(
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
cwd: string,
|
|
||||||
query: string,
|
|
||||||
searchIn: MemorySearchScope,
|
|
||||||
): Promise<{ results: MemorySearchResult[] }> {
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
|
||||||
if (normalizedQuery.length === 0) {
|
|
||||||
throw new HttpError(400, "Memory search query is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = getMemorySettings(settingsManager);
|
|
||||||
const memoryDir = getMemoryDir(settings, cwd);
|
|
||||||
const results: MemorySearchResult[] = [];
|
|
||||||
|
|
||||||
for (const filePath of listMemoryFiles(memoryDir)) {
|
|
||||||
const memoryFile = readMemoryFile(filePath);
|
|
||||||
if (!memoryFile) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const relativePath = normalizePathSeparators(relative(memoryDir, filePath));
|
|
||||||
if (searchIn === "content") {
|
|
||||||
if (!memoryFile.content.toLowerCase().includes(normalizedQuery)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const match =
|
|
||||||
memoryFile.content
|
|
||||||
.split("\n")
|
|
||||||
.find((line) => line.toLowerCase().includes(normalizedQuery)) ??
|
|
||||||
memoryFile.content.slice(0, 120);
|
|
||||||
results.push({
|
|
||||||
path: relativePath,
|
|
||||||
match,
|
|
||||||
description: memoryFile.frontmatter.description,
|
|
||||||
tags: memoryFile.frontmatter.tags ?? [],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchIn === "tags") {
|
|
||||||
const tags = memoryFile.frontmatter.tags ?? [];
|
|
||||||
if (!tags.some((tag) => tag.toLowerCase().includes(normalizedQuery))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
results.push({
|
|
||||||
path: relativePath,
|
|
||||||
match: `Tags: ${tags.join(", ")}`,
|
|
||||||
description: memoryFile.frontmatter.description,
|
|
||||||
tags,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
memoryFile.frontmatter.description.toLowerCase().includes(normalizedQuery)
|
|
||||||
) {
|
|
||||||
results.push({
|
|
||||||
path: relativePath,
|
|
||||||
match: memoryFile.frontmatter.description,
|
|
||||||
description: memoryFile.frontmatter.description,
|
|
||||||
tags: memoryFile.frontmatter.tags ?? [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { results };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncProjectMemory(
|
|
||||||
settingsManager: SettingsManager,
|
|
||||||
cwd: string,
|
|
||||||
action: MemorySyncAction,
|
|
||||||
): Promise<MemorySyncResult> {
|
|
||||||
const settings = getMemorySettings(settingsManager);
|
|
||||||
const localPath = settings.localPath ?? DEFAULT_LOCAL_PATH;
|
|
||||||
const projectPath = getProjectRepoPath(settings, cwd);
|
|
||||||
const configured = Boolean(settings.repoUrl?.trim());
|
|
||||||
const repositoryReady = existsSync(join(localPath, ".git"));
|
|
||||||
const initialized = existsSync(join(getMemoryDir(settings, cwd), "core", "user"));
|
|
||||||
|
|
||||||
if (action === "status") {
|
|
||||||
const dirty = await getRepositoryDirtyState(localPath, projectPath);
|
|
||||||
return {
|
|
||||||
success: repositoryReady && dirty !== null,
|
|
||||||
message: repositoryReady
|
|
||||||
? dirty === null
|
|
||||||
? "Memory repository status is unavailable"
|
|
||||||
: dirty
|
|
||||||
? "Memory repository has uncommitted changes"
|
|
||||||
: "Memory repository is clean"
|
|
||||||
: configured
|
|
||||||
? "Memory repository is not initialized"
|
|
||||||
: "Memory repository is not configured",
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!configured) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Memory repo URL is not configured",
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "pull") {
|
|
||||||
const result = await syncRepository(settings);
|
|
||||||
return {
|
|
||||||
success: result.success,
|
|
||||||
message: result.message,
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty: await getRepositoryDirtyState(localPath, projectPath),
|
|
||||||
updated: result.updated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncResult = await syncRepository(settings);
|
|
||||||
if (!syncResult.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: syncResult.message,
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty: null,
|
|
||||||
updated: syncResult.updated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusResult = await runGit(
|
|
||||||
localPath,
|
|
||||||
"status",
|
|
||||||
"--porcelain",
|
|
||||||
"--",
|
|
||||||
projectPath,
|
|
||||||
);
|
|
||||||
if (!statusResult.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: statusResult.stderr.trim() || "Failed to inspect memory repository",
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasChanges = statusResult.stdout.trim().length > 0;
|
|
||||||
if (hasChanges) {
|
|
||||||
const addResult = await runGit(localPath, "add", "-A", "--", projectPath);
|
|
||||||
if (!addResult.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: addResult.stderr.trim() || "Failed to stage memory changes",
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = new Date()
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[:.]/g, "-")
|
|
||||||
.slice(0, 19);
|
|
||||||
const commitResult = await runGit(
|
|
||||||
localPath,
|
|
||||||
"commit",
|
|
||||||
"-m",
|
|
||||||
`Update memory for ${basename(cwd)} - ${timestamp}`,
|
|
||||||
"--only",
|
|
||||||
"--",
|
|
||||||
projectPath,
|
|
||||||
);
|
|
||||||
if (!commitResult.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
commitResult.stderr.trim() || "Failed to commit memory changes",
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pushResult = await runGit(localPath, "push");
|
|
||||||
return {
|
|
||||||
success: pushResult.success,
|
|
||||||
message: pushResult.success
|
|
||||||
? hasChanges
|
|
||||||
? "Committed and pushed memory changes"
|
|
||||||
: "No memory changes to push"
|
|
||||||
: pushResult.stderr.trim() || "Failed to push memory changes",
|
|
||||||
configured,
|
|
||||||
initialized,
|
|
||||||
dirty: await getRepositoryDirtyState(localPath, projectPath),
|
|
||||||
committed: hasChanges,
|
|
||||||
updated: syncResult.updated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -29,15 +29,6 @@ import type {
|
||||||
HistoryPart,
|
HistoryPart,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import {
|
|
||||||
getMemoryStatus,
|
|
||||||
initializeMemory,
|
|
||||||
listProjectMemoryFiles,
|
|
||||||
readProjectMemoryFile,
|
|
||||||
searchProjectMemory,
|
|
||||||
syncProjectMemory,
|
|
||||||
writeProjectMemoryFile,
|
|
||||||
} from "./memory.js";
|
|
||||||
import type { Settings } from "../settings-manager.js";
|
import type { Settings } from "../settings-manager.js";
|
||||||
import {
|
import {
|
||||||
createVercelStreamListener,
|
createVercelStreamListener,
|
||||||
|
|
@ -306,6 +297,16 @@ export class GatewayRuntime {
|
||||||
return managedSession;
|
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(
|
private async processNext(
|
||||||
managedSession: ManagedGatewaySession,
|
managedSession: ManagedGatewaySession,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -634,106 +635,89 @@ export class GatewayRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "GET" && path === "/memory/status") {
|
if (method === "GET" && path === "/memory/status") {
|
||||||
const memory = await getMemoryStatus(
|
const sessionKey = url.searchParams.get("sessionKey");
|
||||||
this.primarySession.settingsManager,
|
const memorySession = await this.resolveMemorySession(sessionKey);
|
||||||
this.primarySession.sessionManager.getCwd(),
|
const memory = await memorySession.getMemoryStatus();
|
||||||
);
|
|
||||||
this.writeJson(response, 200, { memory });
|
this.writeJson(response, 200, { memory });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/memory/init") {
|
if (method === "GET" && path === "/memory/core") {
|
||||||
const body = await this.readJsonBody(request);
|
const sessionKey = url.searchParams.get("sessionKey");
|
||||||
const result = await initializeMemory(
|
const memorySession = await this.resolveMemorySession(sessionKey);
|
||||||
this.primarySession.settingsManager,
|
const memories = await memorySession.getCoreMemories();
|
||||||
this.primarySession.sessionManager.getCwd(),
|
this.writeJson(response, 200, { memories });
|
||||||
{ force: body.force === true },
|
|
||||||
);
|
|
||||||
this.writeJson(response, 200, result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "GET" && path === "/memory/files") {
|
|
||||||
const directory = url.searchParams.get("directory") ?? undefined;
|
|
||||||
const result = await listProjectMemoryFiles(
|
|
||||||
this.primarySession.settingsManager,
|
|
||||||
this.primarySession.sessionManager.getCwd(),
|
|
||||||
directory,
|
|
||||||
);
|
|
||||||
this.writeJson(response, 200, result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "GET" && path === "/memory/file") {
|
|
||||||
const filePath = url.searchParams.get("path");
|
|
||||||
if (!filePath) {
|
|
||||||
this.writeJson(response, 400, { error: "Missing memory file path" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await readProjectMemoryFile(
|
|
||||||
this.primarySession.settingsManager,
|
|
||||||
this.primarySession.sessionManager.getCwd(),
|
|
||||||
filePath,
|
|
||||||
);
|
|
||||||
this.writeJson(response, 200, result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "POST" && path === "/memory/file") {
|
|
||||||
const body = await this.readJsonBody(request);
|
|
||||||
const filePath = typeof body.path === "string" ? body.path : "";
|
|
||||||
const content = typeof body.content === "string" ? body.content : "";
|
|
||||||
const description =
|
|
||||||
typeof body.description === "string" ? body.description : "";
|
|
||||||
const tags = Array.isArray(body.tags)
|
|
||||||
? body.tags.filter((tag): tag is string => typeof tag === "string")
|
|
||||||
: undefined;
|
|
||||||
const result = await writeProjectMemoryFile(
|
|
||||||
this.primarySession.settingsManager,
|
|
||||||
this.primarySession.sessionManager.getCwd(),
|
|
||||||
{
|
|
||||||
path: filePath,
|
|
||||||
content,
|
|
||||||
description,
|
|
||||||
tags,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.writeJson(response, 200, result);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/memory/search") {
|
if (method === "POST" && path === "/memory/search") {
|
||||||
const body = await this.readJsonBody(request);
|
const body = await this.readJsonBody(request);
|
||||||
const query = typeof body.query === "string" ? body.query : "";
|
const query = typeof body.query === "string" ? body.query : "";
|
||||||
const searchIn =
|
const limit =
|
||||||
body.searchIn === "content" ||
|
typeof body.limit === "number" && Number.isFinite(body.limit)
|
||||||
body.searchIn === "tags" ||
|
? Math.max(1, Math.floor(body.limit))
|
||||||
body.searchIn === "description"
|
: undefined;
|
||||||
? body.searchIn
|
const sessionKey =
|
||||||
: "content";
|
typeof body.sessionKey === "string" ? body.sessionKey : undefined;
|
||||||
const result = await searchProjectMemory(
|
const memorySession = await this.resolveMemorySession(sessionKey);
|
||||||
this.primarySession.settingsManager,
|
const result = await memorySession.searchMemory(query, limit);
|
||||||
this.primarySession.sessionManager.getCwd(),
|
|
||||||
query,
|
|
||||||
searchIn,
|
|
||||||
);
|
|
||||||
this.writeJson(response, 200, result);
|
this.writeJson(response, 200, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "POST" && path === "/memory/sync") {
|
if (method === "POST" && path === "/memory/remember") {
|
||||||
const body = await this.readJsonBody(request);
|
const body = await this.readJsonBody(request);
|
||||||
const action =
|
const content = typeof body.content === "string" ? body.content : "";
|
||||||
body.action === "pull" ||
|
if (!content.trim()) {
|
||||||
body.action === "push" ||
|
this.writeJson(response, 400, { error: "Missing memory content" });
|
||||||
body.action === "status"
|
return;
|
||||||
? body.action
|
}
|
||||||
: "status";
|
const sessionKey =
|
||||||
const result = await syncProjectMemory(
|
typeof body.sessionKey === "string" ? body.sessionKey : undefined;
|
||||||
this.primarySession.settingsManager,
|
const memorySession = await this.resolveMemorySession(sessionKey);
|
||||||
this.primarySession.sessionManager.getCwd(),
|
const memory = await memorySession.rememberMemory({
|
||||||
action,
|
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 sessionKey =
|
||||||
|
typeof body.sessionKey === "string" ? body.sessionKey : undefined;
|
||||||
|
const memorySession = await this.resolveMemorySession(sessionKey);
|
||||||
|
const result = await memorySession.forgetMemory({
|
||||||
|
id:
|
||||||
|
typeof body.id === "number" && Number.isFinite(body.id)
|
||||||
|
? Math.floor(body.id)
|
||||||
|
: undefined,
|
||||||
|
key: typeof body.key === "string" ? body.key : undefined,
|
||||||
|
});
|
||||||
|
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);
|
this.writeJson(response, 200, result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1532
packages/coding-agent/src/core/memory/runtime-memory.ts
Normal file
1532
packages/coding-agent/src/core/memory/runtime-memory.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -315,6 +315,7 @@ export async function createAgentSession(
|
||||||
};
|
};
|
||||||
|
|
||||||
const extensionRunnerRef: { current?: ExtensionRunner } = {};
|
const extensionRunnerRef: { current?: ExtensionRunner } = {};
|
||||||
|
const sessionRef: { current?: AgentSession } = {};
|
||||||
|
|
||||||
agent = new Agent({
|
agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
|
|
@ -326,9 +327,15 @@ export async function createAgentSession(
|
||||||
convertToLlm: convertToLlmWithBlockImages,
|
convertToLlm: convertToLlmWithBlockImages,
|
||||||
sessionId: sessionManager.getSessionId(),
|
sessionId: sessionManager.getSessionId(),
|
||||||
transformContext: async (messages) => {
|
transformContext: async (messages) => {
|
||||||
|
const currentSession = sessionRef.current;
|
||||||
|
let transformedMessages = messages;
|
||||||
|
if (currentSession) {
|
||||||
|
transformedMessages =
|
||||||
|
await currentSession.transformRuntimeContext(transformedMessages);
|
||||||
|
}
|
||||||
const runner = extensionRunnerRef.current;
|
const runner = extensionRunnerRef.current;
|
||||||
if (!runner) return messages;
|
if (!runner) return transformedMessages;
|
||||||
return runner.emitContext(messages);
|
return runner.emitContext(transformedMessages);
|
||||||
},
|
},
|
||||||
steeringMode: settingsManager.getSteeringMode(),
|
steeringMode: settingsManager.getSteeringMode(),
|
||||||
followUpMode: settingsManager.getFollowUpMode(),
|
followUpMode: settingsManager.getFollowUpMode(),
|
||||||
|
|
@ -388,6 +395,7 @@ export async function createAgentSession(
|
||||||
initialActiveToolNames,
|
initialActiveToolNames,
|
||||||
extensionRunnerRef,
|
extensionRunnerRef,
|
||||||
});
|
});
|
||||||
|
sessionRef.current = session;
|
||||||
const extensionsResult = resourceLoader.getExtensions();
|
const extensionsResult = resourceLoader.getExtensions();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ export interface GatewaySettings {
|
||||||
webhook?: GatewayWebhookSettings;
|
webhook?: GatewayWebhookSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompanionMemorySettings {
|
||||||
|
enabled?: boolean;
|
||||||
|
storageDir?: string;
|
||||||
|
maxCoreTokens?: number;
|
||||||
|
maxRecallResults?: number;
|
||||||
|
writer?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
maxTokens?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type TransportSetting = Transport;
|
export type TransportSetting = Transport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -125,6 +136,7 @@ export interface Settings {
|
||||||
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
|
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
|
||||||
markdown?: MarkdownSettings;
|
markdown?: MarkdownSettings;
|
||||||
gateway?: GatewaySettings;
|
gateway?: GatewaySettings;
|
||||||
|
companionMemory?: CompanionMemorySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -62,7 +61,7 @@ export type ParsedFrontmatter = GrayMatterFile<string>["data"];
|
||||||
const DEFAULT_LOCAL_PATH = path.join(os.homedir(), ".pi", "memory-md");
|
const DEFAULT_LOCAL_PATH = path.join(os.homedir(), ".pi", "memory-md");
|
||||||
|
|
||||||
export function getCurrentDate(): string {
|
export function getCurrentDate(): string {
|
||||||
return new Date().toISOString().split("T")[0] ?? "";
|
return new Date().toISOString().split("T")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandPath(p: string): string {
|
function expandPath(p: string): string {
|
||||||
|
|
@ -72,73 +71,12 @@ function expandPath(p: string): string {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLegacyProjectDirName(cwd: string): string {
|
|
||||||
return path.basename(cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProjectDirName(cwd: string): string {
|
|
||||||
const projectName = getLegacyProjectDirName(cwd);
|
|
||||||
const hash = createHash("sha256")
|
|
||||||
.update(path.resolve(cwd))
|
|
||||||
.digest("hex")
|
|
||||||
.slice(0, 12);
|
|
||||||
return `${projectName}-${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function migrateLegacyMemoryDir(
|
|
||||||
preferredDir: string,
|
|
||||||
legacyDir: string,
|
|
||||||
): string {
|
|
||||||
try {
|
|
||||||
fs.renameSync(legacyDir, preferredDir);
|
|
||||||
return preferredDir;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to migrate legacy memory dir:", error);
|
|
||||||
return legacyDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMemoryDir(
|
export function getMemoryDir(
|
||||||
settings: MemoryMdSettings,
|
settings: MemoryMdSettings,
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
): string {
|
): string {
|
||||||
const basePath = settings.localPath || DEFAULT_LOCAL_PATH;
|
const basePath = settings.localPath || DEFAULT_LOCAL_PATH;
|
||||||
const preferredDir = path.join(basePath, getProjectDirName(ctx.cwd));
|
return path.join(basePath, path.basename(ctx.cwd));
|
||||||
if (fs.existsSync(preferredDir)) {
|
|
||||||
return preferredDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyDir = path.join(basePath, getLegacyProjectDirName(ctx.cwd));
|
|
||||||
if (fs.existsSync(legacyDir)) {
|
|
||||||
return migrateLegacyMemoryDir(preferredDir, legacyDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
return preferredDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProjectRepoPath(
|
|
||||||
settings: MemoryMdSettings,
|
|
||||||
ctx: ExtensionContext,
|
|
||||||
): string {
|
|
||||||
const basePath = settings.localPath || DEFAULT_LOCAL_PATH;
|
|
||||||
return path.relative(basePath, getMemoryDir(settings, ctx)).split(path.sep).join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveMemoryPath(
|
|
||||||
settings: MemoryMdSettings,
|
|
||||||
ctx: ExtensionContext,
|
|
||||||
relativePath: string,
|
|
||||||
): string {
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
|
||||||
const resolvedPath = path.resolve(memoryDir, relativePath.trim());
|
|
||||||
const resolvedRoot = path.resolve(memoryDir);
|
|
||||||
if (
|
|
||||||
resolvedPath !== resolvedRoot &&
|
|
||||||
!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)
|
|
||||||
) {
|
|
||||||
throw new Error(`Memory path escapes root: ${relativePath}`);
|
|
||||||
}
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRepoName(settings: MemoryMdSettings): string {
|
function getRepoName(settings: MemoryMdSettings): string {
|
||||||
|
|
@ -147,38 +85,7 @@ function getRepoName(settings: MemoryMdSettings): string {
|
||||||
return match ? match[1] : "memory-md";
|
return match ? match[1] : "memory-md";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGitHead(
|
function loadSettings(): MemoryMdSettings {
|
||||||
pi: ExtensionAPI,
|
|
||||||
cwd: string,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const result = await gitExec(pi, cwd, "rev-parse", "HEAD");
|
|
||||||
if (!result.success) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const head = result.stdout.trim();
|
|
||||||
return head.length > 0 ? head : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadScopedSettings(settingsPath: string): MemoryMdSettings {
|
|
||||||
if (!fs.existsSync(settingsPath)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(settingsPath, "utf-8");
|
|
||||||
const parsed = JSON.parse(content);
|
|
||||||
const scoped = parsed["pi-memory-md"];
|
|
||||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return scoped as MemoryMdSettings;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to load memory settings:", error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSettings(cwd?: string): MemoryMdSettings {
|
|
||||||
const DEFAULT_SETTINGS: MemoryMdSettings = {
|
const DEFAULT_SETTINGS: MemoryMdSettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
repoUrl: "",
|
repoUrl: "",
|
||||||
|
|
@ -197,27 +104,16 @@ function loadSettings(cwd?: string): MemoryMdSettings {
|
||||||
"agent",
|
"agent",
|
||||||
"settings.json",
|
"settings.json",
|
||||||
);
|
);
|
||||||
const projectSettings = cwd
|
if (!fs.existsSync(globalSettings)) {
|
||||||
? path.join(cwd, ".pi", "settings.json")
|
return DEFAULT_SETTINGS;
|
||||||
: undefined;
|
}
|
||||||
const globalLoaded = loadScopedSettings(globalSettings);
|
|
||||||
const projectLoaded = projectSettings
|
try {
|
||||||
? loadScopedSettings(projectSettings)
|
const content = fs.readFileSync(globalSettings, "utf-8");
|
||||||
: {};
|
const parsed = JSON.parse(content);
|
||||||
const loadedSettings = {
|
const loadedSettings = {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...globalLoaded,
|
...(parsed["pi-memory-md"] as MemoryMdSettings),
|
||||||
...projectLoaded,
|
|
||||||
autoSync: {
|
|
||||||
...DEFAULT_SETTINGS.autoSync,
|
|
||||||
...globalLoaded.autoSync,
|
|
||||||
...projectLoaded.autoSync,
|
|
||||||
},
|
|
||||||
systemPrompt: {
|
|
||||||
...DEFAULT_SETTINGS.systemPrompt,
|
|
||||||
...globalLoaded.systemPrompt,
|
|
||||||
...projectLoaded.systemPrompt,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadedSettings.localPath) {
|
if (loadedSettings.localPath) {
|
||||||
|
|
@ -225,6 +121,10 @@ function loadSettings(cwd?: string): MemoryMdSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadedSettings;
|
return loadedSettings;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load memory settings:", error);
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -265,40 +165,12 @@ export async function syncRepository(
|
||||||
if (fs.existsSync(localPath)) {
|
if (fs.existsSync(localPath)) {
|
||||||
const gitDir = path.join(localPath, ".git");
|
const gitDir = path.join(localPath, ".git");
|
||||||
if (!fs.existsSync(gitDir)) {
|
if (!fs.existsSync(gitDir)) {
|
||||||
let existingEntries: string[];
|
|
||||||
try {
|
|
||||||
existingEntries = fs.readdirSync(localPath);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `Path exists but is not a directory: ${localPath}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingEntries.length === 0) {
|
|
||||||
const cloneIntoEmptyDir = await gitExec(pi, localPath, "clone", repoUrl, ".");
|
|
||||||
if (cloneIntoEmptyDir.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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `Directory exists but is not a git repo: ${localPath}`,
|
message: `Directory exists but is not a git repo: ${localPath}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousHead = await getGitHead(pi, localPath);
|
|
||||||
const pullResult = await gitExec(
|
const pullResult = await gitExec(
|
||||||
pi,
|
pi,
|
||||||
localPath,
|
localPath,
|
||||||
|
|
@ -314,21 +186,15 @@ export async function syncRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
isRepoInitialized.value = true;
|
isRepoInitialized.value = true;
|
||||||
const currentHead = await getGitHead(pi, localPath);
|
|
||||||
const updated =
|
const updated =
|
||||||
previousHead !== null &&
|
pullResult.stdout.includes("Updating") ||
|
||||||
currentHead !== null &&
|
pullResult.stdout.includes("Fast-forward");
|
||||||
previousHead !== currentHead;
|
|
||||||
const repoName = getRepoName(settings);
|
const repoName = getRepoName(settings);
|
||||||
const message =
|
|
||||||
previousHead === null || currentHead === null
|
|
||||||
? `Synchronized [${repoName}]`
|
|
||||||
: updated
|
|
||||||
? `Pulled latest changes from [${repoName}]`
|
|
||||||
: `[${repoName}] is already latest`;
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message,
|
message: updated
|
||||||
|
? `Pulled latest changes from [${repoName}]`
|
||||||
|
: `[${repoName}] is already latest`,
|
||||||
updated,
|
updated,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -456,7 +322,7 @@ export function writeMemoryFile(
|
||||||
* Build memory context for agent prompt.
|
* Build memory context for agent prompt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function ensureDirectoryStructure(memoryDir: string): void {
|
function ensureDirectoryStructure(memoryDir: string): void {
|
||||||
const dirs = [
|
const dirs = [
|
||||||
path.join(memoryDir, "core", "user"),
|
path.join(memoryDir, "core", "user"),
|
||||||
path.join(memoryDir, "core", "project"),
|
path.join(memoryDir, "core", "project"),
|
||||||
|
|
@ -468,7 +334,7 @@ export function ensureDirectoryStructure(memoryDir: string): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultFiles(memoryDir: string): void {
|
function createDefaultFiles(memoryDir: string): void {
|
||||||
const identityFile = path.join(memoryDir, "core", "user", "identity.md");
|
const identityFile = path.join(memoryDir, "core", "user", "identity.md");
|
||||||
if (!fs.existsSync(identityFile)) {
|
if (!fs.existsSync(identityFile)) {
|
||||||
writeMemoryFile(
|
writeMemoryFile(
|
||||||
|
|
@ -496,68 +362,6 @@ export function createDefaultFiles(memoryDir: string): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMemoryDirectoryTree(
|
|
||||||
memoryDir: string,
|
|
||||||
maxDepth = 3,
|
|
||||||
maxLines = 40,
|
|
||||||
): string {
|
|
||||||
if (!fs.existsSync(memoryDir)) {
|
|
||||||
return "Unable to generate directory tree.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`${path.basename(memoryDir) || memoryDir}/`];
|
|
||||||
let truncated = false;
|
|
||||||
|
|
||||||
function visit(dir: string, depth: number, prefix: string): void {
|
|
||||||
if (depth >= maxDepth || lines.length >= maxLines) {
|
|
||||||
truncated = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let entries: fs.Dirent[];
|
|
||||||
try {
|
|
||||||
entries = fs
|
|
||||||
.readdirSync(dir, { withFileTypes: true })
|
|
||||||
.filter((entry) => entry.name !== "node_modules")
|
|
||||||
.sort((left, right) => {
|
|
||||||
if (left.isDirectory() !== right.isDirectory()) {
|
|
||||||
return left.isDirectory() ? -1 : 1;
|
|
||||||
}
|
|
||||||
return left.name.localeCompare(right.name);
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
truncated = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [index, entry] of entries.entries()) {
|
|
||||||
if (lines.length >= maxLines) {
|
|
||||||
truncated = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLast = index === entries.length - 1;
|
|
||||||
const marker = isLast ? "\\-- " : "|-- ";
|
|
||||||
const childPrefix = `${prefix}${isLast ? " " : "| "}`;
|
|
||||||
lines.push(
|
|
||||||
`${prefix}${marker}${entry.name}${entry.isDirectory() ? "/" : ""}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
visit(path.join(dir, entry.name), depth + 1, childPrefix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visit(memoryDir, 0, "");
|
|
||||||
|
|
||||||
if (truncated) {
|
|
||||||
lines.push("... (tree truncated)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMemoryContext(
|
function buildMemoryContext(
|
||||||
settings: MemoryMdSettings,
|
settings: MemoryMdSettings,
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
|
|
@ -630,7 +434,7 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
let memoryInjected = false;
|
let memoryInjected = false;
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
settings = loadSettings(ctx.cwd);
|
settings = loadSettings();
|
||||||
|
|
||||||
if (!settings.enabled) {
|
if (!settings.enabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -647,11 +451,7 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (settings.autoSync?.onSessionStart && settings.localPath) {
|
||||||
settings.autoSync?.onSessionStart &&
|
|
||||||
settings.localPath &&
|
|
||||||
settings.repoUrl
|
|
||||||
) {
|
|
||||||
syncPromise = syncRepository(pi, settings, repoInitialized).then(
|
syncPromise = syncRepository(pi, settings, repoInitialized).then(
|
||||||
(syncResult) => {
|
(syncResult) => {
|
||||||
if (settings.repoUrl) {
|
if (settings.repoUrl) {
|
||||||
|
|
@ -709,20 +509,14 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
registerAllTools(pi, () => settings, repoInitialized);
|
registerAllTools(pi, settings, repoInitialized);
|
||||||
|
|
||||||
pi.registerCommand("memory-status", {
|
pi.registerCommand("memory-status", {
|
||||||
description: "Show memory repository status",
|
description: "Show memory repository status",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
settings = loadSettings(ctx.cwd);
|
|
||||||
const projectName = path.basename(ctx.cwd);
|
const projectName = path.basename(ctx.cwd);
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
const projectRepoPath = getProjectRepoPath(settings, ctx);
|
|
||||||
const coreUserDir = path.join(memoryDir, "core", "user");
|
const coreUserDir = path.join(memoryDir, "core", "user");
|
||||||
const repoConfigured = Boolean(settings.repoUrl);
|
|
||||||
const repoReady = Boolean(
|
|
||||||
settings.localPath && fs.existsSync(path.join(settings.localPath, ".git")),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(coreUserDir)) {
|
if (!fs.existsSync(coreUserDir)) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
|
|
@ -732,37 +526,12 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!repoConfigured) {
|
|
||||||
ctx.ui.notify(
|
|
||||||
`Memory: ${projectName} | Local only | Path: ${memoryDir}`,
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!repoReady || !settings.localPath) {
|
|
||||||
ctx.ui.notify(
|
|
||||||
`Memory: ${projectName} | Repo not initialized | Path: ${memoryDir}`,
|
|
||||||
"warning",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await gitExec(
|
const result = await gitExec(
|
||||||
pi,
|
pi,
|
||||||
settings.localPath,
|
settings.localPath!,
|
||||||
"status",
|
"status",
|
||||||
"--porcelain",
|
"--porcelain",
|
||||||
"--",
|
|
||||||
projectRepoPath,
|
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
|
||||||
ctx.ui.notify(
|
|
||||||
`Memory: ${projectName} | Repo status unavailable | Path: ${memoryDir}`,
|
|
||||||
"warning",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isDirty = result.stdout.trim().length > 0;
|
const isDirty = result.stdout.trim().length > 0;
|
||||||
|
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
|
|
@ -775,36 +544,26 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
pi.registerCommand("memory-init", {
|
pi.registerCommand("memory-init", {
|
||||||
description: "Initialize memory repository",
|
description: "Initialize memory repository",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
settings = loadSettings(ctx.cwd);
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
const alreadyInitialized = fs.existsSync(
|
const alreadyInitialized = fs.existsSync(
|
||||||
path.join(memoryDir, "core", "user"),
|
path.join(memoryDir, "core", "user"),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (settings.repoUrl) {
|
|
||||||
const result = await syncRepository(pi, settings, repoInitialized);
|
const result = await syncRepository(pi, settings, repoInitialized);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
ctx.ui.notify(`Initialization failed: ${result.message}`, "error");
|
ctx.ui.notify(`Initialization failed: ${result.message}`, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ensureDirectoryStructure(memoryDir);
|
ensureDirectoryStructure(memoryDir);
|
||||||
createDefaultFiles(memoryDir);
|
createDefaultFiles(memoryDir);
|
||||||
repoInitialized.value = true;
|
|
||||||
|
|
||||||
if (alreadyInitialized) {
|
if (alreadyInitialized) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(`Memory already exists: ${result.message}`, "info");
|
||||||
settings.repoUrl
|
|
||||||
? "Memory already exists and repository is ready"
|
|
||||||
: "Local memory already exists",
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
settings.repoUrl
|
`Memory initialized: ${result.message}\n\nCreated:\n - core/user\n - core/project\n - reference`,
|
||||||
? "Memory initialized and repository is ready\n\nCreated:\n - core/user\n - core/project\n - reference"
|
|
||||||
: "Local memory initialized\n\nCreated:\n - core/user\n - core/project\n - reference",
|
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -814,7 +573,6 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
pi.registerCommand("memory-refresh", {
|
pi.registerCommand("memory-refresh", {
|
||||||
description: "Refresh memory context from files",
|
description: "Refresh memory context from files",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
settings = loadSettings(ctx.cwd);
|
|
||||||
const memoryContext = buildMemoryContext(settings, ctx);
|
const memoryContext = buildMemoryContext(settings, ctx);
|
||||||
|
|
||||||
if (!memoryContext) {
|
if (!memoryContext) {
|
||||||
|
|
@ -852,7 +610,6 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
pi.registerCommand("memory-check", {
|
pi.registerCommand("memory-check", {
|
||||||
description: "Check memory folder structure",
|
description: "Check memory folder structure",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
settings = loadSettings(ctx.cwd);
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
|
|
||||||
if (!fs.existsSync(memoryDir)) {
|
if (!fs.existsSync(memoryDir)) {
|
||||||
|
|
@ -860,7 +617,25 @@ export default function memoryMdExtension(pi: ExtensionAPI) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.ui.notify(formatMemoryDirectoryTree(memoryDir).trim(), "info");
|
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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,15 @@ import { Text } from "@mariozechner/pi-tui";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import type { MemoryFrontmatter, MemoryMdSettings } from "./memory-md.js";
|
import type { MemoryFrontmatter, MemoryMdSettings } from "./memory-md.js";
|
||||||
import {
|
import {
|
||||||
createDefaultFiles,
|
|
||||||
ensureDirectoryStructure,
|
|
||||||
formatMemoryDirectoryTree,
|
|
||||||
getCurrentDate,
|
getCurrentDate,
|
||||||
getMemoryDir,
|
getMemoryDir,
|
||||||
getProjectRepoPath,
|
|
||||||
gitExec,
|
gitExec,
|
||||||
listMemoryFiles,
|
listMemoryFiles,
|
||||||
readMemoryFile,
|
readMemoryFile,
|
||||||
resolveMemoryPath,
|
|
||||||
syncRepository,
|
syncRepository,
|
||||||
writeMemoryFile,
|
writeMemoryFile,
|
||||||
} from "./memory-md.js";
|
} from "./memory-md.js";
|
||||||
|
|
||||||
type MemorySettingsGetter = () => MemoryMdSettings;
|
|
||||||
|
|
||||||
function renderWithExpandHint(
|
function renderWithExpandHint(
|
||||||
text: string,
|
text: string,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
|
|
@ -41,7 +34,7 @@ function renderWithExpandHint(
|
||||||
|
|
||||||
export function registerMemorySync(
|
export function registerMemorySync(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
isRepoInitialized: { value: boolean },
|
isRepoInitialized: { value: boolean },
|
||||||
): void {
|
): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
|
|
@ -59,73 +52,26 @@ export function registerMemorySync(
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const { action } = params as { action: "pull" | "push" | "status" };
|
const { action } = params as { action: "pull" | "push" | "status" };
|
||||||
const settings = getSettings();
|
const localPath = settings.localPath!;
|
||||||
const localPath = settings.localPath;
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
const projectRepoPath = getProjectRepoPath(settings, ctx);
|
|
||||||
const coreUserDir = path.join(memoryDir, "core", "user");
|
const coreUserDir = path.join(memoryDir, "core", "user");
|
||||||
const configured = Boolean(settings.repoUrl);
|
|
||||||
const initialized = fs.existsSync(coreUserDir);
|
|
||||||
const repoReady = Boolean(
|
|
||||||
localPath && fs.existsSync(path.join(localPath, ".git")),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (action === "status") {
|
if (action === "status") {
|
||||||
|
const initialized =
|
||||||
|
isRepoInitialized.value && fs.existsSync(coreUserDir);
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Memory not initialized. Use memory_init to set up.",
|
text: "Memory repository not initialized. Use memory_init to set up.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: { initialized: false, configured, dirty: null },
|
details: { initialized: false },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configured) {
|
const result = await gitExec(pi, localPath, "status", "--porcelain");
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Memory repository is not configured. Local memory is available only on this machine.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { initialized: true, configured: false, dirty: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!repoReady || !localPath) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Memory repository is configured but not initialized locally.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { initialized: true, configured: true, dirty: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await gitExec(
|
|
||||||
pi,
|
|
||||||
localPath,
|
|
||||||
"status",
|
|
||||||
"--porcelain",
|
|
||||||
"--",
|
|
||||||
projectRepoPath,
|
|
||||||
);
|
|
||||||
if (!result.success) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Unable to inspect memory repository status.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { initialized: true, configured: true, dirty: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const dirty = result.stdout.trim().length > 0;
|
const dirty = result.stdout.trim().length > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -142,95 +88,36 @@ export function registerMemorySync(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "pull") {
|
if (action === "pull") {
|
||||||
if (!configured) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Memory repository is not configured. Nothing to pull.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { success: false, configured: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const result = await syncRepository(pi, settings, isRepoInitialized);
|
const result = await syncRepository(pi, settings, isRepoInitialized);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: result.message }],
|
content: [{ type: "text", text: result.message }],
|
||||||
details: { success: result.success, configured: true },
|
details: { success: result.success },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "push") {
|
if (action === "push") {
|
||||||
if (!configured || !localPath) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Memory repository is not configured. Nothing to push.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { success: false, configured: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!repoReady) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Memory repository is configured but not initialized locally.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { success: false, configured: true },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncResult = await syncRepository(pi, settings, isRepoInitialized);
|
|
||||||
if (!syncResult.success) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: syncResult.message }],
|
|
||||||
details: { success: false, configured: true },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusResult = await gitExec(
|
const statusResult = await gitExec(
|
||||||
pi,
|
pi,
|
||||||
localPath,
|
localPath,
|
||||||
"status",
|
"status",
|
||||||
"--porcelain",
|
"--porcelain",
|
||||||
"--",
|
|
||||||
projectRepoPath,
|
|
||||||
);
|
);
|
||||||
if (!statusResult.success) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Unable to inspect memory repository before push.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { success: false, configured: true },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const hasChanges = statusResult.stdout.trim().length > 0;
|
const hasChanges = statusResult.stdout.trim().length > 0;
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
await gitExec(pi, localPath, "add", "-A", "--", projectRepoPath);
|
await gitExec(pi, localPath, "add", ".");
|
||||||
|
|
||||||
const timestamp = new Date()
|
const timestamp = new Date()
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace(/[:.]/g, "-")
|
.replace(/[:.]/g, "-")
|
||||||
.slice(0, 19);
|
.slice(0, 19);
|
||||||
const commitMessage = `Update memory for ${path.basename(ctx.cwd)} - ${timestamp}`;
|
const commitMessage = `Update memory - ${timestamp}`;
|
||||||
const commitResult = await gitExec(
|
const commitResult = await gitExec(
|
||||||
pi,
|
pi,
|
||||||
localPath,
|
localPath,
|
||||||
"commit",
|
"commit",
|
||||||
"-m",
|
"-m",
|
||||||
commitMessage,
|
commitMessage,
|
||||||
"--only",
|
|
||||||
"--",
|
|
||||||
projectRepoPath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!commitResult.success) {
|
if (!commitResult.success) {
|
||||||
|
|
@ -302,7 +189,7 @@ export function registerMemorySync(
|
||||||
|
|
||||||
export function registerMemoryRead(
|
export function registerMemoryRead(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
): void {
|
): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "memory_read",
|
name: "memory_read",
|
||||||
|
|
@ -317,8 +204,8 @@ export function registerMemoryRead(
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const { path: relPath } = params as { path: string };
|
const { path: relPath } = params as { path: string };
|
||||||
const settings = getSettings();
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
const fullPath = resolveMemoryPath(settings, ctx, relPath);
|
const fullPath = path.join(memoryDir, relPath);
|
||||||
|
|
||||||
const memory = readMemoryFile(fullPath);
|
const memory = readMemoryFile(fullPath);
|
||||||
if (!memory) {
|
if (!memory) {
|
||||||
|
|
@ -384,7 +271,7 @@ export function registerMemoryRead(
|
||||||
|
|
||||||
export function registerMemoryWrite(
|
export function registerMemoryWrite(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
): void {
|
): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "memory_write",
|
name: "memory_write",
|
||||||
|
|
@ -413,24 +300,17 @@ export function registerMemoryWrite(
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const settings = getSettings();
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
const fullPath = resolveMemoryPath(settings, ctx, relPath);
|
const fullPath = path.join(memoryDir, relPath);
|
||||||
|
|
||||||
const existing = readMemoryFile(fullPath);
|
const existing = readMemoryFile(fullPath);
|
||||||
const existingFrontmatter = existing?.frontmatter;
|
const existingFrontmatter = existing?.frontmatter || { description };
|
||||||
|
|
||||||
const frontmatter: MemoryFrontmatter = {
|
const frontmatter: MemoryFrontmatter = {
|
||||||
|
...existingFrontmatter,
|
||||||
description,
|
description,
|
||||||
created: existingFrontmatter?.created ?? getCurrentDate(),
|
|
||||||
updated: getCurrentDate(),
|
updated: getCurrentDate(),
|
||||||
...(existingFrontmatter?.limit !== undefined
|
...(tags && { tags }),
|
||||||
? { limit: existingFrontmatter.limit }
|
|
||||||
: {}),
|
|
||||||
...(tags !== undefined
|
|
||||||
? { tags }
|
|
||||||
: existingFrontmatter?.tags
|
|
||||||
? { tags: existingFrontmatter.tags }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
writeMemoryFile(fullPath, content, frontmatter);
|
writeMemoryFile(fullPath, content, frontmatter);
|
||||||
|
|
@ -487,7 +367,7 @@ export function registerMemoryWrite(
|
||||||
|
|
||||||
export function registerMemoryList(
|
export function registerMemoryList(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
): void {
|
): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "memory_list",
|
name: "memory_list",
|
||||||
|
|
@ -501,11 +381,8 @@ export function registerMemoryList(
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const { directory } = params as { directory?: string };
|
const { directory } = params as { directory?: string };
|
||||||
const settings = getSettings();
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
const searchDir = directory
|
const searchDir = directory ? path.join(memoryDir, directory) : memoryDir;
|
||||||
? resolveMemoryPath(settings, ctx, directory)
|
|
||||||
: memoryDir;
|
|
||||||
const files = listMemoryFiles(searchDir);
|
const files = listMemoryFiles(searchDir);
|
||||||
const relPaths = files.map((f) => path.relative(memoryDir, f));
|
const relPaths = files.map((f) => path.relative(memoryDir, f));
|
||||||
|
|
||||||
|
|
@ -555,7 +432,7 @@ export function registerMemoryList(
|
||||||
|
|
||||||
export function registerMemorySearch(
|
export function registerMemorySearch(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
): void {
|
): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "memory_search",
|
name: "memory_search",
|
||||||
|
|
@ -580,7 +457,6 @@ export function registerMemorySearch(
|
||||||
query: string;
|
query: string;
|
||||||
searchIn: "content" | "tags" | "description";
|
searchIn: "content" | "tags" | "description";
|
||||||
};
|
};
|
||||||
const settings = getSettings();
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
const files = listMemoryFiles(memoryDir);
|
const files = listMemoryFiles(memoryDir);
|
||||||
const results: Array<{ path: string; match: string }> = [];
|
const results: Array<{ path: string; match: string }> = [];
|
||||||
|
|
@ -668,7 +544,7 @@ export function registerMemorySearch(
|
||||||
|
|
||||||
export function registerMemoryInit(
|
export function registerMemoryInit(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
isRepoInitialized: { value: boolean },
|
isRepoInitialized: { value: boolean },
|
||||||
): void {
|
): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
|
|
@ -682,19 +558,10 @@ export function registerMemoryInit(
|
||||||
),
|
),
|
||||||
}) as any,
|
}) as any,
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||||
const { force = false } = params as { force?: boolean };
|
const { force = false } = params as { force?: boolean };
|
||||||
const settings = getSettings();
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
|
||||||
const alreadyInitialized = fs.existsSync(
|
|
||||||
path.join(memoryDir, "core", "user"),
|
|
||||||
);
|
|
||||||
const repoReady = Boolean(
|
|
||||||
settings.localPath &&
|
|
||||||
fs.existsSync(path.join(settings.localPath, ".git")),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (alreadyInitialized && (!settings.repoUrl || repoReady) && !force) {
|
if (isRepoInitialized.value && !force) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|
@ -706,35 +573,18 @@ export function registerMemoryInit(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.repoUrl) {
|
|
||||||
const result = await syncRepository(pi, settings, isRepoInitialized);
|
const result = await syncRepository(pi, settings, isRepoInitialized);
|
||||||
if (!result.success) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Initialization failed: ${result.message}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: { success: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureDirectoryStructure(memoryDir);
|
|
||||||
createDefaultFiles(memoryDir);
|
|
||||||
isRepoInitialized.value = true;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: settings.repoUrl
|
text: result.success
|
||||||
? `Memory repository initialized.\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}`
|
? `Memory repository initialized:\n${result.message}\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}`
|
||||||
: `Local memory initialized.\n\nCreated directory structure:\n${["core/user", "core/project", "reference"].map((d) => ` - ${d}`).join("\n")}`,
|
: `Initialization failed: ${result.message}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
details: { success: true },
|
details: { success: result.success },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -778,7 +628,7 @@ export function registerMemoryInit(
|
||||||
|
|
||||||
export function registerMemoryCheck(
|
export function registerMemoryCheck(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
): void {
|
): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "memory_check",
|
name: "memory_check",
|
||||||
|
|
@ -787,7 +637,6 @@ export function registerMemoryCheck(
|
||||||
parameters: Type.Object({}) as any,
|
parameters: Type.Object({}) as any,
|
||||||
|
|
||||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||||
const settings = getSettings();
|
|
||||||
const memoryDir = getMemoryDir(settings, ctx);
|
const memoryDir = getMemoryDir(settings, ctx);
|
||||||
|
|
||||||
if (!fs.existsSync(memoryDir)) {
|
if (!fs.existsSync(memoryDir)) {
|
||||||
|
|
@ -802,7 +651,26 @@ export function registerMemoryCheck(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const treeOutput = formatMemoryDirectoryTree(memoryDir);
|
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 files = listMemoryFiles(memoryDir);
|
||||||
const relPaths = files.map((f) => path.relative(memoryDir, f));
|
const relPaths = files.map((f) => path.relative(memoryDir, f));
|
||||||
|
|
@ -851,14 +719,14 @@ export function registerMemoryCheck(
|
||||||
|
|
||||||
export function registerAllTools(
|
export function registerAllTools(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
getSettings: MemorySettingsGetter,
|
settings: MemoryMdSettings,
|
||||||
isRepoInitialized: { value: boolean },
|
isRepoInitialized: { value: boolean },
|
||||||
): void {
|
): void {
|
||||||
registerMemorySync(pi, getSettings, isRepoInitialized);
|
registerMemorySync(pi, settings, isRepoInitialized);
|
||||||
registerMemoryRead(pi, getSettings);
|
registerMemoryRead(pi, settings);
|
||||||
registerMemoryWrite(pi, getSettings);
|
registerMemoryWrite(pi, settings);
|
||||||
registerMemoryList(pi, getSettings);
|
registerMemoryList(pi, settings);
|
||||||
registerMemorySearch(pi, getSettings);
|
registerMemorySearch(pi, settings);
|
||||||
registerMemoryInit(pi, getSettings, isRepoInitialized);
|
registerMemoryInit(pi, settings, isRepoInitialized);
|
||||||
registerMemoryCheck(pi, getSettings);
|
registerMemoryCheck(pi, settings);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ SERVICE_STDERR_LOG=""
|
||||||
|
|
||||||
DEFAULT_PACKAGES=(
|
DEFAULT_PACKAGES=(
|
||||||
"npm:@e9n/pi-channels"
|
"npm:@e9n/pi-channels"
|
||||||
"npm:pi-memory-md"
|
|
||||||
"npm:pi-teams"
|
"npm:pi-teams"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue