@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 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 getRecentMemories(
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((f) => f.endsWith(".md"))
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
- // Start daemon in background
176
- exec(
177
- `python3 "${daemonScript}" "${projectDir}" "${collectionName}" ` +
178
- `--memsearch-cmd "${shellEscape(memsearchCmd)}" --poll-interval 10 &`,
179
- {
180
- timeout: 5000,
181
- env: { ...process.env, MEMSEARCH_NO_WATCH: "1" },
182
- },
183
- () => { /* ignore */ }
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
- output.system.push(
384
- `[memsearch] Memory available. You have access to memory_search, memory_get, and memory_transcript tools for recalling past sessions.\n\n${context}`
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.10",
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.