open-memex 0.6.2 → 0.7.0-alpha.1

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
  }
@@ -963,13 +1038,16 @@ function removeOpencodePluginFile(file: string): "removed" | "absent" | null {
963
1038
  const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
964
1039
  if (doc === null) {
965
1040
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
966
- console.error(` Delete the open-memex URL from the "plugin" array.`);
1041
+ console.error(` Delete the open-memex URL from the "plugin" array and the open-memex path from "plugins".`);
967
1042
  return null;
968
1043
  }
969
1044
  const res = removePluginEntry(doc, "open-memex");
1045
+ // D60: the v2 `plugins` entry goes too (strings and {package} objects).
1046
+ const resV2 = removeV2PluginEntry(doc, "open-memex");
970
1047
  // F30: also sweep the pre-rename leftover — see removeServerEntryFile.
971
1048
  const stale = removePluginEntry(doc, LEGACY_PACKAGE_NAME);
972
- if (res === "removed" || stale === "removed") {
1049
+ removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
1050
+ if (res === "removed" || resV2 === "removed" || stale === "removed") {
973
1051
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
974
1052
  console.log(` - ${file} (memory-plugin entries removed)`);
975
1053
  return "removed";
@@ -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
+ }