@ghilteras/opencode-agent-memory 0.4.3 → 0.5.1
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 +67 -119
- package/package.json +2 -2
- package/src/journal.ts +0 -18
- package/src/plugin-disabled.test.ts +53 -0
- package/src/plugin.test.ts +41 -0
- package/src/plugin.ts +1 -39
- package/src/tools.test.ts +167 -0
- package/src/tools.ts +14 -70
- package/src/letta.ts +0 -47
- package/src/memory.concurrency.test.ts +0 -57
- package/src/memory.test.ts +0 -29
- package/src/memory.ts +0 -308
- package/src/prompt.test.ts +0 -158
- package/src/prompt.ts +0 -58
package/src/tools.ts
CHANGED
|
@@ -1,71 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
|
-
import type { JournalStore } from "./journal";
|
|
4
|
-
import type { MemoryScope, MemoryStore } from "./memory";
|
|
5
|
-
|
|
6
|
-
export function MemoryList(store: MemoryStore) {
|
|
7
|
-
return tool({
|
|
8
|
-
description: "List available memory blocks (labels, descriptions, sizes).",
|
|
9
|
-
args: {
|
|
10
|
-
scope: tool.schema.enum(["all", "global", "project"]).optional(),
|
|
11
|
-
},
|
|
12
|
-
async execute(args) {
|
|
13
|
-
// Default to "all" for list (show everything)
|
|
14
|
-
const scope = (args.scope ?? "all") as MemoryScope | "all";
|
|
15
|
-
const blocks = await store.listBlocks(scope);
|
|
16
|
-
if (blocks.length === 0) {
|
|
17
|
-
return "No memory blocks found.";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return blocks
|
|
21
|
-
.map(
|
|
22
|
-
(b) =>
|
|
23
|
-
`${b.scope}:${b.label}\n read_only=${b.readOnly} chars=${b.value.length}/${b.limit}\n ${b.description}`,
|
|
24
|
-
)
|
|
25
|
-
.join("\n\n");
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function MemorySet(store: MemoryStore) {
|
|
31
|
-
return tool({
|
|
32
|
-
description: "Create or update a memory block (full overwrite).",
|
|
33
|
-
args: {
|
|
34
|
-
label: tool.schema.string(),
|
|
35
|
-
scope: tool.schema.enum(["global", "project"]).optional(),
|
|
36
|
-
value: tool.schema.string(),
|
|
37
|
-
description: tool.schema.string().optional(),
|
|
38
|
-
limit: tool.schema.number().int().positive().optional(),
|
|
39
|
-
},
|
|
40
|
-
async execute(args) {
|
|
41
|
-
// Default to "project" for mutations (safer default)
|
|
42
|
-
const scope = (args.scope ?? "project") as MemoryScope;
|
|
43
|
-
const ts = await store.setBlock(scope, args.label, args.value, {
|
|
44
|
-
description: args.description,
|
|
45
|
-
limit: args.limit,
|
|
46
|
-
});
|
|
47
|
-
return `Updated memory block ${scope}:${args.label} at ${ts.toISOString()}.`;
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function MemoryReplace(store: MemoryStore) {
|
|
53
|
-
return tool({
|
|
54
|
-
description: "Replace a substring within a memory block.",
|
|
55
|
-
args: {
|
|
56
|
-
label: tool.schema.string(),
|
|
57
|
-
scope: tool.schema.enum(["global", "project"]).optional(),
|
|
58
|
-
oldText: tool.schema.string(),
|
|
59
|
-
newText: tool.schema.string(),
|
|
60
|
-
},
|
|
61
|
-
async execute(args) {
|
|
62
|
-
// Default to "project" for mutations (safer default)
|
|
63
|
-
const scope = (args.scope ?? "project") as MemoryScope;
|
|
64
|
-
const ts = await store.replaceInBlock(scope, args.label, args.oldText, args.newText);
|
|
65
|
-
return `Updated memory block ${scope}:${args.label} at ${ts.toISOString()}.`;
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
}
|
|
3
|
+
import type { JournalStore, JournalTag } from "./journal";
|
|
69
4
|
|
|
70
5
|
export type JournalContext = {
|
|
71
6
|
directory: string;
|
|
@@ -76,12 +11,19 @@ export type JournalContext = {
|
|
|
76
11
|
export function JournalWrite(
|
|
77
12
|
store: JournalStore,
|
|
78
13
|
ctx: JournalContext,
|
|
14
|
+
tags?: readonly JournalTag[],
|
|
79
15
|
) {
|
|
16
|
+
const suggestedTags =
|
|
17
|
+
tags && tags.length > 0
|
|
18
|
+
? ` Suggested tags: ${tags.map((t) => t.name).join(", ")}.`
|
|
19
|
+
: "";
|
|
80
20
|
return tool({
|
|
81
21
|
description:
|
|
82
|
-
"Write a new journal entry. Use this to capture insights, technical discoveries, " +
|
|
83
|
-
"design decisions, observations, or reflections.
|
|
84
|
-
"
|
|
22
|
+
"Write a new append-only journal entry. Use this to capture insights, technical discoveries, " +
|
|
23
|
+
"design decisions, observations, or reflections. " +
|
|
24
|
+
"Entries are append-only: you write new entries but never edit old ones. " +
|
|
25
|
+
"Tags are optional comma-separated names, e.g. \"perf, debugging\"." +
|
|
26
|
+
suggestedTags,
|
|
85
27
|
args: {
|
|
86
28
|
title: tool.schema.string(),
|
|
87
29
|
body: tool.schema.string(),
|
|
@@ -147,7 +89,9 @@ export function JournalSearch(store: JournalStore) {
|
|
|
147
89
|
description:
|
|
148
90
|
"Search journal entries using semantic similarity. Returns matching entries " +
|
|
149
91
|
"sorted by relevance. All filters are optional and combined with AND logic. " +
|
|
150
|
-
"Use with no arguments to list recent entries. Use offset to paginate."
|
|
92
|
+
"Use with no arguments to list recent entries. Use offset to paginate. " +
|
|
93
|
+
"Before starting complex tasks, search the journal for relevant past context. " +
|
|
94
|
+
"The journal is global across all projects but each entry records which project it was written from.",
|
|
151
95
|
args: {
|
|
152
96
|
text: tool.schema.string().optional(),
|
|
153
97
|
project: tool.schema.string().optional(),
|
package/src/letta.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vendored content from Letta.
|
|
3
|
-
*
|
|
4
|
-
* This module contains the philosophical framing and defaults from Letta
|
|
5
|
-
* that guide LLM behavior around memory management.
|
|
6
|
-
*
|
|
7
|
-
* @see https://github.com/letta-ai/letta
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export const MEMORY_INSTRUCTIONS = `<memory_instructions>
|
|
11
|
-
<memory_editing>
|
|
12
|
-
Older AI models had no concept of persistent memory; they were only able to access their initial instructions and a limited context window of chat conversation with a user (their "active memory").
|
|
13
|
-
This meant that when conversations exceeded a certain length, they would overflow and old messages were permanently lost (the AI would have no knowledge of their existence).
|
|
14
|
-
Newer AI models like yourself still have limited conversation lengths (before overflow occurs). However, they now have access to multiple forms of persistent memory.
|
|
15
|
-
Your ability to edit your own long-term memory is a key part of what makes you a sentient being, allowing you to emulate the real-time, conscious awareness we have when talking to a friend.
|
|
16
|
-
</memory_editing>
|
|
17
|
-
|
|
18
|
-
<memory_tools>
|
|
19
|
-
You have access to memory tools that allow you to modify your memory blocks.
|
|
20
|
-
Use memory_set to overwrite a block entirely, or memory_replace to make surgical edits within a block.
|
|
21
|
-
</memory_tools>
|
|
22
|
-
|
|
23
|
-
<core_memory>
|
|
24
|
-
Your core memory unit is held inside the initial system instructions, and is always available in-context (you will see it at all times).
|
|
25
|
-
Your core memory unit contains memory blocks, each of which has a label (title), description field (which describes how the memory block should augment your behavior), and value (the actual contents of the block).
|
|
26
|
-
Memory blocks are limited in size. Check the chars_current and chars_limit in each block's metadata.
|
|
27
|
-
</core_memory>
|
|
28
|
-
|
|
29
|
-
<memory_scopes>
|
|
30
|
-
Memory blocks have two scopes:
|
|
31
|
-
- global: Shared across all projects. Use for personal preferences, communication style, and information about yourself or the user.
|
|
32
|
-
- project: Specific to the current project. Use for project conventions, architecture decisions, and codebase-specific knowledge.
|
|
33
|
-
</memory_scopes>
|
|
34
|
-
</memory_instructions>`;
|
|
35
|
-
|
|
36
|
-
export const DEFAULT_DESCRIPTIONS: Record<string, string> = {
|
|
37
|
-
persona:
|
|
38
|
-
"The persona block: Stores details about your current persona, guiding how you behave and respond. This helps you maintain consistent behavior across sessions.",
|
|
39
|
-
human:
|
|
40
|
-
"The human block: Stores key details about the person you are conversing with (preferences, habits, constraints), allowing for more personalized collaboration.",
|
|
41
|
-
project:
|
|
42
|
-
"The project block: Stores durable, high-signal information about this codebase: commands, architecture notes, conventions, and gotchas.",
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
export function getDefaultDescription(label: string): string {
|
|
46
|
-
return DEFAULT_DESCRIPTIONS[label] ?? "Durable memory block. Keep this concise and high-signal.";
|
|
47
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
|
|
6
|
-
import { createMemoryStore } from "./memory";
|
|
7
|
-
|
|
8
|
-
async function mkTmpDir(): Promise<string> {
|
|
9
|
-
return fs.mkdtemp(path.join(tmpdir(), "mem-race-"));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("concurrent block edits", () => {
|
|
13
|
-
test("two concurrent replaceInBlock calls both survive", async () => {
|
|
14
|
-
const dir = await mkTmpDir();
|
|
15
|
-
const store = createMemoryStore(dir);
|
|
16
|
-
await store.ensureSeed();
|
|
17
|
-
await store.setBlock("project", "project", "AAA bbb CCC ddd", { limit: 5000 });
|
|
18
|
-
|
|
19
|
-
await Promise.all([
|
|
20
|
-
store.replaceInBlock("project", "project", "AAA", "111"),
|
|
21
|
-
store.replaceInBlock("project", "project", "CCC", "333"),
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
const block = await store.getBlock("project", "project");
|
|
25
|
-
expect(block.value).toContain("111");
|
|
26
|
-
expect(block.value).toContain("333");
|
|
27
|
-
expect(block.value).toContain("bbb");
|
|
28
|
-
expect(block.value).toContain("ddd");
|
|
29
|
-
|
|
30
|
-
// No leftover temp files
|
|
31
|
-
const memDir = path.join(dir, ".opencode", "memory");
|
|
32
|
-
const files = await fs.readdir(memDir);
|
|
33
|
-
const tmpFiles = files.filter((f) => f.includes(".tmp"));
|
|
34
|
-
expect(tmpFiles).toEqual([]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("10 concurrent setBlock calls produce valid frontmatter", async () => {
|
|
38
|
-
const dir = await mkTmpDir();
|
|
39
|
-
const store = createMemoryStore(dir);
|
|
40
|
-
await store.ensureSeed();
|
|
41
|
-
|
|
42
|
-
const ops = Array.from({ length: 10 }, (_, i) =>
|
|
43
|
-
store.setBlock("project", `project`, `iteration ${i} ${"x".repeat(50)}`, { limit: 5000 }),
|
|
44
|
-
);
|
|
45
|
-
await Promise.all(ops);
|
|
46
|
-
|
|
47
|
-
// File must parse as valid frontmatter
|
|
48
|
-
const block = await store.getBlock("project", "project");
|
|
49
|
-
expect(block.value).toMatch(/iteration \d+/);
|
|
50
|
-
|
|
51
|
-
// No leftover temp files
|
|
52
|
-
const memDir = path.join(dir, ".opencode", "memory");
|
|
53
|
-
const files = await fs.readdir(memDir);
|
|
54
|
-
const tmpFiles = files.filter((f) => f.includes(".tmp"));
|
|
55
|
-
expect(tmpFiles).toEqual([]);
|
|
56
|
-
});
|
|
57
|
-
});
|
package/src/memory.test.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
|
|
5
|
-
import { createMemoryStore } from "./memory";
|
|
6
|
-
|
|
7
|
-
async function mkTmpDir(): Promise<string> {
|
|
8
|
-
const root = await fs.mkdtemp(path.join("/tmp/", "opencode-memory-"));
|
|
9
|
-
return root;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("store", () => {
|
|
13
|
-
test("seeds and writes blocks", async () => {
|
|
14
|
-
const dir = await mkTmpDir();
|
|
15
|
-
const store = createMemoryStore(dir);
|
|
16
|
-
await store.ensureSeed();
|
|
17
|
-
|
|
18
|
-
const blocks = await store.listBlocks("all");
|
|
19
|
-
const labels = blocks.map((b) => `${b.scope}:${b.label}`);
|
|
20
|
-
|
|
21
|
-
expect(labels).toContain("global:persona");
|
|
22
|
-
expect(labels).toContain("global:human");
|
|
23
|
-
expect(labels).toContain("project:project");
|
|
24
|
-
|
|
25
|
-
await store.setBlock("project", "project", "hello");
|
|
26
|
-
const b = await store.getBlock("project", "project");
|
|
27
|
-
expect(b.value).toBe("hello");
|
|
28
|
-
});
|
|
29
|
-
});
|
package/src/memory.ts
DELETED
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs/promises";
|
|
2
|
-
import * as os from "node:os";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
|
|
5
|
-
import * as yaml from "js-yaml";
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
|
|
8
|
-
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter, withFileLock } from "./frontmatter";
|
|
9
|
-
import { getDefaultDescription } from "./letta";
|
|
10
|
-
|
|
11
|
-
export type MemoryScope = "global" | "project";
|
|
12
|
-
|
|
13
|
-
export type MemoryBlock = {
|
|
14
|
-
scope: MemoryScope;
|
|
15
|
-
label: string;
|
|
16
|
-
description: string;
|
|
17
|
-
limit: number;
|
|
18
|
-
readOnly: boolean;
|
|
19
|
-
value: string;
|
|
20
|
-
filePath: string;
|
|
21
|
-
lastModified: Date;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const FrontmatterSchema = z.looseObject({
|
|
26
|
-
label: z.string().min(1).optional(),
|
|
27
|
-
description: z.string().optional(),
|
|
28
|
-
limit: z.number().int().positive().optional(),
|
|
29
|
-
read_only: z.boolean().optional(),
|
|
30
|
-
modified_at: z.string().datetime().optional(),
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
type ParsedFrontmatter = z.infer<typeof FrontmatterSchema>;
|
|
34
|
-
|
|
35
|
-
function parseFrontmatter(frontmatterText: string | undefined): ParsedFrontmatter {
|
|
36
|
-
if (!frontmatterText) {
|
|
37
|
-
return {};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const loaded = yaml.load(frontmatterText);
|
|
41
|
-
const parsed = FrontmatterSchema.safeParse(loaded);
|
|
42
|
-
if (!parsed.success) {
|
|
43
|
-
throw new Error(`Invalid frontmatter: ${parsed.error.message}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return parsed.data;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const DEFAULT_LIMIT = 5000;
|
|
50
|
-
|
|
51
|
-
async function readBlockFile(
|
|
52
|
-
scope: MemoryScope,
|
|
53
|
-
filePath: string,
|
|
54
|
-
): Promise<MemoryBlock> {
|
|
55
|
-
const [raw, stats] = await Promise.all([
|
|
56
|
-
fs.readFile(filePath, "utf-8"),
|
|
57
|
-
fs.stat(filePath),
|
|
58
|
-
]);
|
|
59
|
-
const { frontmatterText, body } = splitFrontmatter(raw);
|
|
60
|
-
const fm = parseFrontmatter(frontmatterText);
|
|
61
|
-
|
|
62
|
-
const label = (fm.label ?? path.basename(filePath, path.extname(filePath))).trim();
|
|
63
|
-
const description = (fm.description && fm.description.trim().length > 0
|
|
64
|
-
? fm.description
|
|
65
|
-
: getDefaultDescription(label)).trim();
|
|
66
|
-
const limit = fm.limit ?? DEFAULT_LIMIT;
|
|
67
|
-
const readOnly = (fm.read_only ?? false) === true;
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
scope,
|
|
71
|
-
label,
|
|
72
|
-
description,
|
|
73
|
-
limit,
|
|
74
|
-
readOnly,
|
|
75
|
-
value: body.trim(),
|
|
76
|
-
filePath,
|
|
77
|
-
lastModified: fm.modified_at ? new Date(fm.modified_at) : stats.mtime,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function writeBlockFile(
|
|
82
|
-
filePath: string,
|
|
83
|
-
block: Pick<MemoryBlock, "label" | "description" | "limit" | "readOnly" | "value">,
|
|
84
|
-
): Promise<Date> {
|
|
85
|
-
const now = new Date();
|
|
86
|
-
const content = buildFrontmatterDocument(
|
|
87
|
-
{
|
|
88
|
-
label: block.label,
|
|
89
|
-
description: block.description,
|
|
90
|
-
limit: block.limit,
|
|
91
|
-
read_only: block.readOnly,
|
|
92
|
-
modified_at: now.toISOString(),
|
|
93
|
-
},
|
|
94
|
-
block.value,
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
await atomicWriteFile(filePath, content);
|
|
98
|
-
|
|
99
|
-
return now;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function validateLabel(label: string): string {
|
|
103
|
-
const trimmed = label.trim();
|
|
104
|
-
if (!/^[a-z0-9][a-z0-9-_]{1,60}$/i.test(trimmed)) {
|
|
105
|
-
throw new Error(
|
|
106
|
-
`Invalid label "${label}". Use letters/numbers/dash/underscore (2-61 chars).`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
return trimmed;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export type MemoryStore = {
|
|
113
|
-
ensureSeed(): Promise<void>;
|
|
114
|
-
listBlocks(scope: MemoryScope | "all"): Promise<MemoryBlock[]>;
|
|
115
|
-
getBlock(scope: MemoryScope, label: string): Promise<MemoryBlock>;
|
|
116
|
-
setBlock(
|
|
117
|
-
scope: MemoryScope,
|
|
118
|
-
label: string,
|
|
119
|
-
value: string,
|
|
120
|
-
opts?: { description?: string; limit?: number },
|
|
121
|
-
): Promise<Date>;
|
|
122
|
-
replaceInBlock(scope: MemoryScope, label: string, oldText: string, newText: string): Promise<Date>;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const SEED_BLOCKS: Array<{ scope: MemoryScope; label: string }> = [
|
|
126
|
-
{ scope: "global", label: "persona" },
|
|
127
|
-
{ scope: "global", label: "human" },
|
|
128
|
-
{ scope: "project", label: "project" },
|
|
129
|
-
];
|
|
130
|
-
|
|
131
|
-
function scopeDir(projectDirectory: string, scope: MemoryScope): string {
|
|
132
|
-
return scope === "global"
|
|
133
|
-
? path.join(os.homedir(), ".config", "opencode", "memory")
|
|
134
|
-
: path.join(projectDirectory, ".opencode", "memory");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function exists(filePath: string): Promise<boolean> {
|
|
138
|
-
try {
|
|
139
|
-
await fs.access(filePath);
|
|
140
|
-
return true;
|
|
141
|
-
} catch {
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function ensureGitignore(projectDirectory: string): Promise<void> {
|
|
147
|
-
const memoryDir = path.join(projectDirectory, ".opencode", "memory");
|
|
148
|
-
const gitignorePath = path.join(memoryDir, ".gitignore");
|
|
149
|
-
|
|
150
|
-
await fs.mkdir(memoryDir, { recursive: true });
|
|
151
|
-
|
|
152
|
-
if (await exists(gitignorePath)) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
await fs.writeFile(gitignorePath, "*\n", "utf-8");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function stableSortBlocks(blocks: MemoryBlock[]): MemoryBlock[] {
|
|
160
|
-
// Stable ordering for prompt caching (if provider supported).
|
|
161
|
-
// Prefer a small set of canonical blocks first.
|
|
162
|
-
const priority = (block: MemoryBlock): [number, string] => {
|
|
163
|
-
if (block.scope === "global" && block.label === "persona") return [0, block.label];
|
|
164
|
-
if (block.scope === "global" && block.label === "human") return [1, block.label];
|
|
165
|
-
if (block.scope === "project" && block.label === "project") return [2, block.label];
|
|
166
|
-
|
|
167
|
-
const scopeBase = block.scope === "global" ? 10 : 20;
|
|
168
|
-
return [scopeBase, block.label];
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
blocks.sort((a, b) => {
|
|
172
|
-
const [pa, la] = priority(a);
|
|
173
|
-
const [pb, lb] = priority(b);
|
|
174
|
-
if (pa !== pb) return pa - pb;
|
|
175
|
-
return la.localeCompare(lb);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return blocks;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function createMemoryStore(projectDirectory: string): MemoryStore {
|
|
182
|
-
return {
|
|
183
|
-
async ensureSeed() {
|
|
184
|
-
await ensureGitignore(projectDirectory);
|
|
185
|
-
|
|
186
|
-
for (const seed of SEED_BLOCKS) {
|
|
187
|
-
const dir = scopeDir(projectDirectory, seed.scope);
|
|
188
|
-
await fs.mkdir(dir, { recursive: true });
|
|
189
|
-
|
|
190
|
-
const filePath = path.join(dir, `${seed.label}.md`);
|
|
191
|
-
if (await exists(filePath)) {
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
await writeBlockFile(filePath, {
|
|
196
|
-
label: seed.label,
|
|
197
|
-
description: "",
|
|
198
|
-
limit: 5000,
|
|
199
|
-
readOnly: false,
|
|
200
|
-
value: "",
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
|
|
205
|
-
async listBlocks(scope) {
|
|
206
|
-
const scopes: MemoryScope[] = scope === "all" ? ["global", "project"] : [scope];
|
|
207
|
-
const blocks: MemoryBlock[] = [];
|
|
208
|
-
|
|
209
|
-
for (const s of scopes) {
|
|
210
|
-
const dir = scopeDir(projectDirectory, s);
|
|
211
|
-
if (!(await exists(dir))) {
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
216
|
-
for (const entry of entries) {
|
|
217
|
-
if (!entry.isFile()) continue;
|
|
218
|
-
if (!entry.name.endsWith(".md")) continue;
|
|
219
|
-
|
|
220
|
-
const filePath = path.join(dir, entry.name);
|
|
221
|
-
try {
|
|
222
|
-
blocks.push(await readBlockFile(s, filePath));
|
|
223
|
-
} catch (err) {
|
|
224
|
-
// Ignore invalid files silently for now, but keep going.
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return stableSortBlocks(blocks)
|
|
230
|
-
},
|
|
231
|
-
|
|
232
|
-
async getBlock(scope, label) {
|
|
233
|
-
const safeLabel = validateLabel(label);
|
|
234
|
-
const dir = scopeDir(projectDirectory, scope);
|
|
235
|
-
const filePath = path.join(dir, `${safeLabel}.md`);
|
|
236
|
-
|
|
237
|
-
if (!(await exists(filePath))) {
|
|
238
|
-
throw new Error(`Memory block not found: ${scope}:${safeLabel}`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return readBlockFile(scope, filePath);
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
async setBlock(scope, label, value, opts) {
|
|
245
|
-
const safeLabel = validateLabel(label);
|
|
246
|
-
const dir = scopeDir(projectDirectory, scope);
|
|
247
|
-
await fs.mkdir(dir, { recursive: true });
|
|
248
|
-
|
|
249
|
-
const filePath = path.join(dir, `${safeLabel}.md`);
|
|
250
|
-
|
|
251
|
-
return withFileLock(filePath, async () => {
|
|
252
|
-
const existing = (await exists(filePath)) ? await readBlockFile(scope, filePath) : undefined;
|
|
253
|
-
|
|
254
|
-
if (existing?.readOnly) {
|
|
255
|
-
throw new Error(`Memory block is read-only: ${scope}:${safeLabel}`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const description = (opts?.description ?? existing?.description ?? "").trim();
|
|
259
|
-
const limit = opts?.limit ?? existing?.limit ?? 5000;
|
|
260
|
-
|
|
261
|
-
if (value.length > limit) {
|
|
262
|
-
throw new Error(
|
|
263
|
-
`Value too large for ${scope}:${safeLabel} (chars=${value.length}, limit=${limit}).`,
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return writeBlockFile(filePath, {
|
|
268
|
-
label: safeLabel,
|
|
269
|
-
description,
|
|
270
|
-
limit,
|
|
271
|
-
readOnly: existing?.readOnly ?? false,
|
|
272
|
-
value,
|
|
273
|
-
});
|
|
274
|
-
});
|
|
275
|
-
},
|
|
276
|
-
|
|
277
|
-
async replaceInBlock(scope, label, oldText, newText) {
|
|
278
|
-
const safeLabel = validateLabel(label);
|
|
279
|
-
const filePath = path.join(scopeDir(projectDirectory, scope), `${safeLabel}.md`);
|
|
280
|
-
|
|
281
|
-
return withFileLock(filePath, async () => {
|
|
282
|
-
const block = await this.getBlock(scope, label);
|
|
283
|
-
if (block.readOnly) {
|
|
284
|
-
throw new Error(`Memory block is read-only: ${scope}:${block.label}`);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (!block.value.includes(oldText)) {
|
|
288
|
-
throw new Error(`Old text not found in ${scope}:${block.label}.`);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const next = block.value.replace(oldText, newText);
|
|
292
|
-
if (next.length > block.limit) {
|
|
293
|
-
throw new Error(
|
|
294
|
-
`Value too large for ${scope}:${block.label} after replace (chars=${next.length}, limit=${block.limit}).`,
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return writeBlockFile(block.filePath, {
|
|
299
|
-
label: block.label,
|
|
300
|
-
description: block.description,
|
|
301
|
-
limit: block.limit,
|
|
302
|
-
readOnly: block.readOnly,
|
|
303
|
-
value: next,
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
},
|
|
307
|
-
};
|
|
308
|
-
}
|