open-memex 0.6.2 → 0.7.0-alpha.2

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/docs/V2-DESIGN.md CHANGED
@@ -1210,6 +1210,100 @@ requirement: personal data never touches third-party services). Benchmarks to tr
1210
1210
  `@opencode-ai/plugin`, the exact D58 death. It checks only what can be
1211
1211
  attributed; per-project and MCP wiring are out of scope.
1212
1212
 
1213
+ - **D60** — one plugin serves both opencode generations (0.7.0-alpha.1).
1214
+ Opencode 2 changes the plugin contract end to end: config reads
1215
+ `plugins` (directories or `{package}` objects) instead of v1's
1216
+ `plugin` file entries, a v1-style file entry only earns a log warning
1217
+ ("configured plugin path must be a directory") and is silently skipped
1218
+ — the D58 death in a new shape; and the plugin value itself is a
1219
+ structurally decoded `{ id, setup(ctx) }` rather than the v1 server
1220
+ factory. A single `open-memex init --client opencode --global` now
1221
+ writes both keys (`plugin` file URL and `plugins` directory): each
1222
+ host loads from its own key — verified: opencode 1.18.34 ignores the
1223
+ `plugins` array with a config-normalization warning, and opencode 2
1224
+ warns per `plugin` file entry but loads the directory entry — so users on either generation get a
1225
+ working plugin from the same install. `src/index.ts` default-exports
1226
+ `{ id, setup, server }`: v2 calls `setup`, v1 calls `server`, the
1227
+ shared logic (bootstrap, keyword capture, one-time injection) lives in
1228
+ `src/plugin-core.ts`. Cost of the bridge on the v1 side: v1 hosts
1229
+ older than 1.18.29 only invoke function default exports and would
1230
+ skip this object; 1.18.29+ accepts it — the README states the new
1231
+ minimum. The v2 adapter (src/opencode-v2.ts) obeys D58's rule — no
1232
+ runtime `@opencode/plugin` import (a devDependency again); the
1233
+ context is duck-typed from the 2.0 surface, tool arg shapes compile
1234
+ from the same zod shapes via `z.toJSONSchema`, and hooks map
1235
+ one-to-one (session `prompt` → keyword capture, session `context` →
1236
+ first-turn injection). One registration subtlety cost real debugging
1237
+ time and is worth recording: on 2.0.1 a plugin tool added without
1238
+ options is exposed to the model only in code mode — callable through
1239
+ an `execute` meta-tool that runs model-written JS
1240
+ (`tools.memory_search(...)`) — and a model that instead calls the
1241
+ tool by name gets a host-side "Unknown tool" error, so whether the
1242
+ memory tools work at all depends on the model's calling style. Each
1243
+ tool therefore registers with `options: { codemode: false }`, which
1244
+ restores plain function calling (verified: direct calls execute
1245
+ first try). The D59 doctor check extends to both keys:
1246
+ v2 entries are checked for an existing, resolvable entry file and the
1247
+ closure scan now covers `@opencode/plugin` too, plus a generation
1248
+ advisory — when the installed host and the config point at different
1249
+ generations, doctor fails and names the fix (`init --force`; package-
1250
+ name entries installed via `opencode plugin add` are recorded but not
1251
+ path-checked). Field-verified on real hosts 1.18.34 and 2.0.1.
1252
+
1253
+ - **D61 — proactive capture cluster: propose posture, a shared
1254
+ memorability rubric, and capture-time aliasing (retrieval layer 3).**
1255
+ Builds on the "proactive capture" open question below and the
1256
+ CHECKPOINT_PROACTIVITY / MEMORY_VISIBILITY notes. Three pieces,
1257
+ shipped together because they are one user-visible behavior — the
1258
+ agent capturing well without being asked each time:
1259
+ (1) **Posture: propose, not auto-draft.** When the agent finishes a
1260
+ task the user would describe in one sentence, it proposes 1–3 short
1261
+ candidate memories in the conversation and saves only what the user
1262
+ approves (approved inferences are saved with `source: "inference"`,
1263
+ the §3.5 convention). The guidance is text, not mechanism — the same
1264
+ rubric written into the bundled skill, the AGENTS.md memory-hygiene
1265
+ footer, the MCP handshake instructions, the `memory_add` tool
1266
+ description, and a one-line summary in the injected context block —
1267
+ with throttling rules (task end / decision / gotcha moments only, no
1268
+ mid-task interruptions, never re-propose a declined candidate). The
1269
+ auto-draft posture (write first, surface later in bulk) is deferred
1270
+ until memory visibility ships: without an inventory the user can
1271
+ read, silent drafting reads as surveillance, so D61 deliberately
1272
+ does not build it.
1273
+ (2) **Rubric.** One memorability standard, shared by this live
1274
+ capture path and the future retrospective-distillation group:
1275
+ worth saving — decisions and their reasons, preferences,
1276
+ conventions, gotchas, approaches tried and abandoned, and
1277
+ `reference` memories pointing at authoritative docs; not worth
1278
+ saving — one-off task details and anything re-derivable from the
1279
+ code. Retrieval robustness layer 2 (agent-side query expansion:
1280
+ decompose into concepts, 2–3 phrasings each, cross-language, check
1281
+ the other scope) ships as guidance in the same texts, on top of the
1282
+ D56 server-side layer 1.
1283
+ (3) **Capture-time aliases (layer 3).** When a memory is saved, the
1284
+ agent attaches up to 4 aliases — alternate phrasings, synonyms,
1285
+ equivalents in the user's other language — stored in a new optional
1286
+ frontmatter field `aliases` (additive; files without it parse
1287
+ unchanged, `schema_version` stays 2) and indexed in their own FTS
1288
+ column (index schema 6→7, rebuilt from markdown automatically).
1289
+ Gated by a new `captureAliases` config flag (init asks once, default
1290
+ on, `config set captureAliases off` opts out): when off, aliases
1291
+ passed by agents are dropped and the alias guidance disappears from
1292
+ the MCP handshake. Turning it off never rewrites existing memory
1293
+ files — aliases already stored stay stored and searchable, and
1294
+ supersede keeps carrying them forward; the flag gates new captures
1295
+ only (no silent data destruction). Aliases pass through the same
1296
+ secret redaction as memory content before they are stored, and a
1297
+ malformed alias list (blanks, more than 4) is normalized silently —
1298
+ never a tool-call error.
1299
+ Keyword capture (no agent in the loop) leaves
1300
+ aliases empty rather than inventing them; `supersede` carries
1301
+ aliases forward unless the caller replaces them. Aliases are
1302
+ normalized (trim, case-insensitive dedupe, cap 4) and bad ones cost
1303
+ nothing — they rank as bm25 noise, never an error. Retrospective
1304
+ distillation over host transcripts stays its own group; it will
1305
+ reuse this rubric.
1306
+
1213
1307
  ## Open Questions
1214
1308
 
1215
1309
  _All resolved — see D10 (rename), D11 (type/role split), D12 (explicit pull)._
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.6.2",
3
+ "version": "0.7.0-alpha.2",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  // Quick smoke test — runs the pure-logic modules (no bun:sqlite dependency).
2
2
  // Usage: node --experimental-strip-types scripts\smoke-pure.ts
3
- import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, type Frontmatter } from "../src/store/markdown.ts";
3
+ import { parse, serialize, ulid, normalizeFrontmatter, msToRfc3339, timeToMs, parseRawFrontmatter, normalizeAliases, type Frontmatter } from "../src/store/markdown.ts";
4
4
  import { planConversion, isV2File, migrateV2 } from "../src/store/v2migrate.ts";
5
5
  import { redact, findSecret } from "../src/redact.ts";
6
6
  import { detectKeywords } from "../src/capture/keywords.ts";
@@ -9,7 +9,9 @@ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/sco
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { toFtsQuery } from "../src/retrieve/query.ts";
11
11
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
12
- import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
12
+ import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, mergeV2PluginEntry, removeV2PluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection, LEGACY_PACKAGE_NAME, shouldInstallSkill } from "../src/init.ts";
13
+ import { z } from "zod";
14
+ import { memoryAddArgs } from "../src/tools/ops.ts";
13
15
 
14
16
  let fails = 0;
15
17
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -57,6 +59,28 @@ ok("id survives", parsed?.fm.id === a);
57
59
  ok("tags survive", JSON.stringify(parsed?.fm.tags) === '["hello","world"]');
58
60
  ok("body survives", parsed?.body.trim() === body);
59
61
 
62
+ console.log("== capture aliases (D61) ==");
63
+ ok("normalize trims/dedupes/caps at 4",
64
+ JSON.stringify(normalizeAliases([" Time Off ", "time off", "", "节假日", "vacation", "PTO", "leave"])) === '["Time Off","节假日","vacation","PTO"]');
65
+ ok("normalize of undefined is empty", JSON.stringify(normalizeAliases(undefined)) === "[]");
66
+ const fmA: Frontmatter = { ...fm, id: ulid(), aliases: ["time off", "节假日", "vacation days"] };
67
+ const parsedA = parse(serialize(fmA, "body text"));
68
+ ok("aliases survive round-trip",
69
+ JSON.stringify(parsedA?.fm.aliases) === '["time off","节假日","vacation days"]');
70
+ ok("no aliases -> field omitted from file",
71
+ !/^aliases:/m.test(serialize(fm, "x")));
72
+ ok("empty aliases array -> field omitted",
73
+ !/^aliases:/m.test(serialize({ ...fm, aliases: [] }, "x")));
74
+ ok("parse caps hand-edited aliases at 4",
75
+ (normalizeFrontmatter({ id: "x", aliases: ["1", "2", "3", "4", "5"] } as never).aliases ?? []).length === 4);
76
+ {
77
+ // D61 review: the tool schema must never reject a sloppy alias list —
78
+ // normalization decides, not validation (bad aliases are noise).
79
+ const shape = z.object(memoryAddArgs);
80
+ const sloppy = shape.safeParse({ content: "x", aliases: ["a", "", "b", "c", "d", "e"] });
81
+ ok("tool schema accepts >4 / blank aliases (normalize drops them)", sloppy.success);
82
+ }
83
+
60
84
  console.log("== redact ==");
61
85
  const r1 = redact("api key is <private>sk-supersecretkey</private> ok", DEFAULT_CONFIG.redactPatterns);
62
86
  ok("private stripped", !r1.content.includes("sk-supersecretkey"));
@@ -504,6 +528,27 @@ ok("plugin remove absent", removePluginEntry({ plugin: ["other"] }, "open-memex"
504
528
  const lpdoc: Record<string, unknown> = { plugin: ["file:///g/my-o-memory/src/index.ts", "file:///g/open-memex/src/index.ts"] };
505
529
  ok("plugin remove drops legacy my-o-memory entry", removePluginEntry(lpdoc, LEGACY_PACKAGE_NAME) === "removed"
506
530
  && JSON.stringify(lpdoc["plugin"]) === JSON.stringify(["file:///g/open-memex/src/index.ts"]));
531
+ // D60: the OpenCode 2 `plugins` array (directory strings and {package} objects).
532
+ const vdoc: Record<string, unknown> = {};
533
+ ok("v2 merge creates array", mergeV2PluginEntry(vdoc, "/x/open-memex/src", false) === "added"
534
+ && JSON.stringify(vdoc["plugins"]) === JSON.stringify(["/x/open-memex/src"]));
535
+ ok("v2 merge dup kept", mergeV2PluginEntry(vdoc, "/x/open-memex/src", false) === "kept");
536
+ const vdoc2: Record<string, unknown> = { plugins: [{ package: "other-plugin", options: {} }, "/old/open-memex/src"] };
537
+ ok("v2 merge kept with existing open-memex entry (no force)", mergeV2PluginEntry(vdoc2, "/new/open-memex/src", false) === "kept"
538
+ && (vdoc2["plugins"] as unknown[]).length === 2);
539
+ ok("v2 merge force replaces stale open-memex dir, keeps others", mergeV2PluginEntry(vdoc2, "/new/open-memex/src", true) === "added"
540
+ && (vdoc2["plugins"] as unknown[]).length === 2
541
+ && JSON.stringify(vdoc2["plugins"]) === JSON.stringify([{ package: "other-plugin", options: {} }, "/new/open-memex/src"]));
542
+ ok("v2 remove drops string + object entries", removeV2PluginEntry(vdoc2, "open-memex") === "removed"
543
+ && JSON.stringify(vdoc2["plugins"]) === JSON.stringify([{ package: "other-plugin", options: {} }]));
544
+ ok("v2 remove absent", removeV2PluginEntry(vdoc2, "open-memex") === "absent");
545
+ const vdoc3: Record<string, unknown> = { plugins: ["/x/open-memex/src"] };
546
+ ok("v2 remove prunes empty array", removeV2PluginEntry(vdoc3, "open-memex") === "removed" && !("plugins" in vdoc3));
547
+ // D60: the plugin entrypoint default-exports both host generations.
548
+ const entryModule = (await import("../src/index.ts")) as { default: { id?: unknown; setup?: unknown; server?: unknown } };
549
+ ok("entry default export has id/setup/server", entryModule.default.id === "open-memex"
550
+ && typeof entryModule.default.setup === "function"
551
+ && typeof entryModule.default.server === "function");
507
552
  // removeInstructionsSection: cuts MARKER..end, "" when nothing remains.
508
553
  ok("instructions remove keeps prior content",
509
554
  removeInstructionsSection("# mine\n\n<!-- open-memex -->\n# OpenMemex memory\n") === "# mine\n");
@@ -133,6 +133,34 @@ ok("list --type filter", r.out.includes(idDecision) && !r.out.includes(idFact));
133
133
  r = cli(["search", "ff merges"], PROJ);
134
134
  ok("search finds decision", r.out.includes(idDecision), r.out.slice(0, 150));
135
135
 
136
+ // D61: capture-time aliases — a differently-worded query still hits.
137
+ const idAlias = addMem(["fulltest holiday policy lives in the handbook", "--aliases", "time off;vacation days"], PROJ);
138
+ r = cli(["search", "vacation"], PROJ);
139
+ ok("search hits via alias-only term", r.out.includes(idAlias), r.out.slice(0, 150));
140
+ ok("alias-only hit explains itself (aka)", /aka:/i.test(r.out), r.out.slice(0, 150));
141
+
142
+ // D61 review: aliases go through the same redaction as content. (The
143
+ // secret is assembled at runtime so no credential-shaped literal sits
144
+ // in this file.)
145
+ const fakePat = "ghp_" + "Ab3".repeat(12);
146
+ const idSecretAlias = addMem(["fulltest alias redaction target", "--aliases", `${fakePat}; benign alias`], PROJ);
147
+ r = cli(["search", "benign"], PROJ);
148
+ ok("benign alias still searchable", r.out.includes(idSecretAlias), r.out.slice(0, 150));
149
+ {
150
+ const dir = path.join(TESTENV.MY_O_MEMORY_HOME, "memories");
151
+ const stack = [dir];
152
+ let hit = "";
153
+ while (stack.length && !hit) {
154
+ const d = stack.pop()!;
155
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
156
+ const p = path.join(d, e.name);
157
+ if (e.isDirectory()) stack.push(p);
158
+ else if (p.endsWith(`${idSecretAlias}.md`)) hit = fs.readFileSync(p, "utf8");
159
+ }
160
+ }
161
+ ok("secret-looking alias is masked in the stored file", !!hit && !hit.includes(fakePat), hit.slice(0, 200));
162
+ }
163
+
136
164
  // ---------- 3. supersede / status / forget ----------
137
165
  console.log("== supersede / status / forget ==");
138
166
  r = cli(["supersede", idFact, "fulltest fact about deploys v2"], PROJ);
@@ -25,6 +25,32 @@ open-memex add "I prefer concise diffs" --scope personal # applies everywhere
25
25
  Keep each memory one self-contained statement. Scope routing: facts about the
26
26
  user ("I"/"me") → `personal`; everything else → the current project.
27
27
 
28
+ Worth saving: decisions and their reasons, preferences, conventions, gotchas,
29
+ approaches tried and abandoned. Not worth saving: one-off task details or
30
+ anything re-derivable from the code.
31
+
32
+ Attach alternate phrasings when saving — synonyms, another way the question
33
+ might be worded, equivalents in the user's other language — so reworded
34
+ questions still find the memory:
35
+
36
+ ```sh
37
+ open-memex add "Public holidays are listed in the HR portal" --aliases "holidays;time off;节假日"
38
+ ```
39
+
40
+ (Aliases are stored only when the install has capture aliases enabled — the
41
+ init default.)
42
+
43
+ ## At task checkpoints — propose, don't just save
44
+
45
+ For conclusions you infer yourself (the user never stated them): when you
46
+ finish a task the user would describe in one sentence, propose 1–3 short
47
+ memories capturing the useful conclusion (what was learned or decided, how an
48
+ issue was resolved, what to avoid, where the authoritative doc lives — not the
49
+ raw transcript). Save nothing the user did not approve; on approval, save it —
50
+ with the `memory_add` tool pass `source: "inference"` so the capture is marked
51
+ as agent-proposed. Don't interrupt mid-task, and don't re-propose something
52
+ the user already declined.
53
+
28
54
  ## Recall
29
55
 
30
56
  ```sh
@@ -33,7 +59,10 @@ open-memex list # recent memories in the current project
33
59
  ```
34
60
 
35
61
  Search before asking the user about past decisions or preferences they may
36
- have told you before.
62
+ have told you before. Search well: break the question into its concepts and
63
+ try 2–3 phrasings per concept — synonyms, the user's other language, shorter
64
+ keyword forms — and check the other scope too, before concluding nothing is
65
+ stored.
37
66
 
38
67
  ## Project outbox → repo (the sync flow)
39
68
 
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  readMemoryFile,
16
16
  ulid,
17
17
  msToRfc3339,
18
+ normalizeAliases,
18
19
  type Frontmatter,
19
20
  } from "./store/markdown.ts";
20
21
  import { loadConfig } from "./config.ts";
@@ -64,19 +65,26 @@ Example:
64
65
 
65
66
  add: `Save a fact, preference, decision, or note to local memory.
66
67
 
67
- Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
68
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2] [--aliases "a;b"]
68
69
 
69
70
  Flags:
70
- --scope project (default) or personal (personal never leaves this machine)
71
- --type memory type (default: fact)
72
- --tag comma-separated tags
71
+ --scope project (default) or personal (personal never leaves this machine)
72
+ --type memory type (default: fact)
73
+ --tag comma-separated tags
74
+ --aliases alternate phrasings of this fact (up to 4), indexed with the
75
+ memory so differently-worded searches still match; separate
76
+ with ; (commas also accepted — use ; when an alias itself
77
+ contains one). Stored only when capture aliases are enabled
78
+ (init default)
73
79
 
74
80
  Example:
75
81
  open-memex add "We deploy on Fridays" --scope project --tag process`,
76
82
 
77
83
  supersede: `Replace a memory with a newer version. The old one is kept as history.
78
84
 
79
- Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
85
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2] [--aliases "a;b"]
86
+
87
+ Without --aliases, the old memory's aliases carry over.
80
88
 
81
89
  Example:
82
90
  open-memex supersede 01ABC "We deploy on Thursdays now"`,
@@ -943,6 +951,20 @@ async function main() {
943
951
  .map((t) => t.trim())
944
952
  .filter(Boolean)
945
953
  : [],
954
+ // D61: --aliases "phrase one; phrase two" (comma also accepted; use
955
+ // ; when an alias itself contains a comma). A bare --aliases with no
956
+ // value makes parseFlags yield "true" — treat that as absent. Stored
957
+ // only when the install enables capture aliases, redacted like content.
958
+ ...(typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
959
+ ? {
960
+ aliases: normalizeAliases(
961
+ flags.aliases
962
+ .split(/[;,]/)
963
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
964
+ .filter(Boolean),
965
+ ),
966
+ }
967
+ : {}),
946
968
  source: "cli",
947
969
  created_at: rfc,
948
970
  updated_at: rfc,
@@ -985,6 +1007,15 @@ async function main() {
985
1007
  tags: flags.tag
986
1008
  ? flags.tag.split(",").map((t) => t.trim()).filter(Boolean)
987
1009
  : undefined,
1010
+ aliases:
1011
+ typeof flags.aliases === "string" && flags.aliases !== "true" && cfg.captureAliases
1012
+ ? normalizeAliases(
1013
+ flags.aliases
1014
+ .split(/[;,]/)
1015
+ .map((a) => redact(a.trim(), cfg.redactPatterns).content)
1016
+ .filter(Boolean),
1017
+ )
1018
+ : undefined,
988
1019
  source: "cli",
989
1020
  });
990
1021
  upsertFromFile(oldMf);
package/src/config.ts CHANGED
@@ -7,6 +7,14 @@ export interface MyOMemoryConfig {
7
7
  maxProfileItems: number;
8
8
  injectOnFirstTurn: boolean;
9
9
  keywordCaptureEnabled: boolean;
10
+ /**
11
+ * D61: capture-time aliasing (retrieval layer 3). When on, agents are
12
+ * asked to attach 2–4 aliases (alternate phrasings, other-language
13
+ * equivalents) to each memory they save; ops stores them and the index
14
+ * searches them, so differently-worded questions still hit. Asked once
15
+ * at init, default on; off = aliases passed by agents are dropped.
16
+ */
17
+ captureAliases: boolean;
10
18
  /** Patterns whose capture group 1 becomes the memory body; saved to the current scope. */
11
19
  keywordPatterns: string[];
12
20
  /** Same shape, but hits are forced into the personal scope (e.g. "remember for me"). */
@@ -34,6 +42,7 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
34
42
  maxProfileItems: 5,
35
43
  injectOnFirstTurn: true,
36
44
  keywordCaptureEnabled: true,
45
+ captureAliases: true,
37
46
  keywordPatterns: [
38
47
  "^\\s*remember(?!\\s+for\\s+me)(?:\\s+that)?[:,]?\\s+(.+)$",
39
48
  "^\\s*(?:please\\s+)?(?:note|don'?t\\s+forget)(?:\\s+that)?[:,]?\\s+(.+)$",
@@ -110,6 +119,7 @@ export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
110
119
  maxProfileItems: toNonNegInt,
111
120
  injectOnFirstTurn: toBool,
112
121
  keywordCaptureEnabled: toBool,
122
+ captureAliases: toBool,
113
123
  memoryDir: toRelativeDir,
114
124
  "sync.autoPull": toBool,
115
125
  logLevel: (v) => {
@@ -78,8 +78,14 @@ export function distillAgentsMarkdown(opts: DistillAgentsOptions): string {
78
78
  ` conclusion — what was learned or decided, how an issue was resolved, what`,
79
79
  ` to avoid, where the authoritative doc lives — not the raw transcript.`,
80
80
  ` Save nothing without user approval.`,
81
+ ` Worth keeping: decisions and their reasons, preferences, conventions,`,
82
+ ` gotchas, approaches tried and abandoned. Not worth keeping: one-off task`,
83
+ ` details or anything re-derivable from the code.`,
81
84
  `- If the knowledge already lives in project docs, save a \`reference\` memory`,
82
85
  ` pointing at the doc instead of copying it.`,
86
+ `- When saving via \`memory_add\`, attach 2–4 aliases (alternate phrasings,`,
87
+ ` synonyms, equivalents in the user's other language) when the install has`,
88
+ ` capture aliases enabled — they make reworded questions find the memory.`,
83
89
  );
84
90
  lines.push(``);
85
91
  return lines.join("\n");
package/src/doctor.ts CHANGED
@@ -94,14 +94,17 @@ function lockingCheck(): Check {
94
94
  }
95
95
 
96
96
  /**
97
- * D59: patrol the opencode native-plugin entry. The D58 field failure was
98
- * silent — the entry was configured, the host never loaded it, and nothing
99
- * anywhere said why. Two death modes to catch:
100
- * 1. the entry's target file does not exist (package moved, nvm rolled
101
- * to a new version dir, checkout deleted);
102
- * 2. the plugin's import closure runtime-imports @opencode-ai/plugin
103
- * (or another host-only module the package does not depend on),
104
- * which is unresolvable from a global install (D58 regression).
97
+ * D59/D60: patrol the opencode native-plugin entries. The D58 field failure
98
+ * was silent — the entry was configured, the host never loaded it, and
99
+ * nothing anywhere said why. Death modes to catch:
100
+ * 1. the entry's target does not exist (package moved, nvm rolled to a
101
+ * new version dir, checkout deleted);
102
+ * 2. the plugin's import closure runtime-imports a host SDK package
103
+ * (@opencode-ai/plugin on v1, @opencode/plugin on v2) — unresolvable
104
+ * from a global install, the host silently skips the plugin (D58);
105
+ * 3. (D60) the config wires only the other host generation: an
106
+ * opencode 2 host never reads the v1 `plugin` file entry (it demands
107
+ * a directory under `plugins`), and opencode 1 cannot read `plugins`.
105
108
  * Read-only; only inspects the user-level opencode configs.
106
109
  */
107
110
  function opencodePluginCheck(): Check {
@@ -109,23 +112,37 @@ function opencodePluginCheck(): Check {
109
112
  try {
110
113
  const jsonPath = opencodeGlobalConfigPath();
111
114
  const candidates = [jsonPath, jsonPath.replace(/\.json$/, ".jsonc")];
112
- const entries: string[] = [];
115
+ const v1Entries: string[] = [];
116
+ const v2Entries: string[] = [];
113
117
  let sawConfig = false;
118
+ const v2EntryPackage = (entry: unknown): string | null => {
119
+ if (typeof entry === "string") return entry;
120
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
121
+ const p = (entry as { package?: unknown }).package;
122
+ return typeof p === "string" ? p : null;
123
+ }
124
+ return null;
125
+ };
114
126
  for (const f of candidates) {
115
127
  if (!fs.existsSync(f)) continue;
116
128
  sawConfig = true;
117
129
  const text = fs.readFileSync(f, "utf8");
118
130
  try {
119
- const doc = JSON.parse(text) as { plugin?: unknown };
120
- const list = Array.isArray(doc.plugin) ? doc.plugin : [];
121
- for (const e of list) {
122
- if (typeof e === "string") entries.push(e);
131
+ const doc = JSON.parse(text) as { plugin?: unknown; plugins?: unknown };
132
+ if (Array.isArray(doc.plugin)) {
133
+ for (const e of doc.plugin) if (typeof e === "string") v1Entries.push(e);
134
+ }
135
+ if (Array.isArray(doc.plugins)) {
136
+ for (const e of doc.plugins) {
137
+ const p = v2EntryPackage(e);
138
+ if (p !== null) v2Entries.push(p);
139
+ }
123
140
  }
124
141
  } catch {
125
142
  // JSONC (comments/trailing commas): fall back to scanning quoted
126
143
  // strings for plugin entries.
127
144
  for (const m of text.matchAll(/"(file:[^"]+|[^"]+\.(?:ts|js|mts|mjs))"/g)) {
128
- entries.push(m[1]!);
145
+ v1Entries.push(m[1]!);
129
146
  }
130
147
  }
131
148
  }
@@ -134,23 +151,63 @@ function opencodePluginCheck(): Check {
134
151
  }
135
152
 
136
153
  const problems: string[] = [];
154
+ // Host SDK packages per generation; a runtime import of either from a
155
+ // globally installed plugin cannot resolve (D58).
137
156
  const RUNTIME_SDK_IMPORT =
138
- /^\s*import\s+(?!type\b)[^;]*?["']@opencode-ai\/plugin(?:\/[^"']*)?["']/m;
139
- const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode-ai\/plugin(?:\/[^"']*)?["']\s*\)/;
157
+ /^\s*import\s+(?!type\b)[^;]*?["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']/m;
158
+ const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode(?:-ai)?\/plugin(?:\/[^"']*)?["']\s*\)/;
140
159
 
160
+ const toPath = (entry: string): string | null => {
161
+ try {
162
+ return entry.startsWith("file:") ? fileURLToPath(entry) : entry;
163
+ } catch {
164
+ return null;
165
+ }
166
+ };
141
167
  // An entry is ours when its path names the package, or when the target
142
168
  // file carries the plugin's "[open-memex]" log marker (source checkouts
143
169
  // can live at any path).
144
- const isOurEntry = (entry: string): boolean => {
145
- if (entry.includes("open-memex")) return true;
170
+ const fileHasMarker = (target: string): boolean => {
146
171
  try {
147
- const target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
148
- if (!fs.existsSync(target)) return false;
149
172
  return fs.readFileSync(target, "utf8").slice(0, 65536).includes("[open-memex]");
150
173
  } catch {
151
174
  return false;
152
175
  }
153
176
  };
177
+ /** Resolve an OpenCode 2 directory entry to its entry file, if any. */
178
+ const resolveV2EntryFile = (target: string): string | null => {
179
+ try {
180
+ if (!fs.existsSync(target)) return null;
181
+ if (fs.statSync(target).isFile()) return target;
182
+ for (const idx of ["index.ts", "index.js", "index.mts", "index.mjs"]) {
183
+ const p = path.join(target, idx);
184
+ if (fs.existsSync(p)) return p;
185
+ }
186
+ const pkgJson = path.join(target, "package.json");
187
+ if (fs.existsSync(pkgJson)) {
188
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8")) as { main?: unknown };
189
+ if (typeof pkg.main === "string") {
190
+ const main = path.join(target, pkg.main);
191
+ if (fs.existsSync(main)) return main;
192
+ }
193
+ }
194
+ } catch {
195
+ return null;
196
+ }
197
+ return null;
198
+ };
199
+ const isOursV1 = (entry: string): boolean => {
200
+ if (entry.includes("open-memex")) return true;
201
+ const target = toPath(entry);
202
+ return target !== null && fs.existsSync(target) && fileHasMarker(target);
203
+ };
204
+ const isOursV2 = (entry: string): boolean => {
205
+ if (entry.includes("open-memex")) return true;
206
+ const target = toPath(entry);
207
+ if (target === null) return false;
208
+ const file = resolveV2EntryFile(target);
209
+ return file !== null && fileHasMarker(file);
210
+ };
154
211
  const scanSdkImports = (entryFile: string): string[] => {
155
212
  // Walk the entry's relative-import closure looking for host-SDK
156
213
  // runtime imports (type-only imports are erased and fine).
@@ -184,15 +241,14 @@ function opencodePluginCheck(): Check {
184
241
  return offenders;
185
242
  };
186
243
 
187
- const ours = entries.filter(isOurEntry);
188
- if (ours.length === 0) {
244
+ const oursV1 = v1Entries.filter(isOursV1);
245
+ const oursV2 = v2Entries.filter(isOursV2);
246
+ if (oursV1.length === 0 && oursV2.length === 0) {
189
247
  return { name, ok: true, detail: "no global plugin entry (per-project or MCP wiring is not checked)" };
190
248
  }
191
- for (const entry of ours) {
192
- let target: string;
193
- try {
194
- target = entry.startsWith("file:") ? fileURLToPath(entry) : entry;
195
- } catch {
249
+ for (const entry of oursV1) {
250
+ const target = toPath(entry);
251
+ if (target === null) {
196
252
  problems.push(`entry "${entry}" is not a parseable file URL`);
197
253
  continue;
198
254
  }
@@ -204,17 +260,72 @@ function opencodePluginCheck(): Check {
204
260
  }
205
261
  for (const f of scanSdkImports(target)) {
206
262
  problems.push(
207
- `${f} runtime-imports @opencode-ai/plugin — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`,
263
+ `${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`,
264
+ );
265
+ }
266
+ }
267
+ for (const entry of oursV2) {
268
+ // A bare package name ("open-memex" via `opencode plugin add`) is
269
+ // resolved by the host's plugin manager; there is no local path to
270
+ // verify.
271
+ const isPackageName =
272
+ !entry.startsWith("file:") &&
273
+ !entry.startsWith(".") &&
274
+ !path.isAbsolute(entry) &&
275
+ !/^[a-zA-Z]:[\\/]/.test(entry);
276
+ if (isPackageName) continue;
277
+ const target = toPath(entry);
278
+ if (target === null || !fs.existsSync(target)) {
279
+ problems.push(
280
+ `opencode 2 entry "${entry}" does not resolve to an existing path — run \`open-memex init --client opencode --global --force\` to re-point it`,
281
+ );
282
+ continue;
283
+ }
284
+ const entryFile = resolveV2EntryFile(target);
285
+ if (entryFile === null) {
286
+ problems.push(
287
+ `opencode 2 entry "${entry}" has no loadable plugin file (looked for index.ts/index.js and package.json main)`,
208
288
  );
289
+ continue;
209
290
  }
291
+ for (const f of scanSdkImports(entryFile)) {
292
+ problems.push(
293
+ `${f} runtime-imports a host SDK package — opencode silently skips plugins whose imports it cannot resolve from a global install (D58)`,
294
+ );
295
+ }
296
+ }
297
+
298
+ // D60: generation mismatch — the config wires only the generation the
299
+ // installed host cannot read. Best-effort: no binary, no opinion.
300
+ let hostMajor: number | null = null;
301
+ try {
302
+ const out = execFileSync("opencode", ["--version"], {
303
+ timeout: 3000,
304
+ encoding: "utf8",
305
+ stdio: ["ignore", "pipe", "ignore"],
306
+ });
307
+ const m = out.match(/(\d+)\.(\d+)\.(\d+)/);
308
+ if (m) hostMajor = Number(m[1]);
309
+ } catch {
310
+ hostMajor = null;
210
311
  }
312
+ if (hostMajor !== null && hostMajor >= 2 && oursV1.length > 0 && oursV2.length === 0) {
313
+ problems.push(
314
+ `the installed opencode is v${hostMajor} but the config only has the opencode 1 "plugin" entry — v2 loads directories from "plugins" and will silently skip this plugin; run \`open-memex init --client opencode --global --force\` to add the v2 entry`,
315
+ );
316
+ } else if (hostMajor === 1 && oursV2.length > 0 && oursV1.length === 0) {
317
+ problems.push(
318
+ `the installed opencode is v1 but the config only has the opencode 2 "plugins" entry — v1 reads the "plugin" key and will not load this plugin; run \`open-memex init --client opencode --global --force\` to add the v1 entry`,
319
+ );
320
+ }
321
+
211
322
  if (problems.length > 0) {
212
323
  return { name, ok: false, detail: problems.join("; ") };
213
324
  }
214
325
  return {
215
326
  name,
216
327
  ok: true,
217
- detail: `${ours.length} plugin ${ours.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
328
+ detail: `${oursV1.length + oursV2.length} plugin ${oursV1.length + oursV2.length === 1 ? "entry" : "entries"}: target exists, no host-SDK runtime imports`,
218
329
  };
219
330
  } catch (err) {
220
331
  return { name, ok: false, detail: String(err) };