@zilliz/memsearch-opencode 0.3.10 → 0.3.11

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,34 @@ function shellEscape(s: string): string {
144
150
  return s.replace(/'/g, "'\\''");
145
151
  }
146
152
 
153
+ /** Marks the start of memsearch's injected block within a system message. */
154
+ export const MEMSEARCH_SYSTEM_MARKER = "[memsearch] Memory available.";
155
+
156
+ /**
157
+ * Merge memsearch's memory context into an `output.system` array without
158
+ * growing it. Some backends (litellm/vllm serving e.g. Qwen models) reject a
159
+ * multi-entry `output.system` array with "system message must be first", so
160
+ * the memory block is folded into the first entry instead of pushed as a new
161
+ * one. If a memsearch block from a previous transform call is already present
162
+ * (identified by MEMSEARCH_SYSTEM_MARKER), it is replaced in place rather than
163
+ * appended again, so repeated calls against the same output stay idempotent.
164
+ */
165
+ export function mergeSystemMemoryContext(
166
+ system: string[] | undefined,
167
+ memoryText: string
168
+ ): string[] {
169
+ if (!Array.isArray(system) || system.length === 0) {
170
+ return [memoryText];
171
+ }
172
+ const result = [...system];
173
+ const existing = result[0];
174
+ const markerIndex = existing.indexOf(MEMSEARCH_SYSTEM_MARKER);
175
+ const base =
176
+ markerIndex === -1 ? existing : existing.slice(0, markerIndex).replace(/\n+$/, "");
177
+ result[0] = base ? `${base}\n\n${memoryText}` : memoryText;
178
+ return result;
179
+ }
180
+
147
181
  /**
148
182
  * Start the capture daemon as a background process.
149
183
  * The daemon polls OpenCode's SQLite for completed turns and writes to daily .md files.
@@ -153,6 +187,7 @@ function startCaptureDaemon(
153
187
  collectionName: string,
154
188
  memsearchCmd: string
155
189
  ): void {
190
+ const stateDir = join(projectDir, ".memsearch");
156
191
  const pidFile = join(projectDir, ".memsearch", ".capture.pid");
157
192
  const daemonScript = join(PLUGIN_DIR, "scripts", "capture-daemon.py");
158
193
 
@@ -167,21 +202,40 @@ function startCaptureDaemon(
167
202
  return; // Already running
168
203
  } catch {
169
204
  // Process is dead, clean up stale PID file
205
+ try { unlinkSync(pidFile); } catch { /* ignore */ }
170
206
  }
171
207
  }
172
208
  } catch { /* ignore */ }
173
209
  }
174
210
 
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
- );
211
+ try {
212
+ mkdirSync(stateDir, { recursive: true });
213
+ const child = spawn(
214
+ "python3",
215
+ [
216
+ daemonScript,
217
+ projectDir,
218
+ collectionName,
219
+ "--memsearch-cmd",
220
+ memsearchCmd,
221
+ "--poll-interval",
222
+ "10",
223
+ "--parent-pid",
224
+ String(process.pid),
225
+ ],
226
+ {
227
+ detached: true,
228
+ stdio: "ignore",
229
+ env: { ...process.env, MEMSEARCH_NO_WATCH: "1" },
230
+ }
231
+ );
232
+ child.unref();
233
+ if (child.pid) {
234
+ try { writeFileSync(pidFile, String(child.pid), "utf-8"); } catch { /* ignore */ }
235
+ }
236
+ } catch {
237
+ // Capture is best-effort; tools still work without the background daemon.
238
+ }
185
239
  }
186
240
 
187
241
  /**
@@ -380,9 +434,10 @@ const MemsearchPlugin: Plugin = async ({ project, directory, worktree }) => {
380
434
  try {
381
435
  const context = getRecentMemories(memoryDir);
382
436
  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
- );
437
+ const memoryText =
438
+ `${MEMSEARCH_SYSTEM_MARKER} You have access to memory_search, ` +
439
+ `memory_get, and memory_transcript tools for recalling past sessions.\n\n${context}`;
440
+ output.system = mergeSystemMemoryContext(output.system, memoryText);
386
441
  }
387
442
  } catch { /* ignore */ }
388
443
  },
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.11",
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