@ghilteras/opencode-agent-memory 0.4.2 → 0.4.3

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 CHANGED
@@ -1,13 +1,6 @@
1
1
  # opencode-agent-memory
2
2
 
3
- > **Fork notice**: This is the maintained fork `@ghilteras/opencode-agent-memory` v0.4.2 of
4
- > [opencode-agent-memory](https://github.com/joshuadavidthomas/opencode-agent-memory)
5
- > (joshuadavidthomas, MIT). v0.4.2 adds a title-anchor floor so title queries are not diluted by long bodies. This release adopts the "proper timestamps without cache
6
- > busting" design (per-block `modified_at` frontmatter, remove the volatile
7
- > `memory_metadata` block, append memory XML at the end of the system prompt) derived
8
- > from the Annakan/draxxris fork plan. Upstream PR #20 (freeze metadata timestamps) is
9
- > superseded by the stronger per-block design and remains open upstream. v0.4.0 swaps
10
- > the embedding model to a multilingual one and adds a versioned embedding format. v0.4.1 makes journal search fast (in-memory per-store index, embedding-model warmup at init) and fixes the js-yaml import for ESM runtimes.
3
+ **agent-memory for OpenCode — maintained by Ghilteras.** Originally based on [opencode-agent-memory](https://github.com/joshuadavidthomas/opencode-agent-memory) by Josh Thomas (MIT).
11
4
 
12
5
  [Letta](https://letta.com)-style editable [memory blocks](https://docs.letta.com/guides/agents/memory-blocks/) for [OpenCode](https://opencode.ai).
13
6
 
@@ -19,6 +12,12 @@ Think of it as AGENTS.md with a harness. OpenCode supports [rules](https://openc
19
12
 
20
13
  For background on the memory concept, see Letta's docs on [memory](https://docs.letta.com/guides/agents/memory/) and [memory blocks](https://docs.letta.com/guides/agents/memory-blocks/).
21
14
 
15
+ ## Maintenance & contributions
16
+
17
+ This repository (`Ghilteras/opencode-agent-memory`, npm `@ghilteras/opencode-agent-memory`) is the **maintained home** of this plugin and its sole upstream. Issues and pull requests belong here.
18
+
19
+ The historical origin (`joshuadavidthomas/opencode-agent-memory`) is reference-only: it is not a contribution target — do not open PRs, issues, or comments there, including courtesy closings.
20
+
22
21
  ## Features
23
22
 
24
23
  - **Persistent memory** - Information survives across sessions and context compaction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghilteras/opencode-agent-memory",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Letta-style editable memory blocks + journal for OpenCode (maintained fork of opencode-agent-memory).",
5
5
  "author": "Angelo Pantano <ghilteras@protonmail.com>",
6
6
  "license": "MIT",
@@ -1,8 +1,23 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
 
4
5
  import * as yaml from "js-yaml";
5
6
 
7
+ const fileLocks = new Map<string, Promise<unknown>>();
8
+
9
+ export function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
10
+ const key = path.resolve(filePath);
11
+ const prior = fileLocks.get(key) ?? Promise.resolve();
12
+ const result = prior.then(fn, fn); // run fn whatever the prior outcome was
13
+ const settled = result.then(() => undefined, () => undefined); // never rejects
14
+ fileLocks.set(key, settled);
15
+ settled.then(() => {
16
+ if (fileLocks.get(key) === settled) fileLocks.delete(key);
17
+ });
18
+ return result;
19
+ }
20
+
6
21
  export function splitFrontmatter(text: string): {
7
22
  frontmatterText: string | undefined;
8
23
  body: string;
@@ -40,8 +55,13 @@ export async function atomicWriteFile(
40
55
  ): Promise<void> {
41
56
  const tempPath = path.join(
42
57
  path.dirname(filePath),
43
- `.${path.basename(filePath)}.tmp`,
58
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
44
59
  );
45
- await fs.writeFile(tempPath, content, "utf-8");
46
- await fs.rename(tempPath, filePath);
60
+ try {
61
+ await fs.writeFile(tempPath, content, "utf-8");
62
+ await fs.rename(tempPath, filePath);
63
+ } catch (err) {
64
+ await fs.rm(tempPath, { force: true }).catch(() => {});
65
+ throw err;
66
+ }
47
67
  }
@@ -0,0 +1,57 @@
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.ts CHANGED
@@ -5,7 +5,7 @@ import * as path from "node:path";
5
5
  import * as yaml from "js-yaml";
6
6
  import { z } from "zod";
7
7
 
8
- import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
8
+ import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter, withFileLock } from "./frontmatter";
9
9
  import { getDefaultDescription } from "./letta";
10
10
 
11
11
  export type MemoryScope = "global" | "project";
@@ -247,53 +247,61 @@ export function createMemoryStore(projectDirectory: string): MemoryStore {
247
247
  await fs.mkdir(dir, { recursive: true });
248
248
 
249
249
  const filePath = path.join(dir, `${safeLabel}.md`);
250
- const existing = (await exists(filePath)) ? await readBlockFile(scope, filePath) : undefined;
251
250
 
252
- if (existing?.readOnly) {
253
- throw new Error(`Memory block is read-only: ${scope}:${safeLabel}`);
254
- }
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
+ }
255
257
 
256
- const description = (opts?.description ?? existing?.description ?? "").trim();
257
- const limit = opts?.limit ?? existing?.limit ?? 5000;
258
+ const description = (opts?.description ?? existing?.description ?? "").trim();
259
+ const limit = opts?.limit ?? existing?.limit ?? 5000;
258
260
 
259
- if (value.length > limit) {
260
- throw new Error(
261
- `Value too large for ${scope}:${safeLabel} (chars=${value.length}, limit=${limit}).`,
262
- );
263
- }
261
+ if (value.length > limit) {
262
+ throw new Error(
263
+ `Value too large for ${scope}:${safeLabel} (chars=${value.length}, limit=${limit}).`,
264
+ );
265
+ }
264
266
 
265
- return writeBlockFile(filePath, {
266
- label: safeLabel,
267
- description,
268
- limit,
269
- readOnly: existing?.readOnly ?? false,
270
- value,
267
+ return writeBlockFile(filePath, {
268
+ label: safeLabel,
269
+ description,
270
+ limit,
271
+ readOnly: existing?.readOnly ?? false,
272
+ value,
273
+ });
271
274
  });
272
275
  },
273
276
 
274
277
  async replaceInBlock(scope, label, oldText, newText) {
275
- const block = await this.getBlock(scope, label);
276
- if (block.readOnly) {
277
- throw new Error(`Memory block is read-only: ${scope}:${block.label}`);
278
- }
278
+ const safeLabel = validateLabel(label);
279
+ const filePath = path.join(scopeDir(projectDirectory, scope), `${safeLabel}.md`);
279
280
 
280
- if (!block.value.includes(oldText)) {
281
- throw new Error(`Old text not found in ${scope}:${block.label}.`);
282
- }
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
+ }
283
286
 
284
- const next = block.value.replace(oldText, newText);
285
- if (next.length > block.limit) {
286
- throw new Error(
287
- `Value too large for ${scope}:${block.label} after replace (chars=${next.length}, limit=${block.limit}).`,
288
- );
289
- }
287
+ if (!block.value.includes(oldText)) {
288
+ throw new Error(`Old text not found in ${scope}:${block.label}.`);
289
+ }
290
290
 
291
- return writeBlockFile(block.filePath, {
292
- label: block.label,
293
- description: block.description,
294
- limit: block.limit,
295
- readOnly: block.readOnly,
296
- value: next,
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
+ });
297
305
  });
298
306
  },
299
307
  };
package/src/plugin.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
2
2
 
3
+ import { existsSync } from "node:fs";
4
+
3
5
  import {
4
6
  buildJournalSystemNote,
5
7
  createJournalStore,
@@ -20,7 +22,12 @@ import { warmupEmbedder } from "./embeddings";
20
22
 
21
23
  export const MemoryPlugin: Plugin = async ({ directory }) => {
22
24
  const store = createMemoryStore(directory);
23
- await store.ensureSeed();
25
+ // Only seed into a directory that still exists. OpenCode walks a persisted instance list
26
+ // at startup and bootstraps every entry; because ensureSeed() creates
27
+ // <directory>/.opencode/memory recursively, a stale entry made this plugin re-create
28
+ // directories the operator had deliberately deleted. A missing directory means the
29
+ // instance is gone, not new, so there is nothing to seed.
30
+ if (existsSync(directory)) await store.ensureSeed();
24
31
 
25
32
  // Journal: opt-in via ~/.config/opencode/agent-memory.json
26
33
  const config = await loadConfig();