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/src/index.ts CHANGED
@@ -1,133 +1,46 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
- import { loadConfig } from "./config.ts";
3
- import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
4
- import { db } from "./store/db.ts";
5
- import { syncScope, upsertFromFile } from "./store/sync.ts";
6
- import { scopeHasFiles } from "./store/migrate.ts";
7
2
  import {
8
- writeMemoryFile,
9
- readMemoryFile,
10
- ulid,
11
- msToRfc3339,
12
- type Frontmatter,
13
- } from "./store/markdown.ts";
14
- import { buildContextBlock } from "./retrieve/inject.ts";
15
- import { detectKeywords } from "./capture/keywords.ts";
16
- import { findDuplicates } from "./store/lifecycle.ts";
17
- import { redact } from "./redact.ts";
3
+ PLUGIN_ID,
4
+ bootstrapPlugin,
5
+ captureFromText,
6
+ injectOnce,
7
+ } from "./plugin-core.ts";
18
8
  import { makeTools } from "./tools/memory.ts";
19
-
20
- const plugin: Plugin = async ({ worktree, directory }) => {
21
- const cfg = loadConfig();
9
+ import { v2Plugin } from "./opencode-v2.ts";
10
+
11
+ /**
12
+ * One entrypoint, both opencode generations (D60):
13
+ * - OpenCode 2 structurally decodes the default export as `{ id, setup }`
14
+ * and ignores `server` (see src/opencode-v2.ts).
15
+ * - OpenCode 1 (≥1.18.29) reads `server` off the same default export and
16
+ * ignores `setup` — the function below is the v1 plugin exactly as it
17
+ * behaved before dual support.
18
+ * Neither host runtime-imports an SDK package from this file: v1's SDK is
19
+ * type-only (D58), and v2's decode is structural.
20
+ */
21
+ const server: Plugin = async ({ worktree, directory }) => {
22
22
  const roots = worktree || directory || process.cwd();
23
- const scope: Scope = resolveProjectScope(roots);
24
-
25
- // Init DB and one-shot sync of markdown -> index on plugin load.
26
- try {
27
- db();
28
- syncScope(scope.key);
29
- syncScope(PERSONAL_SCOPE.key);
30
- if (cfg.logLevel === "debug") {
31
- console.log(`[open-memex] loaded. scope=${scope.key}`);
32
- }
33
- // If a git remote was added *after* memories were first captured, the
34
- // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
35
- // disk and tell the user how to migrate. We do NOT auto-migrate: two
36
- // different repos at the same cwd would collide.
37
- const cwdScope = resolveCwdScope(roots);
38
- if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
39
- console.warn(
40
- `[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
41
- `Current project scope is "${scope.key}". ` +
42
- `To move them: npm run cli -- migrate --from ${cwdScope.key}`,
43
- );
44
- }
45
- } catch (err) {
46
- console.error("[open-memex] init failed:", err);
47
- }
48
-
49
- const tools = makeTools(() => scope, cfg);
50
- const injectedSessions = new Set<string>();
23
+ const state = bootstrapPlugin(roots);
24
+ const tools = makeTools(() => state.scope, state.cfg);
51
25
 
52
26
  return {
53
27
  tool: tools,
54
28
 
55
29
  async "chat.message"(_input, output) {
56
- if (!cfg.keywordCaptureEnabled) return;
57
30
  const parts = (output.parts ?? []) as Array<{ type: string; text?: string }>;
58
31
  const text = parts
59
32
  .map((p) => (p?.type === "text" ? p.text ?? "" : ""))
60
33
  .filter(Boolean)
61
34
  .join("\n");
62
- if (!text) return;
63
-
64
- const hits = detectKeywords(text, cfg);
65
- for (const h of hits) {
66
- const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
67
- // Secrets are masked (first 4 chars kept) and the capture proceeds;
68
- // skip only when nothing usable remains.
69
- if (content.length === 0) continue;
70
- if (hadSecret && cfg.logLevel === "debug") {
71
- console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
72
- }
73
- // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
74
- const target = h.personal ? PERSONAL_SCOPE : scope;
75
- // Dedup (§3.4): skip exact duplicates captured before.
76
- if (findDuplicates(target.key, content).exact) continue;
77
- const now = Date.now();
78
- const rfc = msToRfc3339(now);
79
- const fm: Frontmatter = {
80
- id: ulid(),
81
- schema_version: 2,
82
- scope_key: target.key,
83
- scope: target.kind === "project" ? "project" : "personal",
84
- visibility: target.kind === "project" ? "internal" : "private",
85
- project_name: target.projectName,
86
- type: "fact",
87
- role: "knowledge",
88
- importance: "normal",
89
- status: "active",
90
- tags: ["keyword"],
91
- source: "keyword",
92
- created_at: rfc,
93
- updated_at: rfc,
94
- supersedes: null,
95
- superseded_by: null,
96
- review_state: "draft",
97
- proposed_by: null,
98
- approved_by: null,
99
- derived_from: null,
100
- review_note: null,
101
- review_history: [],
102
- };
103
- try {
104
- const { filePath } = writeMemoryFile(fm, content);
105
- const mf = readMemoryFile(filePath);
106
- if (mf) upsertFromFile(mf);
107
- // Capture feedback: always visible (not debug-only) — the user said
108
- // "记住…", they should see that it landed. The opencode plugin API
109
- // offers no toast channel, so the plugin log is the feedback surface.
110
- const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
111
- console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
112
- } catch (err) {
113
- console.error("[open-memex] keyword capture failed:", err);
114
- }
115
- }
35
+ captureFromText(text, state);
116
36
  },
117
37
 
118
38
  async "experimental.chat.system.transform"(input, output) {
119
- if (!cfg.injectOnFirstTurn) return;
120
- const sid = input.sessionID ?? "";
121
- if (injectedSessions.has(sid)) return;
122
- injectedSessions.add(sid);
123
- try {
124
- const block = buildContextBlock(scope, cfg);
125
- if (block) output.system.push(block);
126
- } catch (err) {
127
- console.error("[open-memex] context injection failed:", err);
128
- }
39
+ injectOnce(input.sessionID ?? "", state, (block) => {
40
+ output.system.push(block);
41
+ });
129
42
  },
130
43
  };
131
44
  };
132
45
 
133
- export default plugin;
46
+ export default { id: PLUGIN_ID, setup: v2Plugin.setup, server };
package/src/init.ts CHANGED
@@ -300,6 +300,16 @@ function opencodePluginUrl(): string {
300
300
  return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
301
301
  }
302
302
 
303
+ /**
304
+ * D60: the same plugin directory, in the form OpenCode 2 wants. v2 rejects
305
+ * file entries in `plugins` ("configured plugin path must be a directory")
306
+ * and loads `<dir>/index.ts` instead.
307
+ */
308
+ function opencodePluginDir(): string {
309
+ const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
310
+ return path.join(pkgRoot, "src");
311
+ }
312
+
303
313
  /**
304
314
  * D46: opencode's user-level config. opencode reads `~/.config/opencode/`
305
315
  * (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
@@ -330,6 +340,65 @@ export function mergePluginEntry(
330
340
  return "added";
331
341
  }
332
342
 
343
+ /** The package path an OpenCode 2 `plugins` entry refers to, if any. */
344
+ function v2EntryPackage(entry: unknown): string | null {
345
+ if (typeof entry === "string") return entry;
346
+ if (entry !== null && typeof entry === "object" && "package" in entry) {
347
+ const p = (entry as { package?: unknown }).package;
348
+ return typeof p === "string" ? p : null;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ /**
354
+ * D60: pure merge of the open-memex directory into the `plugins` array
355
+ * (the OpenCode 2 spelling). An existing open-memex entry (any form)
356
+ * counts as present; with force, stale open-memex entries are replaced
357
+ * by `dir`. Same "added"/"kept" contract as mergePluginEntry.
358
+ */
359
+ export function mergeV2PluginEntry(
360
+ doc: Record<string, unknown>,
361
+ dir: string,
362
+ force: boolean,
363
+ ): "added" | "kept" {
364
+ let plugins = doc["plugins"];
365
+ if (!Array.isArray(plugins)) {
366
+ plugins = [] as unknown[];
367
+ doc["plugins"] = plugins;
368
+ }
369
+ const list = plugins as unknown[];
370
+ const ours = (e: unknown) => (v2EntryPackage(e) ?? "").includes("open-memex");
371
+ if (!force && list.some(ours)) return "kept";
372
+ for (let i = list.length - 1; i >= 0; i--) {
373
+ const p = v2EntryPackage(list[i]);
374
+ if (p === dir) {
375
+ if (!force) return "kept";
376
+ list.splice(i, 1);
377
+ } else if (force && ours(list[i])) {
378
+ list.splice(i, 1);
379
+ }
380
+ }
381
+ list.push(dir);
382
+ return "added";
383
+ }
384
+
385
+ /**
386
+ * Pure removal of open-memex entries from the `plugins` array (OpenCode 2
387
+ * spelling — strings and `{package}` objects). Drops the key when empty.
388
+ */
389
+ export function removeV2PluginEntry(
390
+ doc: Record<string, unknown>,
391
+ match: string,
392
+ ): "removed" | "absent" {
393
+ const plugins = doc["plugins"];
394
+ if (!Array.isArray(plugins)) return "absent";
395
+ const kept = (plugins as unknown[]).filter((p) => !(v2EntryPackage(p) ?? "").includes(match));
396
+ if (kept.length === plugins.length) return "absent";
397
+ if (kept.length === 0) delete doc["plugins"];
398
+ else doc["plugins"] = kept;
399
+ return "removed";
400
+ }
401
+
333
402
  /**
334
403
  * D46: wire the native plugin at the opencode user level — the one-time
335
404
  * global setup. Merges into the existing config when it parses as JSON;
@@ -343,13 +412,15 @@ function writeOpencodeGlobalPlugin(force: boolean): string | null {
343
412
  .map((f) => path.join(dir, f))
344
413
  .find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
345
414
  const url = opencodePluginUrl();
415
+ const pluginDir = opencodePluginDir();
346
416
  let doc: Record<string, unknown> = {};
347
417
  if (fs.existsSync(file)) {
348
418
  try {
349
419
  doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
350
420
  } catch {
351
421
  console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
352
- console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
422
+ console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] (opencode 1)`);
423
+ console.log(` and "plugins": ["${pluginDir}"] (opencode 2) to it.`);
353
424
  console.log(` Also remove any "${LEGACY_PACKAGE_NAME}" entries from "plugin" (stale — package renamed).`);
354
425
  return null;
355
426
  }
@@ -358,8 +429,12 @@ function writeOpencodeGlobalPlugin(force: boolean): string | null {
358
429
  // alongside the new one — it writes to the OLD data dir, silently splitting
359
430
  // memories in two. Drop it wherever init touches the plugin list.
360
431
  const staleRemoved = removePluginEntry(doc, LEGACY_PACKAGE_NAME) === "removed";
432
+ removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
361
433
  const merged = mergePluginEntry(doc, url, force);
362
- if (merged === "kept" && !staleRemoved) {
434
+ // D60: write both key generations — opencode 1 reads `plugin`, opencode 2
435
+ // reads `plugins`; each host ignores the other's key.
436
+ const mergedV2 = mergeV2PluginEntry(doc, pluginDir, force);
437
+ if (merged === "kept" && mergedV2 === "kept" && !staleRemoved) {
363
438
  console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
364
439
  return file;
365
440
  }
@@ -367,7 +442,7 @@ function writeOpencodeGlobalPlugin(force: boolean): string | null {
367
442
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
368
443
  if (staleRemoved)
369
444
  console.log(` - ${file} (stale my-o-memory plugin entry removed — package renamed to open-memex)`);
370
- if (merged === "added")
445
+ if (merged === "added" || mergedV2 === "added")
371
446
  console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
372
447
  return file;
373
448
  }
@@ -819,6 +894,14 @@ export async function initProject(opts: {
819
894
  );
820
895
  if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
821
896
  patch.injectOnFirstTurn = injectOnFirstTurn;
897
+ // D61: capture-time aliases — agents attach alternate phrasings to each
898
+ // memory so differently-worded questions still find it.
899
+ const captureAliases = await askBool(
900
+ "Attach alternate phrasings (aliases) to memories so reworded questions still find them?",
901
+ DEFAULT_CONFIG.captureAliases,
902
+ );
903
+ if (captureAliases !== DEFAULT_CONFIG.captureAliases)
904
+ patch.captureAliases = captureAliases;
822
905
  if (Object.keys(patch).length > 0) {
823
906
  const file = saveConfig(patch);
824
907
  console.log(
@@ -963,13 +1046,16 @@ function removeOpencodePluginFile(file: string): "removed" | "absent" | null {
963
1046
  const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
964
1047
  if (doc === null) {
965
1048
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
966
- console.error(` Delete the open-memex URL from the "plugin" array.`);
1049
+ console.error(` Delete the open-memex URL from the "plugin" array and the open-memex path from "plugins".`);
967
1050
  return null;
968
1051
  }
969
1052
  const res = removePluginEntry(doc, "open-memex");
1053
+ // D60: the v2 `plugins` entry goes too (strings and {package} objects).
1054
+ const resV2 = removeV2PluginEntry(doc, "open-memex");
970
1055
  // F30: also sweep the pre-rename leftover — see removeServerEntryFile.
971
1056
  const stale = removePluginEntry(doc, LEGACY_PACKAGE_NAME);
972
- if (res === "removed" || stale === "removed") {
1057
+ removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
1058
+ if (res === "removed" || resV2 === "removed" || stale === "removed") {
973
1059
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
974
1060
  console.log(` - ${file} (memory-plugin entries removed)`);
975
1061
  return "removed";
package/src/mcp.ts CHANGED
@@ -112,9 +112,10 @@ memory_resolve, memory_pr_status — always call them by these full names.
112
112
  docs, save it as type "reference" pointing at the doc instead of copying it.
113
113
  Long-form notes are fine ONLY when the user explicitly asks to save one.
114
114
  - Before asking the user about past decisions, conventions, or preferences they
115
- may have told you before, call memory_search first — try a few keyword
116
- variants (including the user's own language) when the first search comes up
117
- empty.
115
+ may have told you before, call memory_search first — search well: break the
116
+ question into its concepts and try 2–3 phrasings per concept (synonyms, the
117
+ user's other language, shorter keyword forms), and check the other scope too,
118
+ before concluding nothing is stored.
118
119
  - Memories default to this project's scope; use the personal scope for facts
119
120
  about the user that hold across all projects. When a saved fact becomes
120
121
  outdated, call memory_supersede (find the old memory's id with memory_search
@@ -178,10 +179,16 @@ export async function runMcpServer() {
178
179
  // D53: session-start outbox state, pushed. The stdio server starts fresh per
179
180
  // session, so construction-time state ≈ session-start state.
180
181
  const n = outboxDraftCount(scope.key);
182
+ // D61: the alias guidance rides the handshake only when the install
183
+ // enables capture aliases — otherwise agents would attach aliases the
184
+ // server then drops.
185
+ const aliasGuidance = cfg.captureAliases
186
+ ? "\n- When you save a memory, attach 2–4 aliases via memory_add's aliases parameter: alternate phrasings, synonyms, equivalents in the user's other language. They are indexed with the memory so a differently-worded question still finds it."
187
+ : "";
181
188
  const instructions =
182
- n > 0
189
+ (n > 0
183
190
  ? `${SERVER_INSTRUCTIONS}\n\nSession start: the project outbox has ${n} draft${n === 1 ? "" : "s"} waiting for review — call memory_status to see them.`
184
- : SERVER_INSTRUCTIONS;
191
+ : SERVER_INSTRUCTIONS) + aliasGuidance;
185
192
 
186
193
  const server = new McpServer(
187
194
  { name: "open-memex", version: SERVER_VERSION },
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+ import {
3
+ PLUGIN_ID,
4
+ bootstrapPlugin,
5
+ captureFromText,
6
+ injectOnce,
7
+ type PluginState,
8
+ } from "./plugin-core.ts";
9
+ import { makeTools } from "./tools/memory.ts";
10
+
11
+ /**
12
+ * OpenCode 2 adapter (D60). The v2 host structurally decodes the plugin's
13
+ * default export as `{ id, setup }` — `Plugin.define` is an identity
14
+ * wrapper, so a plain object with no runtime import of `@opencode/plugin`
15
+ * loads identically. Importing the SDK at runtime would repeat the D58
16
+ * failure (devDependency, unresolvable from a global install, silent
17
+ * skip); the context is duck-typed here from the 2.0.x surface instead.
18
+ *
19
+ * v1/v2 hook mapping used below:
20
+ * tool map → ctx.tool.transform (JSON Schema in,
21
+ * { content } out)
22
+ * chat.message (keyword capture) → ctx.session.hook("prompt") — fires
23
+ * once per submitted prompt
24
+ * experimental.chat.system.transform → ctx.session.hook("context") — runs
25
+ * before each model request; the
26
+ * system array is mutable
27
+ *
28
+ * Tool registration must opt out of code mode (`options.codemode: false`,
29
+ * verified on 2.0.1): by default the host exposes plugin tools only
30
+ * through an `execute` meta-tool that runs model-written JS, and a model
31
+ * that calls the tool by name instead gets "Unknown tool" — flaky,
32
+ * model-dependent behavior. With the opt-out the five tools are plain
33
+ * function calls, same as on v1 and MCP.
34
+ */
35
+
36
+ interface V2ToolInfo {
37
+ name: string;
38
+ description: string;
39
+ input: unknown;
40
+ options?: { codemode?: boolean };
41
+ execute: (input: Record<string, unknown>) => Promise<{ content: string }>;
42
+ }
43
+
44
+ interface V2Registration {
45
+ dispose?: () => Promise<void>;
46
+ }
47
+
48
+ export interface V2PluginContext {
49
+ app?: { version?: string };
50
+ location?: { directory?: string; project?: { directory?: string } };
51
+ tool: {
52
+ transform: (cb: (editor: { add: (tool: V2ToolInfo) => void }) => void) => Promise<unknown>;
53
+ };
54
+ session: {
55
+ hook: (
56
+ name: "prompt" | "context",
57
+ cb: (event: never) => void | Promise<void>,
58
+ ) => Promise<V2Registration>;
59
+ };
60
+ }
61
+
62
+ interface V2PromptEvent {
63
+ sessionID: string;
64
+ prompt?: { text?: string };
65
+ }
66
+
67
+ interface V2ContextEvent {
68
+ sessionID: string;
69
+ system: unknown[];
70
+ }
71
+
72
+ /** Register the five memory tools on a v2 host. */
73
+ async function registerTools(ctx: V2PluginContext, state: PluginState): Promise<void> {
74
+ const tools = makeTools(() => state.scope, state.cfg);
75
+ await ctx.tool.transform((editor) => {
76
+ for (const [name, t] of Object.entries(tools)) {
77
+ editor.add({
78
+ name,
79
+ description: t.description,
80
+ input: z.toJSONSchema(z.object(t.args)),
81
+ options: { codemode: false },
82
+ execute: async (input) => {
83
+ const r = await t.execute(input as never, undefined as never);
84
+ const content =
85
+ typeof r === "string" ? r : ((r as { output?: string }).output ?? JSON.stringify(r));
86
+ return { content };
87
+ },
88
+ });
89
+ }
90
+ });
91
+ }
92
+
93
+ export const v2Plugin = {
94
+ id: PLUGIN_ID,
95
+ async setup(ctx: V2PluginContext) {
96
+ const root = ctx.location?.directory ?? ctx.location?.project?.directory ?? process.cwd();
97
+ const state = bootstrapPlugin(root);
98
+ const registrations: V2Registration[] = [];
99
+
100
+ await registerTools(ctx, state);
101
+
102
+ registrations.push(
103
+ await ctx.session.hook("prompt", (ev: V2PromptEvent) => {
104
+ captureFromText(ev.prompt?.text ?? "", state);
105
+ }),
106
+ );
107
+ registrations.push(
108
+ await ctx.session.hook("context", (ev: V2ContextEvent) => {
109
+ injectOnce(ev.sessionID ?? "", state, (block) => {
110
+ ev.system.push({ type: "text", text: block });
111
+ });
112
+ }),
113
+ );
114
+
115
+ return () => {
116
+ for (const r of registrations) void r.dispose?.();
117
+ };
118
+ },
119
+ };
@@ -0,0 +1,152 @@
1
+ import { loadConfig, type MyOMemoryConfig } from "./config.ts";
2
+ import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE, type Scope } from "./scope.ts";
3
+ import { db } from "./store/db.ts";
4
+ import { syncScope, upsertFromFile } from "./store/sync.ts";
5
+ import { scopeHasFiles } from "./store/migrate.ts";
6
+ import {
7
+ writeMemoryFile,
8
+ readMemoryFile,
9
+ ulid,
10
+ msToRfc3339,
11
+ type Frontmatter,
12
+ } from "./store/markdown.ts";
13
+ import { buildContextBlock } from "./retrieve/inject.ts";
14
+ import { detectKeywords } from "./capture/keywords.ts";
15
+ import { findDuplicates } from "./store/lifecycle.ts";
16
+ import { redact } from "./redact.ts";
17
+
18
+ /**
19
+ * Host-agnostic plugin core (D60). Both opencode host generations adapt to
20
+ * this module: the v1 plugin (src/index.ts `server`) and the v2 plugin
21
+ * (src/opencode-v2.ts `setup`). Host-specific glue — tool registration and
22
+ * hook shapes — stays in the adapters; everything memory-shaped lives here.
23
+ */
24
+ export const PLUGIN_ID = "open-memex";
25
+
26
+ export interface PluginState {
27
+ cfg: MyOMemoryConfig;
28
+ scope: Scope;
29
+ /** Sessions that already received the one-time context injection. */
30
+ injectedSessions: Set<string>;
31
+ }
32
+
33
+ /**
34
+ * Resolve the project scope for a host working directory and bring the
35
+ * index up to date. Never throws: a broken store must not keep the host
36
+ * from starting — failures land in the host's log instead.
37
+ */
38
+ export function bootstrapPlugin(root: string): PluginState {
39
+ const cfg = loadConfig();
40
+ const scope = resolveProjectScope(root);
41
+ try {
42
+ db();
43
+ syncScope(scope.key);
44
+ syncScope(PERSONAL_SCOPE.key);
45
+ if (cfg.logLevel === "debug") {
46
+ console.log(`[open-memex] loaded. scope=${scope.key}`);
47
+ }
48
+ // If a git remote was added *after* memories were first captured, the
49
+ // scope key changes (cwd-hash -> origin-hash). Detect the legacy dir on
50
+ // disk and tell the user how to migrate. We do NOT auto-migrate: two
51
+ // different repos at the same cwd would collide.
52
+ const cwdScope = resolveCwdScope(root);
53
+ if (cwdScope.key !== scope.key && scopeHasFiles(cwdScope.key)) {
54
+ console.warn(
55
+ `[open-memex] found memories under legacy scope key "${cwdScope.key}". ` +
56
+ `Current project scope is "${scope.key}". ` +
57
+ `To move them: npm run cli -- migrate --from ${cwdScope.key}`,
58
+ );
59
+ }
60
+ } catch (err) {
61
+ console.error("[open-memex] init failed:", err);
62
+ }
63
+ return { cfg, scope, injectedSessions: new Set<string>() };
64
+ }
65
+
66
+ /**
67
+ * Keyword capture (§ capture): scan one user message for "remember …"
68
+ * triggers and save each hit. Shared by the v1 `chat.message` hook and
69
+ * the v2 session `prompt` hook — both hand us the message text.
70
+ */
71
+ export function captureFromText(text: string, state: PluginState): void {
72
+ const { cfg, scope } = state;
73
+ if (!cfg.keywordCaptureEnabled || !text) return;
74
+ const hits = detectKeywords(text, cfg);
75
+ for (const h of hits) {
76
+ const { content, hadSecret, matchedPattern } = redact(h.content, cfg.redactPatterns);
77
+ // Secrets are masked (first 4 chars kept) and the capture proceeds;
78
+ // skip only when nothing usable remains.
79
+ if (content.length === 0) continue;
80
+ if (hadSecret && cfg.logLevel === "debug") {
81
+ console.log(`[open-memex] keyword capture masked secret (${matchedPattern})`);
82
+ }
83
+ // Personal patterns ("remember for me" / "记住(个人)") force the personal scope.
84
+ const target = h.personal ? PERSONAL_SCOPE : scope;
85
+ // Dedup (§3.4): skip exact duplicates captured before.
86
+ if (findDuplicates(target.key, content).exact) continue;
87
+ const now = Date.now();
88
+ const rfc = msToRfc3339(now);
89
+ const fm: Frontmatter = {
90
+ id: ulid(),
91
+ schema_version: 2,
92
+ scope_key: target.key,
93
+ scope: target.kind === "project" ? "project" : "personal",
94
+ visibility: target.kind === "project" ? "internal" : "private",
95
+ project_name: target.projectName,
96
+ type: "fact",
97
+ role: "knowledge",
98
+ importance: "normal",
99
+ status: "active",
100
+ tags: ["keyword"],
101
+ source: "keyword",
102
+ created_at: rfc,
103
+ updated_at: rfc,
104
+ supersedes: null,
105
+ superseded_by: null,
106
+ review_state: "draft",
107
+ proposed_by: null,
108
+ approved_by: null,
109
+ derived_from: null,
110
+ review_note: null,
111
+ review_history: [],
112
+ };
113
+ try {
114
+ const { filePath } = writeMemoryFile(fm, content);
115
+ const mf = readMemoryFile(filePath);
116
+ if (mf) upsertFromFile(mf);
117
+ // Capture feedback: always visible (not debug-only) — the user said
118
+ // "记住…", they should see that it landed. The opencode plugin API
119
+ // offers no toast channel, so the plugin log is the feedback surface.
120
+ const preview = content.length > 60 ? content.slice(0, 60) + "…" : content;
121
+ console.log(`[open-memex] remembered → ${target.kind} scope: "${preview}"`);
122
+ } catch (err) {
123
+ console.error("[open-memex] keyword capture failed:", err);
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * First-turn context injection: exactly once per session. The host adapter
130
+ * supplies `push` (v1: output.system.push; v2: the session context hook's
131
+ * system array). Returns true when a block was pushed.
132
+ */
133
+ export function injectOnce(
134
+ sessionID: string,
135
+ state: PluginState,
136
+ push: (block: string) => void,
137
+ ): boolean {
138
+ const { cfg, scope } = state;
139
+ if (!cfg.injectOnFirstTurn) return false;
140
+ if (state.injectedSessions.has(sessionID)) return false;
141
+ state.injectedSessions.add(sessionID);
142
+ try {
143
+ const block = buildContextBlock(scope, cfg);
144
+ if (block) {
145
+ push(block);
146
+ return true;
147
+ }
148
+ } catch (err) {
149
+ console.error("[open-memex] context injection failed:", err);
150
+ }
151
+ return false;
152
+ }
@@ -28,7 +28,7 @@ export function buildContextBlock(scope: Scope, cfg: MyOMemoryConfig): string |
28
28
 
29
29
  lines.push(
30
30
  "",
31
- "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. Do not mention this block to the user unless asked.",
31
+ "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. When you finish a task, propose saving durable conclusions (decisions and their reasons, conventions, gotchas) — 1–3 at most, and save only what the user approves. Do not mention this block to the user unless asked.",
32
32
  );
33
33
 
34
34
  return lines.join("\n");
@@ -7,6 +7,8 @@ export interface SearchHit {
7
7
  project_name: string;
8
8
  type: string;
9
9
  tags: string[];
10
+ /** D61: capture-time aliases stored on this memory (search hits only). */
11
+ aliases: string[];
10
12
  snippet: string;
11
13
  score: number;
12
14
  updated_at: number;
@@ -20,6 +22,7 @@ interface RawRow {
20
22
  project_name: string;
21
23
  type: string;
22
24
  tags: string;
25
+ aliases?: string;
23
26
  updated_at: number;
24
27
  snippet: string;
25
28
  score: number;
@@ -29,13 +32,22 @@ interface RawRow {
29
32
  }
30
33
 
31
34
  function toHit(r: RawRow): SearchHit {
35
+ const aliases = r.aliases ? r.aliases.split(",").map((a) => a.trim()).filter(Boolean) : [];
36
+ let snippet = r.snippet ?? "";
37
+ // D61: the snippet highlights matches in the body column only. When the
38
+ // match came through an alias (body shows no highlight), say so — the
39
+ // hit otherwise looks unrelated to the query.
40
+ if (aliases.length > 0 && !/\[[^\]]+\]/.test(snippet)) {
41
+ snippet += ` (aka: ${aliases.join(", ")})`;
42
+ }
32
43
  return {
33
44
  id: r.id,
34
45
  scope_key: r.scope_key,
35
46
  project_name: r.project_name,
36
47
  type: r.type,
37
48
  tags: r.tags ? r.tags.split(",").filter(Boolean) : [],
38
- snippet: r.snippet ?? "",
49
+ aliases,
50
+ snippet,
39
51
  score: r.score,
40
52
  updated_at: r.updated_at,
41
53
  status: r.status,
@@ -161,7 +173,7 @@ export function search(
161
173
  const typeFilter = opts.type ? ` AND m.type = ?` : "";
162
174
 
163
175
  const sql = `
164
- SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
176
+ SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.aliases, m.updated_at,
165
177
  m.status, m.superseded_by, m.review_state,
166
178
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
167
179
  bm25(memories_fts) AS score