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/AGENTS.md +12 -2
- package/CHANGELOG.md +24 -1
- package/README.md +21 -2
- package/README.zh-CN.md +7 -2
- package/dist/cli.js +31 -6
- package/dist/config.js +2 -0
- package/dist/distill-agents.js +1 -1
- package/dist/doctor.js +143 -32
- package/dist/index.js +20 -112
- package/dist/init.js +85 -5
- package/dist/mcp.js +12 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/plugin-core.js +138 -0
- package/dist/retrieve/inject.js +1 -1
- package/dist/retrieve/search.js +11 -2
- package/dist/store/db.js +11 -9
- package/dist/store/lifecycle.js +2 -0
- package/dist/store/markdown.js +39 -1
- package/dist/store/sync.js +5 -3
- package/dist/tools/ops.js +22 -3
- package/docs/V2-DESIGN.md +94 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +47 -2
- package/scripts/test-full.ts +28 -0
- package/skills/open-memex/SKILL.md +30 -1
- package/src/cli.ts +36 -5
- package/src/config.ts +10 -0
- package/src/distill-agents.ts +6 -0
- package/src/doctor.ts +140 -29
- package/src/index.ts +25 -112
- package/src/init.ts +91 -5
- package/src/mcp.ts +12 -5
- package/src/opencode-v2.ts +119 -0
- package/src/plugin-core.ts +152 -0
- package/src/retrieve/inject.ts +1 -1
- package/src/retrieve/search.ts +14 -2
- package/src/store/db.ts +11 -9
- package/src/store/lifecycle.ts +3 -0
- package/src/store/markdown.ts +45 -1
- package/src/store/sync.ts +6 -3
- package/src/tools/ops.ts +31 -2
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
package/scripts/smoke-pure.ts
CHANGED
|
@@ -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");
|
package/scripts/test-full.ts
CHANGED
|
@@ -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
|
|
71
|
-
--type
|
|
72
|
-
--tag
|
|
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) => {
|
package/src/distill-agents.ts
CHANGED
|
@@ -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
|
|
98
|
-
* silent — the entry was configured, the host never loaded it, and
|
|
99
|
-
* anywhere said why.
|
|
100
|
-
* 1. the entry's target
|
|
101
|
-
*
|
|
102
|
-
* 2. the plugin's import closure runtime-imports
|
|
103
|
-
* (
|
|
104
|
-
*
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
const RUNTIME_SDK_REQUIRE = /require\(\s*["']@opencode
|
|
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
|
|
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
|
|
188
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
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: `${
|
|
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) };
|