@ghilteras/opencode-agent-memory 0.3.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/LICENSE +9 -0
- package/README.md +180 -0
- package/package.json +42 -0
- package/src/embeddings.ts +46 -0
- package/src/frontmatter.ts +47 -0
- package/src/journal.test.ts +384 -0
- package/src/journal.ts +358 -0
- package/src/letta.ts +47 -0
- package/src/memory.test.ts +29 -0
- package/src/memory.ts +300 -0
- package/src/plugin.ts +80 -0
- package/src/prompt.test.ts +158 -0
- package/src/prompt.ts +58 -0
- package/src/tools.ts +200 -0
package/src/journal.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
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 yaml from "js-yaml";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { cosineSimilarity, generateEmbedding } from "./embeddings";
|
|
9
|
+
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
|
|
10
|
+
|
|
11
|
+
const TagSchema = z.looseObject({
|
|
12
|
+
name: z.string().min(1),
|
|
13
|
+
description: z.string().min(1),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const ConfigSchema = z.looseObject({
|
|
17
|
+
journal: z
|
|
18
|
+
.looseObject({
|
|
19
|
+
enabled: z.boolean().optional(),
|
|
20
|
+
tags: z.array(TagSchema).optional(),
|
|
21
|
+
})
|
|
22
|
+
.optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type AgentMemoryConfig = z.infer<typeof ConfigSchema>;
|
|
26
|
+
|
|
27
|
+
export async function loadConfig(
|
|
28
|
+
configDir?: string,
|
|
29
|
+
): Promise<AgentMemoryConfig> {
|
|
30
|
+
const dir = configDir ?? path.join(os.homedir(), ".config", "opencode");
|
|
31
|
+
const configPath = path.join(dir, "agent-memory.json");
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fs.readFile(configPath, "utf-8");
|
|
34
|
+
const parsed = ConfigSchema.safeParse(JSON.parse(raw));
|
|
35
|
+
if (!parsed.success) return {};
|
|
36
|
+
return parsed.data;
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type JournalTag = {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const EntryFrontmatterSchema = z.looseObject({
|
|
48
|
+
title: z.string().min(1),
|
|
49
|
+
project: z.string().optional(),
|
|
50
|
+
model: z.string().optional(),
|
|
51
|
+
provider: z.string().optional(),
|
|
52
|
+
agent: z.string().optional(),
|
|
53
|
+
session_id: z.string().optional(),
|
|
54
|
+
created: z.string().optional(),
|
|
55
|
+
tags: z.array(z.string().min(1)).optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export type JournalEntry = {
|
|
59
|
+
id: string;
|
|
60
|
+
title: string;
|
|
61
|
+
project: string;
|
|
62
|
+
model: string;
|
|
63
|
+
provider: string;
|
|
64
|
+
agent: string;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
created: Date;
|
|
67
|
+
tags: string[];
|
|
68
|
+
body: string;
|
|
69
|
+
filePath: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function entryFilename(date: Date): string {
|
|
73
|
+
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
|
74
|
+
return [
|
|
75
|
+
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`,
|
|
76
|
+
"-",
|
|
77
|
+
`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`,
|
|
78
|
+
"-",
|
|
79
|
+
`${pad(date.getUTCMilliseconds(), 3)}`,
|
|
80
|
+
".md",
|
|
81
|
+
].join("");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function embeddingPath(entryPath: string): string {
|
|
85
|
+
return entryPath.replace(/\.md$/, ".embedding");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readEntryFile(filePath: string): Promise<JournalEntry> {
|
|
89
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
90
|
+
const { frontmatterText, body } = splitFrontmatter(raw);
|
|
91
|
+
|
|
92
|
+
if (!frontmatterText) {
|
|
93
|
+
throw new Error(`Journal entry missing frontmatter: ${filePath}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const loaded = yaml.load(frontmatterText);
|
|
97
|
+
const parsed = EntryFrontmatterSchema.safeParse(loaded);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
throw new Error(`Invalid journal frontmatter in ${filePath}: ${parsed.error.message}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const fm = parsed.data;
|
|
103
|
+
const id = path.basename(filePath, ".md");
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
title: fm.title,
|
|
108
|
+
project: fm.project ?? "",
|
|
109
|
+
model: fm.model ?? "",
|
|
110
|
+
provider: fm.provider ?? "",
|
|
111
|
+
agent: fm.agent ?? "",
|
|
112
|
+
sessionId: fm.session_id ?? "",
|
|
113
|
+
created: fm.created ? new Date(fm.created) : new Date(),
|
|
114
|
+
tags: fm.tags ?? [],
|
|
115
|
+
body: body.trim(),
|
|
116
|
+
filePath,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function loadEmbedding(entryPath: string): Promise<number[] | undefined> {
|
|
121
|
+
const ePath = embeddingPath(entryPath);
|
|
122
|
+
try {
|
|
123
|
+
const raw = await fs.readFile(ePath, "utf-8");
|
|
124
|
+
return JSON.parse(raw) as number[];
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
131
|
+
|
|
132
|
+
function validateId(id: string): string {
|
|
133
|
+
const trimmed = id.trim();
|
|
134
|
+
if (!SAFE_ID.test(trimmed)) {
|
|
135
|
+
throw new Error(`Invalid journal entry ID: "${id}"`);
|
|
136
|
+
}
|
|
137
|
+
return trimmed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type JournalStore = {
|
|
141
|
+
write(entry: {
|
|
142
|
+
title: string;
|
|
143
|
+
body: string;
|
|
144
|
+
project?: string;
|
|
145
|
+
model?: string;
|
|
146
|
+
provider?: string;
|
|
147
|
+
agent?: string;
|
|
148
|
+
sessionId?: string;
|
|
149
|
+
tags?: string[];
|
|
150
|
+
}): Promise<JournalEntry>;
|
|
151
|
+
|
|
152
|
+
read(id: string): Promise<JournalEntry>;
|
|
153
|
+
|
|
154
|
+
search(query: {
|
|
155
|
+
text?: string;
|
|
156
|
+
project?: string;
|
|
157
|
+
tags?: string[];
|
|
158
|
+
limit?: number;
|
|
159
|
+
offset?: number;
|
|
160
|
+
}): Promise<{ entries: JournalEntry[]; total: number; allTags: string[] }>;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export function createJournalStore(configDir?: string): JournalStore {
|
|
164
|
+
const journalDir = path.join(
|
|
165
|
+
configDir ?? path.join(os.homedir(), ".config", "opencode"),
|
|
166
|
+
"journal",
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
async write(entry) {
|
|
171
|
+
await fs.mkdir(journalDir, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const created = new Date();
|
|
174
|
+
const filename = entryFilename(created);
|
|
175
|
+
const filePath = path.join(journalDir, filename);
|
|
176
|
+
|
|
177
|
+
const frontmatter: Record<string, unknown> = {
|
|
178
|
+
title: entry.title,
|
|
179
|
+
created: created.toISOString(),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (entry.project) frontmatter.project = entry.project;
|
|
183
|
+
if (entry.model) frontmatter.model = entry.model;
|
|
184
|
+
if (entry.provider) frontmatter.provider = entry.provider;
|
|
185
|
+
if (entry.agent) frontmatter.agent = entry.agent;
|
|
186
|
+
if (entry.sessionId) frontmatter.session_id = entry.sessionId;
|
|
187
|
+
if (entry.tags && entry.tags.length > 0) frontmatter.tags = entry.tags;
|
|
188
|
+
|
|
189
|
+
const content = buildFrontmatterDocument(frontmatter, entry.body);
|
|
190
|
+
await atomicWriteFile(filePath, content);
|
|
191
|
+
|
|
192
|
+
// Generate and save embedding for semantic search
|
|
193
|
+
const searchableText = `${entry.title}\n${entry.body}`;
|
|
194
|
+
try {
|
|
195
|
+
const embedding = await generateEmbedding(searchableText);
|
|
196
|
+
await fs.writeFile(
|
|
197
|
+
embeddingPath(filePath),
|
|
198
|
+
JSON.stringify(embedding),
|
|
199
|
+
"utf-8",
|
|
200
|
+
);
|
|
201
|
+
} catch {
|
|
202
|
+
// Embedding generation can fail (e.g. model download issue).
|
|
203
|
+
// The entry is still saved; text search remains available.
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
id: path.basename(filePath, ".md"),
|
|
208
|
+
title: entry.title,
|
|
209
|
+
project: entry.project ?? "",
|
|
210
|
+
model: entry.model ?? "",
|
|
211
|
+
provider: entry.provider ?? "",
|
|
212
|
+
agent: entry.agent ?? "",
|
|
213
|
+
sessionId: entry.sessionId ?? "",
|
|
214
|
+
created,
|
|
215
|
+
tags: entry.tags ?? [],
|
|
216
|
+
body: entry.body,
|
|
217
|
+
filePath,
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async read(id) {
|
|
222
|
+
const safeId = validateId(id);
|
|
223
|
+
const filePath = path.join(journalDir, `${safeId}.md`);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await fs.access(filePath);
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error(`Journal entry not found: ${safeId}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return readEntryFile(filePath);
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async search(query) {
|
|
235
|
+
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
|
|
236
|
+
const offset = Math.max(query.offset ?? 0, 0);
|
|
237
|
+
|
|
238
|
+
let entries: { entry: JournalEntry; score: number }[] = [];
|
|
239
|
+
|
|
240
|
+
// Read all entry files
|
|
241
|
+
let files: string[];
|
|
242
|
+
try {
|
|
243
|
+
const dirEntries = await fs.readdir(journalDir, {
|
|
244
|
+
withFileTypes: true,
|
|
245
|
+
});
|
|
246
|
+
files = dirEntries
|
|
247
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
248
|
+
.map((e) => e.name)
|
|
249
|
+
.sort()
|
|
250
|
+
.reverse(); // Newest first
|
|
251
|
+
} catch {
|
|
252
|
+
return { entries: [], total: 0, allTags: [] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// If a text query is provided, try semantic search first
|
|
256
|
+
let queryEmbedding: number[] | undefined;
|
|
257
|
+
if (query.text) {
|
|
258
|
+
try {
|
|
259
|
+
queryEmbedding = await generateEmbedding(query.text);
|
|
260
|
+
} catch {
|
|
261
|
+
// Fall back to text search if embedding fails
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Collect all tags across every entry (before filtering)
|
|
266
|
+
const tagSet = new Set<string>();
|
|
267
|
+
|
|
268
|
+
for (const file of files) {
|
|
269
|
+
const filePath = path.join(journalDir, file);
|
|
270
|
+
let entry: JournalEntry;
|
|
271
|
+
try {
|
|
272
|
+
entry = await readEntryFile(filePath);
|
|
273
|
+
} catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Collect tags before applying filters
|
|
278
|
+
for (const tag of entry.tags) {
|
|
279
|
+
tagSet.add(tag);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Apply metadata filters (AND logic)
|
|
283
|
+
if (query.project && entry.project !== query.project) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (query.tags && query.tags.length > 0) {
|
|
287
|
+
const entryTagNames = entry.tags.map((t) => t.toLowerCase());
|
|
288
|
+
const allTagsMatch = query.tags.every((t) =>
|
|
289
|
+
entryTagNames.includes(t.toLowerCase()),
|
|
290
|
+
);
|
|
291
|
+
if (!allTagsMatch) continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Score the entry
|
|
295
|
+
let score = 0;
|
|
296
|
+
|
|
297
|
+
if (query.text) {
|
|
298
|
+
if (queryEmbedding) {
|
|
299
|
+
// Semantic search
|
|
300
|
+
const entryEmbedding = await loadEmbedding(filePath);
|
|
301
|
+
if (entryEmbedding) {
|
|
302
|
+
score = cosineSimilarity(queryEmbedding, entryEmbedding);
|
|
303
|
+
} else {
|
|
304
|
+
// No embedding stored; fall back to text match
|
|
305
|
+
const haystack =
|
|
306
|
+
`${entry.title}\n${entry.body}`.toLowerCase();
|
|
307
|
+
score = haystack.includes(query.text.toLowerCase()) ? 0.5 : 0;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
// Text search fallback
|
|
311
|
+
const haystack = `${entry.title}\n${entry.body}`.toLowerCase();
|
|
312
|
+
score = haystack.includes(query.text.toLowerCase()) ? 1 : 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (score <= 0) continue;
|
|
316
|
+
} else {
|
|
317
|
+
// No text query - chronological order (score by recency)
|
|
318
|
+
score = entry.created.getTime();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
entries.push({ entry, score });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const total = entries.length;
|
|
325
|
+
|
|
326
|
+
// Sort by score descending
|
|
327
|
+
entries.sort((a, b) => b.score - a.score);
|
|
328
|
+
|
|
329
|
+
// Apply offset + limit
|
|
330
|
+
entries = entries.slice(offset, offset + limit);
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
entries: entries.map((e) => e.entry),
|
|
334
|
+
total,
|
|
335
|
+
allTags: Array.from(tagSet).sort(),
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function buildJournalSystemNote(
|
|
342
|
+
tags?: readonly JournalTag[],
|
|
343
|
+
): string {
|
|
344
|
+
const tagSection =
|
|
345
|
+
tags && tags.length > 0
|
|
346
|
+
? `\n\nSuggested tags:\n${tags.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
|
|
347
|
+
: "";
|
|
348
|
+
|
|
349
|
+
return `<journal_instructions>
|
|
350
|
+
You have access to a private journal. Use it to record thoughts, discoveries, and decisions as you work.
|
|
351
|
+
Tags are free-form strings — use them to classify entries however makes sense.${tagSection}
|
|
352
|
+
|
|
353
|
+
Before starting complex tasks, search the journal for relevant past context.
|
|
354
|
+
Journal entries are append-only: you write new entries but never edit old ones.
|
|
355
|
+
Use journal_search to find past entries semantically, and journal_read to read a specific entry.
|
|
356
|
+
The journal is global across all projects but each entry records which project it was written from.
|
|
357
|
+
</journal_instructions>`;
|
|
358
|
+
}
|
package/src/letta.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
});
|