opencode-claude-memory 1.7.7 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -214
- package/dist/agents.d.ts +12 -0
- package/dist/agents.js +69 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.js +79 -0
- package/dist/extraction/ExtractionCoordinator.d.ts +62 -0
- package/dist/extraction/ExtractionCoordinator.js +386 -0
- package/dist/extraction/autodream.d.ts +33 -0
- package/dist/extraction/autodream.js +85 -0
- package/dist/extraction/forkSession.d.ts +37 -0
- package/dist/extraction/forkSession.js +133 -0
- package/dist/extraction/lock.d.ts +29 -0
- package/dist/extraction/lock.js +191 -0
- package/dist/extraction/prompts.d.ts +5 -0
- package/dist/extraction/prompts.js +78 -0
- package/dist/extraction/state.d.ts +38 -0
- package/dist/extraction/state.js +159 -0
- package/dist/hooks/ignore.d.ts +6 -0
- package/dist/hooks/ignore.js +53 -0
- package/dist/hooks/messages.d.ts +14 -0
- package/dist/hooks/messages.js +96 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +50 -665
- package/dist/prompt/sections.d.ts +7 -0
- package/dist/{prompt.js → prompt/sections.js} +16 -72
- package/dist/prompt/systemPrompt.d.ts +7 -0
- package/dist/prompt/systemPrompt.js +64 -0
- package/dist/recall/RecallCoordinator.d.ts +36 -0
- package/dist/recall/RecallCoordinator.js +140 -0
- package/dist/recall/format.d.ts +17 -0
- package/dist/{recall.js → recall/format.js} +13 -36
- package/dist/{recallSelector.d.ts → recall/selector.d.ts} +26 -16
- package/dist/recall/selector.js +92 -0
- package/dist/sdk.d.ts +12 -0
- package/dist/sdk.js +8 -0
- package/dist/store/MemoryStore.d.ts +41 -0
- package/dist/store/MemoryStore.js +105 -0
- package/dist/store/frontmatter.d.ts +25 -0
- package/dist/store/frontmatter.js +61 -0
- package/dist/store/indexFile.d.ts +15 -0
- package/dist/store/indexFile.js +153 -0
- package/dist/{paths.d.ts → store/paths.d.ts} +7 -6
- package/dist/store/paths.js +187 -0
- package/dist/store/scan.d.ts +29 -0
- package/dist/store/scan.js +112 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +142 -0
- package/dist/util/exclusiveFile.d.ts +11 -0
- package/dist/util/exclusiveFile.js +108 -0
- package/dist/util/legacyShellHook.d.ts +3 -0
- package/dist/util/legacyShellHook.js +23 -0
- package/dist/util/log.d.ts +6 -0
- package/dist/util/log.js +29 -0
- package/dist/util/ownedSessions.d.ts +7 -0
- package/dist/util/ownedSessions.js +39 -0
- package/dist/util/timeout.d.ts +5 -0
- package/dist/util/timeout.js +39 -0
- package/package.json +15 -8
- package/bin/opencode-memory +0 -1436
- package/dist/memory.d.ts +0 -31
- package/dist/memory.js +0 -224
- package/dist/memoryScan.d.ts +0 -20
- package/dist/memoryScan.js +0 -106
- package/dist/nativeExtraction.d.ts +0 -9
- package/dist/nativeExtraction.js +0 -80
- package/dist/paths.js +0 -141
- package/dist/prompt.d.ts +0 -4
- package/dist/recall.d.ts +0 -12
- package/dist/recallSelector.js +0 -154
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// LLM memory selection, ported from Claude Code's findRelevantMemories.ts side query. Runs in a
|
|
2
|
+
// hidden child session so the main conversation never sees the selector exchange.
|
|
3
|
+
import { runForkSession } from "../extraction/forkSession.js";
|
|
4
|
+
import { unwrapData } from "../sdk.js";
|
|
5
|
+
import { formatMemoryManifest } from "../store/scan.js";
|
|
6
|
+
export const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to OpenCode as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
|
|
7
|
+
|
|
8
|
+
Return a list of filenames for the memories that will clearly be useful to OpenCode as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description.
|
|
9
|
+
- If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning.
|
|
10
|
+
- If there are no memories in the list that would clearly be useful, feel free to return an empty list.
|
|
11
|
+
- If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (OpenCode is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter.
|
|
12
|
+
`;
|
|
13
|
+
export const SELECT_MEMORIES_FORMAT = {
|
|
14
|
+
type: "json_schema",
|
|
15
|
+
schema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
selected_memories: { type: "array", items: { type: "string" } },
|
|
19
|
+
},
|
|
20
|
+
required: ["selected_memories"],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export const RECALL_SELECTOR_TITLE = "opencode-memory recall selector";
|
|
25
|
+
function tryParseSelectedMemories(raw) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (!Array.isArray(parsed.selected_memories))
|
|
29
|
+
return undefined;
|
|
30
|
+
return parsed.selected_memories.filter((item) => typeof item === "string");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function extractSelectedMemories(response) {
|
|
37
|
+
const data = unwrapData(response);
|
|
38
|
+
if (!data || typeof data !== "object")
|
|
39
|
+
return [];
|
|
40
|
+
const structured = data.info?.structured;
|
|
41
|
+
if (structured && typeof structured === "object") {
|
|
42
|
+
const selected = structured.selected_memories;
|
|
43
|
+
if (Array.isArray(selected)) {
|
|
44
|
+
return selected.filter((item) => typeof item === "string");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(data.parts))
|
|
48
|
+
return [];
|
|
49
|
+
for (const part of data.parts) {
|
|
50
|
+
if (!part || typeof part !== "object")
|
|
51
|
+
continue;
|
|
52
|
+
const text = part.text;
|
|
53
|
+
if (typeof text !== "string")
|
|
54
|
+
continue;
|
|
55
|
+
const parsed = tryParseSelectedMemories(text);
|
|
56
|
+
if (parsed)
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
export function buildSelectorQuery(query, memories, recentTools) {
|
|
62
|
+
const toolsSection = recentTools.length > 0 ? `\n\nRecently used tools: ${recentTools.join(", ")}` : "";
|
|
63
|
+
return `Query: ${query}\n\nAvailable memories:\n${formatMemoryManifest(memories)}${toolsSection}`;
|
|
64
|
+
}
|
|
65
|
+
// Never throws: a failed or timed-out selector simply recalls nothing for this turn.
|
|
66
|
+
export async function selectRelevantMemoryFilenames(input) {
|
|
67
|
+
if (input.memories.length === 0)
|
|
68
|
+
return [];
|
|
69
|
+
try {
|
|
70
|
+
const response = await runForkSession({
|
|
71
|
+
client: input.client,
|
|
72
|
+
directory: input.directory,
|
|
73
|
+
parentSessionID: input.parentSessionID,
|
|
74
|
+
title: RECALL_SELECTOR_TITLE,
|
|
75
|
+
agent: input.agent,
|
|
76
|
+
system: SELECT_MEMORIES_SYSTEM_PROMPT,
|
|
77
|
+
tools: input.tools ?? { "*": false },
|
|
78
|
+
format: SELECT_MEMORIES_FORMAT,
|
|
79
|
+
parts: [{ type: "text", text: buildSelectorQuery(input.query, input.memories, input.recentTools) }],
|
|
80
|
+
timeoutMs: input.timeoutMs,
|
|
81
|
+
onCreated: input.onSessionCreated,
|
|
82
|
+
onFinished: input.onSessionFinished,
|
|
83
|
+
});
|
|
84
|
+
const validFilenames = new Set(input.memories.map((memory) => memory.filename));
|
|
85
|
+
return extractSelectedMemories(response)
|
|
86
|
+
.filter((filename) => validFilenames.has(filename))
|
|
87
|
+
.slice(0, input.maxMemories);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/sdk.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
export type OpencodeClient = PluginInput["client"];
|
|
3
|
+
export type PluginEvent = Parameters<NonNullable<Hooks["event"]>>[0]["event"];
|
|
4
|
+
export type PluginConfig = Parameters<NonNullable<Hooks["config"]>>[0];
|
|
5
|
+
export type AgentConfig = NonNullable<NonNullable<PluginConfig["agent"]>[string]>;
|
|
6
|
+
export type ChatMessage = Parameters<NonNullable<Hooks["experimental.chat.messages.transform"]>>[1]["messages"][number];
|
|
7
|
+
export type MessageInfo = ChatMessage["info"];
|
|
8
|
+
export type MessagePart = ChatMessage["parts"][number];
|
|
9
|
+
export type SessionInfo = Extract<PluginEvent, {
|
|
10
|
+
type: "session.deleted";
|
|
11
|
+
}>["properties"]["info"];
|
|
12
|
+
export declare function unwrapData<T = unknown>(response: unknown): T | undefined;
|
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// hey-api responses are `{ data, error, request, response }`; tests mock the `{ data }` subset.
|
|
2
|
+
export function unwrapData(response) {
|
|
3
|
+
if (!response || typeof response !== "object")
|
|
4
|
+
return response;
|
|
5
|
+
if ("data" in response)
|
|
6
|
+
return response.data;
|
|
7
|
+
return response;
|
|
8
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type MemoryType } from "./frontmatter.js";
|
|
2
|
+
import { type MemoryEntry, type MemoryHeader } from "./scan.js";
|
|
3
|
+
export type SaveMemoryInput = {
|
|
4
|
+
fileName: string;
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
type: MemoryType;
|
|
8
|
+
content: string;
|
|
9
|
+
};
|
|
10
|
+
export type SaveMemoryResult = {
|
|
11
|
+
filePath: string;
|
|
12
|
+
fileName: string;
|
|
13
|
+
unchanged: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type ListOptions = {
|
|
16
|
+
sort?: "name" | "mtime";
|
|
17
|
+
};
|
|
18
|
+
export type MemoryStoreOptions = {
|
|
19
|
+
claudeConfigDir: string;
|
|
20
|
+
};
|
|
21
|
+
export declare class MemoryStore {
|
|
22
|
+
readonly memoryRoot: string;
|
|
23
|
+
readonly gitRoot: string | null;
|
|
24
|
+
readonly canonicalRoot: string;
|
|
25
|
+
readonly claudeConfigDir: string;
|
|
26
|
+
readonly projectDir: string;
|
|
27
|
+
readonly memoryDir: string;
|
|
28
|
+
readonly entrypoint: string;
|
|
29
|
+
readonly stateDir: string;
|
|
30
|
+
constructor(memoryRoot: string, options: MemoryStoreOptions);
|
|
31
|
+
scan(): MemoryHeader[];
|
|
32
|
+
manifest(): string;
|
|
33
|
+
list(options?: ListOptions): MemoryEntry[];
|
|
34
|
+
read(fileName: string): MemoryEntry | null;
|
|
35
|
+
save(input: SaveMemoryInput): SaveMemoryResult;
|
|
36
|
+
delete(fileName: string): boolean;
|
|
37
|
+
search(query: string): MemoryEntry[];
|
|
38
|
+
readIndex(): string;
|
|
39
|
+
private writeIndex;
|
|
40
|
+
private isUnchanged;
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { buildFrontmatter } from "./frontmatter.js";
|
|
4
|
+
import { buildIndexPointer, indexHasPointer, readIndexFile, removeIndexLine, upsertIndexLine } from "./indexFile.js";
|
|
5
|
+
import { ENTRYPOINT_NAME, findCanonicalGitRoot, findGitRoot, MAX_MEMORY_FILE_BYTES, resolveMemoryFilePath, sanitizePath, } from "./paths.js";
|
|
6
|
+
import { formatMemoryManifest, readMemoryEntry, scanMemoryFiles } from "./scan.js";
|
|
7
|
+
// Owns the resolved memory paths for one project. Path resolution (git root, worktree canonical
|
|
8
|
+
// root, sanitised project key) happens exactly once, in the constructor.
|
|
9
|
+
export class MemoryStore {
|
|
10
|
+
memoryRoot;
|
|
11
|
+
gitRoot;
|
|
12
|
+
canonicalRoot;
|
|
13
|
+
claudeConfigDir;
|
|
14
|
+
projectDir;
|
|
15
|
+
memoryDir;
|
|
16
|
+
entrypoint;
|
|
17
|
+
// Plugin-private state (extraction watermarks, auto-dream gate) lives next to, not inside, the
|
|
18
|
+
// Claude Code project directory so Claude Code never sees it.
|
|
19
|
+
stateDir;
|
|
20
|
+
constructor(memoryRoot, options) {
|
|
21
|
+
this.memoryRoot = memoryRoot;
|
|
22
|
+
this.gitRoot = findGitRoot(memoryRoot);
|
|
23
|
+
this.canonicalRoot = findCanonicalGitRoot(memoryRoot) ?? memoryRoot;
|
|
24
|
+
this.claudeConfigDir = options.claudeConfigDir;
|
|
25
|
+
const projectKey = sanitizePath(this.canonicalRoot);
|
|
26
|
+
this.projectDir = join(this.claudeConfigDir, "projects", projectKey);
|
|
27
|
+
this.memoryDir = join(this.projectDir, "memory");
|
|
28
|
+
this.entrypoint = join(this.memoryDir, ENTRYPOINT_NAME);
|
|
29
|
+
this.stateDir = join(this.claudeConfigDir, "opencode-memory", projectKey);
|
|
30
|
+
mkdirSync(this.memoryDir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
scan() {
|
|
33
|
+
return scanMemoryFiles(this.memoryDir);
|
|
34
|
+
}
|
|
35
|
+
manifest() {
|
|
36
|
+
return formatMemoryManifest(this.scan());
|
|
37
|
+
}
|
|
38
|
+
list(options = {}) {
|
|
39
|
+
const entries = [];
|
|
40
|
+
for (const header of this.scan()) {
|
|
41
|
+
const entry = readMemoryEntry(this.memoryDir, header.filename);
|
|
42
|
+
if (entry)
|
|
43
|
+
entries.push(entry);
|
|
44
|
+
}
|
|
45
|
+
if ((options.sort ?? "name") === "name") {
|
|
46
|
+
entries.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
read(fileName) {
|
|
51
|
+
const { relativePath } = resolveMemoryFilePath(this.memoryDir, fileName);
|
|
52
|
+
return readMemoryEntry(this.memoryDir, relativePath);
|
|
53
|
+
}
|
|
54
|
+
save(input) {
|
|
55
|
+
const { relativePath, filePath } = resolveMemoryFilePath(this.memoryDir, input.fileName);
|
|
56
|
+
if (typeof input.name !== "string" || !input.name.trim()) {
|
|
57
|
+
throw new Error("Memory name is required");
|
|
58
|
+
}
|
|
59
|
+
const fileContent = `${buildFrontmatter(input)}\n\n${input.content.trim()}\n`;
|
|
60
|
+
if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
|
|
61
|
+
throw new Error(`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`);
|
|
62
|
+
}
|
|
63
|
+
const pointer = buildIndexPointer(relativePath, input.name, input.description);
|
|
64
|
+
if (this.isUnchanged(filePath, fileContent, pointer)) {
|
|
65
|
+
return { filePath, fileName: relativePath, unchanged: true };
|
|
66
|
+
}
|
|
67
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
68
|
+
writeFileSync(filePath, fileContent, "utf-8");
|
|
69
|
+
this.writeIndex(upsertIndexLine(this.readIndex(), relativePath, pointer));
|
|
70
|
+
return { filePath, fileName: relativePath, unchanged: false };
|
|
71
|
+
}
|
|
72
|
+
delete(fileName) {
|
|
73
|
+
const { relativePath, filePath } = resolveMemoryFilePath(this.memoryDir, fileName);
|
|
74
|
+
try {
|
|
75
|
+
unlinkSync(filePath);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
this.writeIndex(removeIndexLine(this.readIndex(), relativePath));
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
search(query) {
|
|
84
|
+
const lowerQuery = query.toLowerCase();
|
|
85
|
+
return this.list().filter((entry) => entry.name.toLowerCase().includes(lowerQuery) ||
|
|
86
|
+
entry.description.toLowerCase().includes(lowerQuery) ||
|
|
87
|
+
entry.body.toLowerCase().includes(lowerQuery));
|
|
88
|
+
}
|
|
89
|
+
readIndex() {
|
|
90
|
+
return readIndexFile(this.entrypoint);
|
|
91
|
+
}
|
|
92
|
+
writeIndex(content) {
|
|
93
|
+
writeFileSync(this.entrypoint, content, "utf-8");
|
|
94
|
+
}
|
|
95
|
+
isUnchanged(filePath, fileContent, pointer) {
|
|
96
|
+
try {
|
|
97
|
+
if (readFileSync(filePath, "utf-8") !== fileContent)
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return indexHasPointer(this.readIndex(), pointer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const MEMORY_TYPES: readonly ["user", "feedback", "project", "reference"];
|
|
2
|
+
export type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
3
|
+
export declare const FRONTMATTER_MAX_LINES = 30;
|
|
4
|
+
export type Frontmatter = {
|
|
5
|
+
name?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
type?: string;
|
|
8
|
+
[key: string]: string | undefined;
|
|
9
|
+
};
|
|
10
|
+
export type ParsedMemoryFile = {
|
|
11
|
+
frontmatter: Frontmatter;
|
|
12
|
+
body: string;
|
|
13
|
+
hasFrontmatter: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function parseFrontmatter(raw: string): ParsedMemoryFile;
|
|
16
|
+
export declare function parseFrontmatterHeader(raw: string): {
|
|
17
|
+
frontmatter: Frontmatter;
|
|
18
|
+
hasFrontmatter: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function buildFrontmatter(input: {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
type: MemoryType;
|
|
24
|
+
}): string;
|
|
25
|
+
export declare function parseMemoryType(raw: string | undefined): MemoryType | undefined;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The single definition of the memory file format. Every code path that reads or writes a memory
|
|
2
|
+
// file goes through this module, so a file can never be interpreted differently by two features.
|
|
3
|
+
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
|
|
4
|
+
// Matches Claude Code's memoryScan.ts: the closing `---` must appear within the first 30 lines.
|
|
5
|
+
export const FRONTMATTER_MAX_LINES = 30;
|
|
6
|
+
function parseFields(lines) {
|
|
7
|
+
const frontmatter = {};
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
const colonIdx = line.indexOf(":");
|
|
10
|
+
if (colonIdx === -1)
|
|
11
|
+
continue;
|
|
12
|
+
const key = line.slice(0, colonIdx).trim();
|
|
13
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
14
|
+
if (key && value)
|
|
15
|
+
frontmatter[key] = value;
|
|
16
|
+
}
|
|
17
|
+
return frontmatter;
|
|
18
|
+
}
|
|
19
|
+
function findClosingLine(lines) {
|
|
20
|
+
const limit = Math.min(lines.length, FRONTMATTER_MAX_LINES);
|
|
21
|
+
for (let i = 1; i < limit; i++) {
|
|
22
|
+
if (lines[i]?.trimEnd() === "---")
|
|
23
|
+
return i;
|
|
24
|
+
}
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
export function parseFrontmatter(raw) {
|
|
28
|
+
const trimmed = raw.trim();
|
|
29
|
+
if (!trimmed.startsWith("---")) {
|
|
30
|
+
return { frontmatter: {}, body: trimmed, hasFrontmatter: false };
|
|
31
|
+
}
|
|
32
|
+
const lines = trimmed.split("\n");
|
|
33
|
+
const closing = findClosingLine(lines);
|
|
34
|
+
if (closing === -1) {
|
|
35
|
+
return { frontmatter: {}, body: trimmed, hasFrontmatter: false };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
frontmatter: parseFields(lines.slice(1, closing)),
|
|
39
|
+
body: lines
|
|
40
|
+
.slice(closing + 1)
|
|
41
|
+
.join("\n")
|
|
42
|
+
.trim(),
|
|
43
|
+
hasFrontmatter: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Header-only variant for the directory scanner: parses the first FRONTMATTER_MAX_LINES lines and
|
|
47
|
+
// never materialises the body. Leading whitespace is dropped before counting lines, exactly as the
|
|
48
|
+
// full parser does, so both entry points agree on every file (leading blank lines included).
|
|
49
|
+
export function parseFrontmatterHeader(raw) {
|
|
50
|
+
const head = raw.trimStart().split("\n").slice(0, FRONTMATTER_MAX_LINES).join("\n");
|
|
51
|
+
const { frontmatter, hasFrontmatter } = parseFrontmatter(head);
|
|
52
|
+
return { frontmatter, hasFrontmatter };
|
|
53
|
+
}
|
|
54
|
+
export function buildFrontmatter(input) {
|
|
55
|
+
return `---\nname: ${input.name}\ndescription: ${input.description}\ntype: ${input.type}\n---`;
|
|
56
|
+
}
|
|
57
|
+
export function parseMemoryType(raw) {
|
|
58
|
+
if (!raw)
|
|
59
|
+
return undefined;
|
|
60
|
+
return MEMORY_TYPES.find((t) => t === raw);
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const POINTER_RE: RegExp;
|
|
2
|
+
export declare function readIndexFile(entrypoint: string): string;
|
|
3
|
+
export declare function buildIndexPointer(fileName: string, name: string, description: string): string;
|
|
4
|
+
export declare function indexHasPointer(raw: string, pointer: string): boolean;
|
|
5
|
+
export declare function pointerTarget(line: string): string | undefined;
|
|
6
|
+
export declare function upsertIndexLine(raw: string, fileName: string, pointer: string): string;
|
|
7
|
+
export declare function removeIndexLine(raw: string, fileName: string): string;
|
|
8
|
+
export type EntrypointTruncation = {
|
|
9
|
+
content: string;
|
|
10
|
+
lineCount: number;
|
|
11
|
+
byteCount: number;
|
|
12
|
+
wasLineTruncated: boolean;
|
|
13
|
+
wasByteTruncated: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function truncateEntrypoint(raw: string): EntrypointTruncation;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// MEMORY.md is shared with Claude Code and may be hand-organised (headings, blank-line groups,
|
|
2
|
+
// comments). Edits are therefore line-level: only the pointer line for the target file changes.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { ENTRYPOINT_NAME, MAX_ENTRYPOINT_BYTES, MAX_ENTRYPOINT_LINES } from "./paths.js";
|
|
5
|
+
// Only markdown list items of the form `- [Title](file.md) ...` count as index pointers. The label
|
|
6
|
+
// is matched lazily so titles containing brackets (`[Use [Bun]](x.md)`) resolve to the first
|
|
7
|
+
// `](target)` that looks like a file reference; the target may not contain whitespace or parens.
|
|
8
|
+
export const POINTER_RE = /^\s*[-*]\s+\[(.*?)\]\(([^()\s]+)\)/;
|
|
9
|
+
export function readIndexFile(entrypoint) {
|
|
10
|
+
try {
|
|
11
|
+
return readFileSync(entrypoint, "utf-8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function buildIndexPointer(fileName, name, description) {
|
|
18
|
+
return `- [${name}](${fileName}) — ${description}`;
|
|
19
|
+
}
|
|
20
|
+
export function indexHasPointer(raw, pointer) {
|
|
21
|
+
return raw.split(/\r?\n/).some((line) => line.trimEnd() === pointer);
|
|
22
|
+
}
|
|
23
|
+
function detectEol(raw) {
|
|
24
|
+
return raw.includes("\r\n") ? "\r\n" : "\n";
|
|
25
|
+
}
|
|
26
|
+
export function pointerTarget(line) {
|
|
27
|
+
const match = POINTER_RE.exec(line);
|
|
28
|
+
return match?.[2];
|
|
29
|
+
}
|
|
30
|
+
function splitIndex(raw) {
|
|
31
|
+
const eol = detectEol(raw);
|
|
32
|
+
const lines = raw.split(/\r?\n/);
|
|
33
|
+
const trailingNewline = raw.endsWith("\n");
|
|
34
|
+
if (trailingNewline)
|
|
35
|
+
lines.pop();
|
|
36
|
+
return { lines, eol, trailingNewline };
|
|
37
|
+
}
|
|
38
|
+
// Re-joins with the file's own line-ending style and its original end-of-file newline state: a
|
|
39
|
+
// file that did not end with a newline keeps not ending with one (05 §"MEMORY.md 最小编辑").
|
|
40
|
+
function joinIndex(parts) {
|
|
41
|
+
if (parts.lines.length === 0)
|
|
42
|
+
return "";
|
|
43
|
+
return parts.lines.join(parts.eol) + (parts.trailingNewline ? parts.eol : "");
|
|
44
|
+
}
|
|
45
|
+
// Removes every line except the first one that points at `fileName`; returns the index of the
|
|
46
|
+
// kept line (or -1). Duplicate pointers arise from hand edits or from older writers that failed to
|
|
47
|
+
// recognise a line, and would otherwise accumulate and leave dangling entries behind on delete.
|
|
48
|
+
function dedupePointers(lines, fileName, keepFirst) {
|
|
49
|
+
let kept = -1;
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
if (pointerTarget(lines[i] ?? "") !== fileName)
|
|
52
|
+
continue;
|
|
53
|
+
if (kept === -1 && keepFirst) {
|
|
54
|
+
kept = i;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
lines.splice(i, 1);
|
|
58
|
+
i -= 1;
|
|
59
|
+
}
|
|
60
|
+
return kept;
|
|
61
|
+
}
|
|
62
|
+
// Replaces the pointer line for `fileName` in place, or appends it after the last pointer line
|
|
63
|
+
// (keeping any grouping intact). Blank lines and unrelated lines are never touched.
|
|
64
|
+
export function upsertIndexLine(raw, fileName, pointer) {
|
|
65
|
+
if (!raw.trim())
|
|
66
|
+
return `${pointer}\n`;
|
|
67
|
+
const parts = splitIndex(raw);
|
|
68
|
+
const { lines } = parts;
|
|
69
|
+
const existingIdx = dedupePointers(lines, fileName, true);
|
|
70
|
+
if (existingIdx >= 0) {
|
|
71
|
+
const indent = /^\s*/.exec(lines[existingIdx] ?? "")?.[0] ?? "";
|
|
72
|
+
lines[existingIdx] = indent + pointer;
|
|
73
|
+
return joinIndex(parts);
|
|
74
|
+
}
|
|
75
|
+
let lastPointerIdx = -1;
|
|
76
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
77
|
+
if (POINTER_RE.test(lines[i] ?? "")) {
|
|
78
|
+
lastPointerIdx = i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (lastPointerIdx >= 0) {
|
|
83
|
+
lines.splice(lastPointerIdx + 1, 0, pointer);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// No pointers yet (e.g. only a heading): append, separated from the existing text by one blank line.
|
|
87
|
+
if (lines.length > 0 && (lines[lines.length - 1] ?? "").trim() !== "")
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push(pointer);
|
|
90
|
+
}
|
|
91
|
+
return joinIndex(parts);
|
|
92
|
+
}
|
|
93
|
+
// Removes the pointer line(s) for `fileName`; collapses the blank lines around it so the removal
|
|
94
|
+
// never leaves two consecutive blank lines behind.
|
|
95
|
+
export function removeIndexLine(raw, fileName) {
|
|
96
|
+
if (!raw)
|
|
97
|
+
return raw;
|
|
98
|
+
const parts = splitIndex(raw);
|
|
99
|
+
const { lines } = parts;
|
|
100
|
+
const idx = lines.findIndex((line) => pointerTarget(line) === fileName);
|
|
101
|
+
if (idx === -1)
|
|
102
|
+
return raw;
|
|
103
|
+
dedupePointers(lines, fileName, false);
|
|
104
|
+
const before = idx - 1;
|
|
105
|
+
const after = idx;
|
|
106
|
+
const isBlank = (i) => i >= 0 && i < lines.length && (lines[i] ?? "").trim() === "";
|
|
107
|
+
if (isBlank(before) && (isBlank(after) || after >= lines.length)) {
|
|
108
|
+
lines.splice(before, 1);
|
|
109
|
+
}
|
|
110
|
+
else if (isBlank(after) && before < 0) {
|
|
111
|
+
lines.splice(after, 1);
|
|
112
|
+
}
|
|
113
|
+
if (lines.every((line) => line.trim() === ""))
|
|
114
|
+
return "";
|
|
115
|
+
return joinIndex(parts);
|
|
116
|
+
}
|
|
117
|
+
function formatFileSize(bytes) {
|
|
118
|
+
if (bytes < 1024)
|
|
119
|
+
return `${bytes}B`;
|
|
120
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
121
|
+
}
|
|
122
|
+
// Port of Claude Code's truncateEntrypointContent() from memdir.ts.
|
|
123
|
+
// Uses .length (char count, same as Claude Code) for byte measurement.
|
|
124
|
+
export function truncateEntrypoint(raw) {
|
|
125
|
+
const trimmed = raw.trim();
|
|
126
|
+
if (!trimmed)
|
|
127
|
+
return { content: "", lineCount: 0, byteCount: 0, wasLineTruncated: false, wasByteTruncated: false };
|
|
128
|
+
const contentLines = trimmed.split("\n");
|
|
129
|
+
const lineCount = contentLines.length;
|
|
130
|
+
const byteCount = trimmed.length;
|
|
131
|
+
const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES;
|
|
132
|
+
const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES;
|
|
133
|
+
if (!wasLineTruncated && !wasByteTruncated) {
|
|
134
|
+
return { content: trimmed, lineCount, byteCount, wasLineTruncated, wasByteTruncated };
|
|
135
|
+
}
|
|
136
|
+
let truncated = wasLineTruncated ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join("\n") : trimmed;
|
|
137
|
+
if (truncated.length > MAX_ENTRYPOINT_BYTES) {
|
|
138
|
+
const cutAt = truncated.lastIndexOf("\n", MAX_ENTRYPOINT_BYTES);
|
|
139
|
+
truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES);
|
|
140
|
+
}
|
|
141
|
+
const reason = wasByteTruncated && !wasLineTruncated
|
|
142
|
+
? `${formatFileSize(byteCount)} (limit: ${formatFileSize(MAX_ENTRYPOINT_BYTES)}) — index entries are too long`
|
|
143
|
+
: wasLineTruncated && !wasByteTruncated
|
|
144
|
+
? `${lineCount} lines (limit: ${MAX_ENTRYPOINT_LINES})`
|
|
145
|
+
: `${lineCount} lines and ${formatFileSize(byteCount)}`;
|
|
146
|
+
return {
|
|
147
|
+
content: `${truncated}\n\n> WARNING: ${ENTRYPOINT_NAME} is ${reason}. Only part of it was loaded. Keep index entries to one line under ~200 chars; move detail into topic files.`,
|
|
148
|
+
lineCount,
|
|
149
|
+
byteCount,
|
|
150
|
+
wasLineTruncated,
|
|
151
|
+
wasByteTruncated,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -3,12 +3,13 @@ export declare const MAX_ENTRYPOINT_LINES = 200;
|
|
|
3
3
|
export declare const MAX_ENTRYPOINT_BYTES = 25000;
|
|
4
4
|
export declare const MAX_MEMORY_FILES = 200;
|
|
5
5
|
export declare const MAX_MEMORY_FILE_BYTES = 40000;
|
|
6
|
-
export declare const FRONTMATTER_MAX_LINES = 30;
|
|
7
6
|
export declare function validateMemoryFileName(fileName: string): string;
|
|
7
|
+
export declare function resolveMemoryFilePath(memoryDir: string, fileName: string): {
|
|
8
|
+
relativePath: string;
|
|
9
|
+
filePath: string;
|
|
10
|
+
};
|
|
8
11
|
export declare function sanitizePath(name: string): string;
|
|
12
|
+
export declare function findGitRoot(startPath: string): string | null;
|
|
9
13
|
export declare function findCanonicalGitRoot(startPath: string): string | null;
|
|
10
|
-
export declare function
|
|
11
|
-
export declare function
|
|
12
|
-
export declare function getMemoryEntrypoint(worktree: string): string;
|
|
13
|
-
export declare function isMemoryPath(absolutePath: string, worktree: string): boolean;
|
|
14
|
-
export declare function ensureDir(dir: string): void;
|
|
14
|
+
export declare function isRootPath(path: string): boolean;
|
|
15
|
+
export declare function resolveMemoryRoot(worktree: string, directory: string): string;
|