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/AGENTS.md +3 -2
- package/CHANGELOG.md +19 -1
- package/README.md +8 -1
- package/README.zh-CN.md +3 -1
- package/dist/doctor.js +143 -32
- package/dist/index.js +20 -112
- package/dist/init.js +80 -5
- package/dist/opencode-v2.js +43 -0
- package/dist/plugin-core.js +138 -0
- package/docs/V2-DESIGN.md +40 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +22 -1
- package/src/doctor.ts +140 -29
- package/src/index.ts +25 -112
- package/src/init.ts +83 -5
- package/src/opencode-v2.ts +119 -0
- package/src/plugin-core.ts +152 -0
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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}"]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|