@zilliz/memsearch-opencode 0.3.10 → 0.3.12
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/index.ts +92 -17
- package/package.json +4 -1
- package/scripts/capture-daemon.py +21 -0
- package/skills/memory-config/SKILL.md +7 -0
- package/skills/memory-to-skill/SKILL.md +6 -0
package/index.ts
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
15
15
|
import { tool } from "@opencode-ai/plugin";
|
|
16
|
-
import { execSync, exec, spawnSync } from "node:child_process";
|
|
16
|
+
import { execSync, exec, spawn, spawnSync } from "node:child_process";
|
|
17
17
|
import {
|
|
18
18
|
readFileSync,
|
|
19
19
|
existsSync,
|
|
20
20
|
mkdirSync,
|
|
21
21
|
readdirSync,
|
|
22
22
|
realpathSync,
|
|
23
|
+
unlinkSync,
|
|
24
|
+
writeFileSync,
|
|
23
25
|
} from "node:fs";
|
|
24
26
|
import { join, dirname } from "node:path";
|
|
25
27
|
import { fileURLToPath } from "node:url";
|
|
@@ -107,7 +109,11 @@ function recentMemoryPreviewLines(content: string, maxLines: number): string[] {
|
|
|
107
109
|
return sections.flat().slice(-maxLines);
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
function
|
|
112
|
+
export function isDailyJournalFile(file: string): boolean {
|
|
113
|
+
return /^\d{4}-\d{2}-\d{2}\.md$/.test(file);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getRecentMemories(
|
|
111
117
|
memDir: string,
|
|
112
118
|
count = 2,
|
|
113
119
|
maxLinesPerFile = 30
|
|
@@ -115,7 +121,7 @@ function getRecentMemories(
|
|
|
115
121
|
if (!existsSync(memDir)) return "";
|
|
116
122
|
|
|
117
123
|
const files = readdirSync(memDir)
|
|
118
|
-
.filter(
|
|
124
|
+
.filter(isDailyJournalFile)
|
|
119
125
|
.sort()
|
|
120
126
|
.slice(-count);
|
|
121
127
|
|
|
@@ -144,6 +150,51 @@ function shellEscape(s: string): string {
|
|
|
144
150
|
return s.replace(/'/g, "'\\''");
|
|
145
151
|
}
|
|
146
152
|
|
|
153
|
+
export function getSkillCandidateHint(memsearchDir: string, memsearchCmd: string): string {
|
|
154
|
+
try {
|
|
155
|
+
const result = spawnSync(
|
|
156
|
+
"bash",
|
|
157
|
+
[
|
|
158
|
+
"-c",
|
|
159
|
+
`MEMSEARCH_DIR='${shellEscape(memsearchDir)}' ${memsearchCmd} skills status --hint`,
|
|
160
|
+
],
|
|
161
|
+
{ encoding: "utf-8", timeout: 5000 }
|
|
162
|
+
);
|
|
163
|
+
if (result.status !== 0) return "";
|
|
164
|
+
return (result.stdout || "").trim().split("\n")[0] || "";
|
|
165
|
+
} catch {
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Marks the start of memsearch's injected block within a system message. */
|
|
171
|
+
export const MEMSEARCH_SYSTEM_MARKER = "[memsearch] Memory available.";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Merge memsearch's memory context into an `output.system` array without
|
|
175
|
+
* growing it. Some backends (litellm/vllm serving e.g. Qwen models) reject a
|
|
176
|
+
* multi-entry `output.system` array with "system message must be first", so
|
|
177
|
+
* the memory block is folded into the first entry instead of pushed as a new
|
|
178
|
+
* one. If a memsearch block from a previous transform call is already present
|
|
179
|
+
* (identified by MEMSEARCH_SYSTEM_MARKER), it is replaced in place rather than
|
|
180
|
+
* appended again, so repeated calls against the same output stay idempotent.
|
|
181
|
+
*/
|
|
182
|
+
export function mergeSystemMemoryContext(
|
|
183
|
+
system: string[] | undefined,
|
|
184
|
+
memoryText: string
|
|
185
|
+
): string[] {
|
|
186
|
+
if (!Array.isArray(system) || system.length === 0) {
|
|
187
|
+
return [memoryText];
|
|
188
|
+
}
|
|
189
|
+
const result = [...system];
|
|
190
|
+
const existing = result[0];
|
|
191
|
+
const markerIndex = existing.indexOf(MEMSEARCH_SYSTEM_MARKER);
|
|
192
|
+
const base =
|
|
193
|
+
markerIndex === -1 ? existing : existing.slice(0, markerIndex).replace(/\n+$/, "");
|
|
194
|
+
result[0] = base ? `${base}\n\n${memoryText}` : memoryText;
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
147
198
|
/**
|
|
148
199
|
* Start the capture daemon as a background process.
|
|
149
200
|
* The daemon polls OpenCode's SQLite for completed turns and writes to daily .md files.
|
|
@@ -153,6 +204,7 @@ function startCaptureDaemon(
|
|
|
153
204
|
collectionName: string,
|
|
154
205
|
memsearchCmd: string
|
|
155
206
|
): void {
|
|
207
|
+
const stateDir = join(projectDir, ".memsearch");
|
|
156
208
|
const pidFile = join(projectDir, ".memsearch", ".capture.pid");
|
|
157
209
|
const daemonScript = join(PLUGIN_DIR, "scripts", "capture-daemon.py");
|
|
158
210
|
|
|
@@ -167,21 +219,40 @@ function startCaptureDaemon(
|
|
|
167
219
|
return; // Already running
|
|
168
220
|
} catch {
|
|
169
221
|
// Process is dead, clean up stale PID file
|
|
222
|
+
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
|
170
223
|
}
|
|
171
224
|
}
|
|
172
225
|
} catch { /* ignore */ }
|
|
173
226
|
}
|
|
174
227
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
228
|
+
try {
|
|
229
|
+
mkdirSync(stateDir, { recursive: true });
|
|
230
|
+
const child = spawn(
|
|
231
|
+
"python3",
|
|
232
|
+
[
|
|
233
|
+
daemonScript,
|
|
234
|
+
projectDir,
|
|
235
|
+
collectionName,
|
|
236
|
+
"--memsearch-cmd",
|
|
237
|
+
memsearchCmd,
|
|
238
|
+
"--poll-interval",
|
|
239
|
+
"10",
|
|
240
|
+
"--parent-pid",
|
|
241
|
+
String(process.pid),
|
|
242
|
+
],
|
|
243
|
+
{
|
|
244
|
+
detached: true,
|
|
245
|
+
stdio: "ignore",
|
|
246
|
+
env: { ...process.env, MEMSEARCH_NO_WATCH: "1" },
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
child.unref();
|
|
250
|
+
if (child.pid) {
|
|
251
|
+
try { writeFileSync(pidFile, String(child.pid), "utf-8"); } catch { /* ignore */ }
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// Capture is best-effort; tools still work without the background daemon.
|
|
255
|
+
}
|
|
185
256
|
}
|
|
186
257
|
|
|
187
258
|
/**
|
|
@@ -223,6 +294,7 @@ const MemsearchPlugin: Plugin = async ({ project, directory, worktree }) => {
|
|
|
223
294
|
const collectionName = deriveCollectionName(projectDir);
|
|
224
295
|
const memsearchDir = join(projectDir, ".memsearch");
|
|
225
296
|
const memoryDir = join(memsearchDir, "memory");
|
|
297
|
+
const skillCandidateHint = getSkillCandidateHint(memsearchDir, memsearchCmd);
|
|
226
298
|
const home = process.env.HOME || "~";
|
|
227
299
|
|
|
228
300
|
// Skip capture/recall in child processes to prevent recursion
|
|
@@ -379,10 +451,13 @@ const MemsearchPlugin: Plugin = async ({ project, directory, worktree }) => {
|
|
|
379
451
|
"experimental.chat.system.transform": async (_input: any, output: any) => {
|
|
380
452
|
try {
|
|
381
453
|
const context = getRecentMemories(memoryDir);
|
|
382
|
-
if (context) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
454
|
+
if (context || skillCandidateHint) {
|
|
455
|
+
const memoryText =
|
|
456
|
+
`${MEMSEARCH_SYSTEM_MARKER} You have access to memory_search, ` +
|
|
457
|
+
`memory_get, and memory_transcript tools for recalling past sessions.` +
|
|
458
|
+
`${skillCandidateHint ? `\n${skillCandidateHint}` : ""}` +
|
|
459
|
+
`${context ? `\n\n${context}` : ""}`;
|
|
460
|
+
output.system = mergeSystemMemoryContext(output.system, memoryText);
|
|
386
461
|
}
|
|
387
462
|
} catch { /* ignore */ }
|
|
388
463
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zilliz/memsearch-opencode",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"description": "memsearch plugin for OpenCode — semantic memory search across sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"semantic-search",
|
|
24
24
|
"milvus"
|
|
25
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test"
|
|
28
|
+
},
|
|
26
29
|
"author": "memsearch contributors",
|
|
27
30
|
"license": "MIT",
|
|
28
31
|
"peerDependencies": {
|
|
@@ -481,6 +481,21 @@ def wake_maintenance(project_dir: str) -> None:
|
|
|
481
481
|
)
|
|
482
482
|
|
|
483
483
|
|
|
484
|
+
def process_is_alive(pid: int) -> bool:
|
|
485
|
+
"""Return whether a process exists without sending it a signal."""
|
|
486
|
+
if pid <= 0:
|
|
487
|
+
return True
|
|
488
|
+
try:
|
|
489
|
+
os.kill(pid, 0)
|
|
490
|
+
except ProcessLookupError:
|
|
491
|
+
return False
|
|
492
|
+
except PermissionError:
|
|
493
|
+
return True
|
|
494
|
+
except OSError:
|
|
495
|
+
return True
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
|
|
484
499
|
def get_session_ids(conn: sqlite3.Connection, project_dir: str) -> list[str]:
|
|
485
500
|
"""Find OpenCode sessions that belong to the given project directory."""
|
|
486
501
|
sessions = conn.execute(
|
|
@@ -764,6 +779,7 @@ def main() -> None:
|
|
|
764
779
|
parser.add_argument("collection_name", help="Milvus collection name")
|
|
765
780
|
parser.add_argument("--memsearch-cmd", default="memsearch", help="memsearch command")
|
|
766
781
|
parser.add_argument("--poll-interval", type=int, default=10, help="Poll interval in seconds")
|
|
782
|
+
parser.add_argument("--parent-pid", type=int, default=0, help="Exit when this parent process is gone")
|
|
767
783
|
args = parser.parse_args()
|
|
768
784
|
|
|
769
785
|
db_path = get_db_path()
|
|
@@ -791,6 +807,9 @@ def main() -> None:
|
|
|
791
807
|
tail_turn_cache: dict[str, TailTurnObservation] = {}
|
|
792
808
|
|
|
793
809
|
while True:
|
|
810
|
+
if args.parent_pid and not process_is_alive(args.parent_pid):
|
|
811
|
+
cleanup()
|
|
812
|
+
|
|
794
813
|
any_new = False
|
|
795
814
|
conn = None
|
|
796
815
|
try:
|
|
@@ -820,6 +839,8 @@ def main() -> None:
|
|
|
820
839
|
if conn is not None:
|
|
821
840
|
conn.close()
|
|
822
841
|
|
|
842
|
+
if args.parent_pid and not process_is_alive(args.parent_pid):
|
|
843
|
+
cleanup()
|
|
823
844
|
time.sleep(args.poll_interval)
|
|
824
845
|
|
|
825
846
|
|
|
@@ -93,6 +93,8 @@ Check index health:
|
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
95
|
memsearch stats
|
|
96
|
+
STATE_DIR="${MEMSEARCH_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.memsearch}"
|
|
97
|
+
test -f "$STATE_DIR/.index-state.json" && cat "$STATE_DIR/.index-state.json"
|
|
96
98
|
```
|
|
97
99
|
|
|
98
100
|
OpenCode transcript recall reads from OpenCode's SQLite database, while captured MemSearch memory lives as markdown under `.memsearch/memory/`.
|
|
@@ -208,6 +210,11 @@ Model guidance:
|
|
|
208
210
|
|
|
209
211
|
Advanced maintenance runs after the plugin wakes it, only when enabled, journal input changed, and `min_interval_hours` elapsed. `PROJECT.md` and `USER.md` are maintenance artifacts by default and are not automatically indexed.
|
|
210
212
|
|
|
213
|
+
If indexing seems silent or search looks stale, check `.memsearch/.index-state.json`
|
|
214
|
+
for `status`, `last_error`, and `failed_files`. `status: degraded` means the
|
|
215
|
+
scan completed but one or more files failed; `status: error` means the index run
|
|
216
|
+
did not complete.
|
|
217
|
+
|
|
211
218
|
If advanced maintenance or `memory_to_skill` seems silent, check
|
|
212
219
|
`.memsearch/.maintenance-state.json` for `<plugin>.<task>.last_error` and
|
|
213
220
|
`last_failed_at`; background hook errors may not surface in the chat.
|
|
@@ -47,10 +47,16 @@ captured automatically going forward) — do not force it.
|
|
|
47
47
|
## B. Review & install candidates (1→2)
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
+
memsearch skills status # pending candidate versions needing install
|
|
50
51
|
memsearch skills list # add -j for sources / installed paths
|
|
51
52
|
git -C .memsearch/skill-candidates log --oneline -5 2>/dev/null || true
|
|
52
53
|
```
|
|
53
54
|
|
|
55
|
+
`skills status` compares each candidate's current `SKILL.md` content hash with
|
|
56
|
+
the hash recorded by the last `skills install`. It does not inspect live agent
|
|
57
|
+
skill directories. A pending installed skill means the candidate source evolved
|
|
58
|
+
after the last deliberate install; reinstall only after reviewing the candidate.
|
|
59
|
+
|
|
54
60
|
Before recommending or installing, skim the candidate's body: if a step looks uncertain or loosely summarized, re-check it against the source (open the transcript if needed) or flag it to the user and let them decide — installing copies the candidate as-is, so this is the last chance to catch a wrong step.
|
|
55
61
|
When showing candidates, mention the store's recent git history when it helps
|
|
56
62
|
explain whether a candidate is new, evolved, removed, or re-created.
|