feat(pi-hermes-memory): 导入上游 v0.9.10 源码快照

从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10
(df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。
本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略
均未改动。

- 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、
  AGENTS 与 LICENSE;该快照共 156 个上游文件。
- 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、
  覆盖率与构建产物。
- 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项:
  在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts
  对齐、strip-ansi 解析确认。
- 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至
  24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为
  对应一个已发布、可复现的版本。

尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载
Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。

验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份;
已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
This commit is contained in:
云服务部-叶林立
2026-10-13 23:19:35 -07:00
parent 879fc5d37f
commit 312bb2ba5a
157 changed files with 52287 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
/**
* Memory write tools — registers the LLM-callable memory_add, memory_replace,
* and memory_remove tools.
* See PLAN.md → "Hermes Source File Reference Map" for source lines.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import { MemoryStore } from "../store/memory-store.js";
import { DatabaseManager } from "../store/db.js";
import {
formatFailureMemoryContent,
reconcileMarkdownFailureScopes,
reconcileMarkdownMemoryScope,
removeExactSyncedMemories,
removeSyncedMemories,
replaceSyncedMemories,
syncMemoryEntry,
isFts5QueryError,
} from "../store/sqlite-memory-store.js";
import { MEMORY_TOOL_DESCRIPTION } from "../constants.js";
import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js";
import type { MemoryCategory, MemoryResult } from "../types.js";
import { normalizeMemoryLookupText } from "../store/memory-lookup.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { memoryResultView } from "./tool-result-views.js";
function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult {
const warnings = [...(((result as any).warnings ?? []) as string[]), warning];
const message = result.message ? `${result.message} Warning: ${warning}` : warning;
return {
...result,
message,
warning,
warnings,
} as MemoryResult;
}
function formatMemoryToolText(result: MemoryResult): string {
const evictedEntries = result.evicted_entries ?? [];
if (result.success && evictedEntries.length > 0) {
const lines = [
result.message ?? `Memory updated. Rotated ${evictedEntries.length} older ${evictedEntries.length === 1 ? "entry" : "entries"} to stay within the limit.`,
"",
"Rotated active memory entries:",
"",
];
evictedEntries.forEach((entry, index) => {
lines.push(`${index + 1}. ${entry}`);
lines.push("");
});
lines.push("If one of these entries should stay active, add it again.");
if (result.usage) lines.push(`Usage: ${result.usage}`);
return lines.join("\n").trim();
}
return JSON.stringify(result);
}
function sqliteProjectFor(rawTarget: "memory" | "user" | "project" | "failure", projectName?: string | null): string | null | undefined {
if (rawTarget === "project") return projectName?.trim() || null;
if (rawTarget === "memory") return null;
if (rawTarget === "user") return null;
if (rawTarget === "failure") return null;
return undefined;
}
function sqliteTargetFor(rawTarget: "memory" | "user" | "project" | "failure"): "memory" | "user" | "failure" {
if (rawTarget === "project") return "memory";
return rawTarget;
}
function matchingMutationTargets(
oldText: string,
store: MemoryStore,
projectStore: MemoryStore | null,
): Array<"memory" | "user" | "failure" | "project"> {
const lookup = normalizeMemoryLookupText(oldText);
if (!lookup) return [];
const targets: Array<"memory" | "user" | "failure" | "project"> = [];
if (store.getMemoryEntries().some((entry) => entry.includes(lookup))) targets.push("memory");
if (store.getUserEntries().some((entry) => entry.includes(lookup))) targets.push("user");
if (store.getAllFailureEntries().some((entry) => entry.includes(lookup))) targets.push("failure");
if (projectStore?.getMemoryEntries().some((entry) => entry.includes(lookup))) targets.push("project");
return targets;
}
function addWrongTargetHint(
result: MemoryResult,
rawTarget: "memory" | "user" | "project" | "failure",
oldText: string,
store: MemoryStore,
projectStore: MemoryStore | null,
): MemoryResult {
if (result.success || !result.error?.startsWith("No entry matched")) return result;
const alternatives = matchingMutationTargets(oldText, store, projectStore)
.filter((target) => target !== rawTarget);
if (alternatives.length === 0) return result;
const quotedTargets = alternatives.map((target) => `"${target}"`).join(", ");
const noun = alternatives.length === 1 ? "target" : "targets";
return {
...result,
error: `No match in target "${rawTarget}"; matching entry found in ${noun} ${quotedTargets}. Retry with the displayed target.`,
matching_targets: alternatives,
};
}
async function syncAddToSqlite(
rawTarget: "memory" | "user" | "project" | "failure",
content: string,
category: MemoryCategory | undefined,
failureReason: string | undefined,
dbManager: DatabaseManager | null,
projectName?: string | null,
): Promise<string | null> {
if (!dbManager) return null;
try {
const sqliteTarget = sqliteTargetFor(rawTarget);
const sqliteProject = sqliteProjectFor(rawTarget, projectName);
if (rawTarget === "failure") {
const failureCategory = category ?? "failure";
syncMemoryEntry(dbManager, {
content: formatFailureMemoryContent(content, {
category: failureCategory,
failureReason,
}),
target: "failure",
project: sqliteProject ?? null,
category: failureCategory,
failureReason,
});
return null;
}
syncMemoryEntry(dbManager, {
content,
target: sqliteTarget,
project: sqliteProject ?? null,
});
return null;
} catch (err) {
return `Saved to Markdown, but SQLite search sync failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
async function syncReplaceToSqlite(
rawTarget: "memory" | "user" | "project" | "failure",
oldText: string,
newContent: string,
dbManager: DatabaseManager | null,
projectName?: string | null,
): Promise<string | null> {
if (!dbManager) return null;
try {
const sqliteTarget = sqliteTargetFor(rawTarget);
const sqliteProject = sqliteProjectFor(rawTarget, projectName);
const syncResult = replaceSyncedMemories(dbManager, oldText, {
content: newContent,
target: sqliteTarget,
project: sqliteProject,
});
if (syncResult.matched === 0) {
return "Saved to Markdown, but no matching SQLite memory row was updated. Run /memory-sync-markdown if search results look stale.";
}
return null;
} catch (err) {
return `Saved to Markdown, but SQLite search sync failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
async function syncRemoveFromSqlite(
rawTarget: "memory" | "user" | "project" | "failure",
oldText: string,
dbManager: DatabaseManager | null,
projectName?: string | null,
): Promise<string | null> {
if (!dbManager) return null;
try {
const sqliteTarget = sqliteTargetFor(rawTarget);
const sqliteProject = sqliteProjectFor(rawTarget, projectName);
const syncResult = removeSyncedMemories(dbManager, oldText, {
target: sqliteTarget,
project: sqliteProject,
});
if (syncResult.matched === 0) {
return "Saved to Markdown, but no matching SQLite memory row was removed. Run /memory-sync-markdown if search results look stale.";
}
return null;
} catch (err) {
return `Saved to Markdown, but SQLite search sync failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
async function syncEvictionsFromSqlite(
rawTarget: "memory" | "user" | "project" | "failure",
evictedEntries: string[] | undefined,
dbManager: DatabaseManager | null,
projectName?: string | null,
): Promise<void> {
if (!dbManager) return;
if (!evictedEntries || evictedEntries.length === 0) return;
const sqliteTarget = sqliteTargetFor(rawTarget);
const sqliteProject = sqliteProjectFor(rawTarget, projectName);
for (const entry of evictedEntries) {
try {
removeExactSyncedMemories(dbManager, entry, {
target: sqliteTarget,
project: sqliteProject,
});
} catch {
// FIFO already updated the Markdown source of truth. SQLite is only a
// best-effort search mirror, so eviction cleanup must not fail the write.
}
}
}
async function reconcileStoreScope(
entries: string[],
rawTarget: "memory" | "user" | "project" | "failure",
dbManager: DatabaseManager | null,
projectName?: string | null,
): Promise<string | null | undefined> {
if (!dbManager) return undefined;
try {
if (rawTarget === "failure") {
return degradedRepairMessage(reconcileMarkdownFailureScopes(dbManager, entries));
}
const target = sqliteTargetFor(rawTarget);
return degradedRepairMessage(
reconcileMarkdownMemoryScope(
dbManager,
entries,
target,
sqliteProjectFor(rawTarget, projectName) ?? null,
),
);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
if (isFts5QueryError(err)) {
return degradedRepairMessage({ degraded: true, degradedReason: detail });
}
return `Saved to Markdown, but SQLite search reconciliation failed: ${detail}`;
}
}
// A degraded reconcile result means the Markdown write succeeded but the
// SQLite search mirror could not be updated (genuine FTS5 index error). The
// caller must tell the user how to repair the index instead of looking like a
// plain success.
function degradedRepairMessage(result: { degraded?: boolean; degradedReason?: string }): string | null {
return result.degraded
? `Saved to Markdown. Search may be temporarily unavailable (FTS5 index error: ${result.degradedReason}). Run /memory-sync-markdown to rebuild the search index.`
: null;
}
type MemoryAction = "add" | "replace" | "remove";
type MemoryToolParams = {
target: "memory" | "user" | "project" | "failure";
content?: string;
old_text?: string;
category?: MemoryCategory;
failure_reason?: string;
};
export function registerMemoryTool(
pi: ExtensionAPI,
store: MemoryStore,
projectStore: ProjectStoreRef,
dbManager: DatabaseManager | null = null,
projectName: ProjectNameRef = null,
bindProjectFromCwd?: (cwd?: string) => void | Promise<void>,
): (candidate: MemoryStore | null) => void {
const reconciledStores = new WeakSet<MemoryStore>();
const attachMutationObserver = (candidate: MemoryStore | null, isProjectStore = false): void => {
if (!candidate || reconciledStores.has(candidate) || typeof candidate.setMutationObserver !== "function") return;
candidate.setMutationObserver((target, entries) =>
reconcileStoreScope(
entries,
isProjectStore && target === "memory" ? "project" : target,
dbManager,
resolveProjectName(projectName),
),
);
reconciledStores.add(candidate);
};
const configureProjectStore = (candidate: MemoryStore | null): void => {
attachMutationObserver(candidate, true);
};
attachMutationObserver(store);
configureProjectStore(resolveProjectStore(projectStore));
if (typeof pi.on === "function") {
pi.on("tool_result", (event) => {
if (!event.toolName.startsWith("memory_")) return;
const details = event.details as { success?: unknown } | undefined;
if (details?.success === false) return { isError: true };
});
}
const executeAction = async (
action: MemoryAction,
params: MemoryToolParams,
signal?: AbortSignal,
) => {
const { target: rawTarget, content, old_text, category, failure_reason } = params;
const target = rawTarget === "project" ? "memory" : rawTarget;
const activeProjectStore = resolveProjectStore(projectStore);
const activeProjectName = resolveProjectName(projectName);
const activeStore = rawTarget === "project" ? activeProjectStore : store;
if (rawTarget === "project" && !activeProjectStore) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: false,
error: "Project memory is not available (no project detected).",
}),
}],
details: {
success: false,
error: "Project memory is not available (no project detected).",
},
};
}
const store_ = activeStore!;
attachMutationObserver(store_);
let result: MemoryResult;
let syncWarning: string | null = null;
const syncHandled = reconciledStores.has(store_);
switch (action) {
case "add":
if (!content) {
throw new Error("Content is required for 'add' action.");
}
if (rawTarget === "failure") {
const memoryCategory = category ?? "failure";
result = await store_.addFailure(content, {
category: memoryCategory,
failureReason: failure_reason,
});
if (result.success && !syncHandled) {
syncWarning = await syncAddToSqlite(rawTarget, content, memoryCategory, failure_reason, dbManager, activeProjectName);
}
} else {
result = await store_.add(target, content, signal);
if (result.success && !syncHandled) {
await syncEvictionsFromSqlite(rawTarget, result.evicted_entries, dbManager, activeProjectName);
syncWarning = await syncAddToSqlite(rawTarget, content, undefined, undefined, dbManager, activeProjectName);
}
}
break;
case "replace":
if (!old_text) throw new Error("old_text is required for 'replace' action.");
if (!content) throw new Error("content is required for 'replace' action.");
result = await store_.replace(target, old_text, content);
if (result.success && !syncHandled) {
syncWarning = await syncReplaceToSqlite(rawTarget, old_text, content, dbManager, activeProjectName);
}
break;
case "remove":
if (!old_text) throw new Error("old_text is required for 'remove' action.");
result = await store_.remove(target, old_text);
if (result.success && !syncHandled) {
syncWarning = await syncRemoveFromSqlite(rawTarget, old_text, dbManager, activeProjectName);
}
break;
}
if (action !== "add" && old_text) {
result = addWrongTargetHint(result, rawTarget, old_text, store, activeProjectStore);
}
if (result.success && !syncHandled && typeof store_.getRawEntriesForSync === "function") {
const reconciliationWarning = await reconcileStoreScope(store_.getRawEntriesForSync(target), rawTarget, dbManager, activeProjectName);
if (reconciliationWarning !== undefined) syncWarning = reconciliationWarning;
}
if (syncWarning && result.success) result = appendSyncWarning(result, syncWarning);
if (rawTarget === "project" && result.success) result = { ...result, target: "project" };
return {
content: [{ type: "text" as const, text: formatMemoryToolText(result) }],
details: result,
};
};
const commonDescription = `${MEMORY_TOOL_DESCRIPTION}
This action-specific tool accepts only the parameters listed in its schema.`;
const registerActionTool = (
action: MemoryAction,
name: string,
label: string,
description: string,
parameters: TSchema,
) => {
pi.registerTool({
name,
label,
description,
promptSnippet: `${label}: persistent memory that survives across sessions`,
promptGuidelines: [
"Use this tool proactively when the user corrects you, shares a preference, or reveals durable environment or project facts.",
"Do not use memory tools for temporary task state, TODO items, or session progress.",
],
renderResult: createSharedToolResultRenderer(memoryResultView),
parameters,
async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string }) {
if (bindProjectFromCwd && ctx?.cwd) {
await bindProjectFromCwd(ctx.cwd);
}
return executeAction(action, params as MemoryToolParams, signal);
},
});
};
const target = StringEnum(["memory", "user", "project", "failure"] as const, {
description: "Memory scope. Use failure for failures, corrections, insights, and tool quirks.",
});
const category = StringEnum(["failure", "correction", "insight", "preference", "convention", "tool-quirk"] as const, {
description: "Category for failure memories.",
});
registerActionTool(
"add",
"memory_add",
"Memory Add",
`${commonDescription}
Add one durable entry. The target and content fields are required.`,
Type.Object({
target,
content: Type.String({ description: "Entry content to save." }),
category: Type.Optional(category),
failure_reason: Type.Optional(Type.String({ description: "Why a failure occurred." })),
}),
);
registerActionTool(
"replace",
"memory_replace",
"Memory Replace",
`${commonDescription}
Replace one existing entry. The target, old_text, and content fields are required.`,
Type.Object({
target,
old_text: Type.String({ description: "Substring identifying the entry to replace." }),
content: Type.String({ description: "Replacement entry content." }),
}),
);
registerActionTool(
"remove",
"memory_remove",
"Memory Remove",
`${commonDescription}
Remove one existing entry. The target and old_text fields are required.`,
Type.Object({
target,
old_text: Type.String({ description: "Substring identifying the entry to remove." }),
}),
);
return configureProjectStore;
}