@zilliz/memsearch-opencode 0.3.11 → 0.3.13

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/context.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Summarize the N most recent daily .md files for cold-start context.
7
+ * Extracts recent non-empty session sections so empty SessionStart headings
8
+ * do not crowd out useful context.
9
+ */
10
+ function recentMemoryPreviewLines(content: string, maxLines: number): string[] {
11
+ const sections: string[][] = [];
12
+ let current: string[] = [];
13
+ let hasBody = false;
14
+
15
+ const flush = () => {
16
+ if (current.length > 0 && hasBody) {
17
+ sections.push(current);
18
+ }
19
+ current = [];
20
+ hasBody = false;
21
+ };
22
+
23
+ for (const rawLine of content.split("\n")) {
24
+ const line = rawLine.trimEnd();
25
+ if (/^##\s/.test(line)) {
26
+ flush();
27
+ current = [line];
28
+ continue;
29
+ }
30
+ if (/^#{3,4}\s/.test(line)) {
31
+ current.push(line);
32
+ continue;
33
+ }
34
+ if (line.startsWith("- ") || line.startsWith("[User]") || line.startsWith("[Assistant]")) {
35
+ current.push(line);
36
+ hasBody = true;
37
+ }
38
+ }
39
+
40
+ flush();
41
+ return sections.flat().slice(-maxLines);
42
+ }
43
+
44
+ export function isDailyJournalFile(file: string): boolean {
45
+ return /^\d{4}-\d{2}-\d{2}\.md$/.test(file);
46
+ }
47
+
48
+ export function getRecentMemories(
49
+ memDir: string,
50
+ count = 2,
51
+ maxLinesPerFile = 30
52
+ ): string {
53
+ if (!existsSync(memDir)) return "";
54
+
55
+ const files = readdirSync(memDir)
56
+ .filter(isDailyJournalFile)
57
+ .sort()
58
+ .slice(-count);
59
+
60
+ if (files.length === 0) return "";
61
+
62
+ const summary: string[] = [];
63
+ for (const file of files) {
64
+ try {
65
+ const content = readFileSync(join(memDir, file), "utf-8");
66
+ const lines = recentMemoryPreviewLines(content, maxLinesPerFile);
67
+ if (lines.length > 0) {
68
+ summary.push(`[${file}]`, ...lines);
69
+ }
70
+ } catch { /* skip */ }
71
+ }
72
+
73
+ if (summary.length === 0) {
74
+ return `You have ${files.length} past memory file(s). Use the memory_search tool when the user's question could benefit from historical context.`;
75
+ }
76
+
77
+ return `Recent memories (use memory_search for full search):\n${summary.join("\n")}`;
78
+ }
79
+
80
+ /** Shell-escape a string for safe use inside single quotes. */
81
+ export function shellEscape(s: string): string {
82
+ return s.replace(/'/g, "'\\''");
83
+ }
84
+
85
+ export function getSkillCandidateHint(memsearchDir: string, memsearchCmd: string): string {
86
+ try {
87
+ const result = spawnSync(
88
+ "bash",
89
+ [
90
+ "-c",
91
+ `MEMSEARCH_DIR='${shellEscape(memsearchDir)}' ${memsearchCmd} skills status --hint`,
92
+ ],
93
+ { encoding: "utf-8", timeout: 5000 }
94
+ );
95
+ if (result.status !== 0) return "";
96
+ return (result.stdout || "").trim().split("\n")[0] || "";
97
+ } catch {
98
+ return "";
99
+ }
100
+ }
101
+
102
+ /** Marks the start of memsearch's injected block within a system message. */
103
+ export const MEMSEARCH_SYSTEM_MARKER = "[memsearch] Memory available.";
104
+
105
+ /**
106
+ * Merge memsearch's memory context into an `output.system` array without
107
+ * growing it. Some backends (litellm/vllm serving e.g. Qwen models) reject a
108
+ * multi-entry `output.system` array with "system message must be first", so
109
+ * the memory block is folded into the first entry instead of pushed as a new
110
+ * one. If a memsearch block from a previous transform call is already present
111
+ * (identified by MEMSEARCH_SYSTEM_MARKER), it is replaced in place rather than
112
+ * appended again, so repeated calls against the same output stay idempotent.
113
+ */
114
+ export function mergeSystemMemoryContext(
115
+ system: string[] | undefined,
116
+ memoryText: string
117
+ ): string[] {
118
+ if (!Array.isArray(system) || system.length === 0) {
119
+ return [memoryText];
120
+ }
121
+ const result = [...system];
122
+ const existing = result[0];
123
+ const markerIndex = existing.indexOf(MEMSEARCH_SYSTEM_MARKER);
124
+ const base =
125
+ markerIndex === -1 ? existing : existing.slice(0, markerIndex).replace(/\n+$/, "");
126
+ result[0] = base ? `${base}\n\n${memoryText}` : memoryText;
127
+ return result;
128
+ }
package/index.ts CHANGED
@@ -18,7 +18,6 @@ import {
18
18
  readFileSync,
19
19
  existsSync,
20
20
  mkdirSync,
21
- readdirSync,
22
21
  realpathSync,
23
22
  unlinkSync,
24
23
  writeFileSync,
@@ -26,6 +25,14 @@ import {
26
25
  import { join, dirname } from "node:path";
27
26
  import { fileURLToPath } from "node:url";
28
27
 
28
+ import {
29
+ getRecentMemories,
30
+ getSkillCandidateHint,
31
+ MEMSEARCH_SYSTEM_MARKER,
32
+ mergeSystemMemoryContext,
33
+ shellEscape,
34
+ } from "./context.ts";
35
+
29
36
  const PLUGIN_DIR = dirname(realpathSync(fileURLToPath(import.meta.url)));
30
37
 
31
38
  // ---------------------------------------------------------------------------
@@ -70,114 +77,6 @@ function deriveCollectionName(projectDir: string): string {
70
77
  }
71
78
  }
72
79
 
73
- /**
74
- * Summarize the N most recent daily .md files for cold-start context.
75
- * Extracts recent non-empty session sections so empty SessionStart headings
76
- * do not crowd out useful context.
77
- */
78
- function recentMemoryPreviewLines(content: string, maxLines: number): string[] {
79
- const sections: string[][] = [];
80
- let current: string[] = [];
81
- let hasBody = false;
82
-
83
- const flush = () => {
84
- if (current.length > 0 && hasBody) {
85
- sections.push(current);
86
- }
87
- current = [];
88
- hasBody = false;
89
- };
90
-
91
- for (const rawLine of content.split("\n")) {
92
- const line = rawLine.trimEnd();
93
- if (/^##\s/.test(line)) {
94
- flush();
95
- current = [line];
96
- continue;
97
- }
98
- if (/^#{3,4}\s/.test(line)) {
99
- current.push(line);
100
- continue;
101
- }
102
- if (line.startsWith("- ") || line.startsWith("[User]") || line.startsWith("[Assistant]")) {
103
- current.push(line);
104
- hasBody = true;
105
- }
106
- }
107
-
108
- flush();
109
- return sections.flat().slice(-maxLines);
110
- }
111
-
112
- export function isDailyJournalFile(file: string): boolean {
113
- return /^\d{4}-\d{2}-\d{2}\.md$/.test(file);
114
- }
115
-
116
- export function getRecentMemories(
117
- memDir: string,
118
- count = 2,
119
- maxLinesPerFile = 30
120
- ): string {
121
- if (!existsSync(memDir)) return "";
122
-
123
- const files = readdirSync(memDir)
124
- .filter(isDailyJournalFile)
125
- .sort()
126
- .slice(-count);
127
-
128
- if (files.length === 0) return "";
129
-
130
- const summary: string[] = [];
131
- for (const file of files) {
132
- try {
133
- const content = readFileSync(join(memDir, file), "utf-8");
134
- const lines = recentMemoryPreviewLines(content, maxLinesPerFile);
135
- if (lines.length > 0) {
136
- summary.push(`[${file}]`, ...lines);
137
- }
138
- } catch { /* skip */ }
139
- }
140
-
141
- if (summary.length === 0) {
142
- return `You have ${files.length} past memory file(s). Use the memory_search tool when the user's question could benefit from historical context.`;
143
- }
144
-
145
- return `Recent memories (use memory_search for full search):\n${summary.join("\n")}`;
146
- }
147
-
148
- /** Shell-escape a string for safe use inside single quotes. */
149
- function shellEscape(s: string): string {
150
- return s.replace(/'/g, "'\\''");
151
- }
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
-
181
80
  /**
182
81
  * Start the capture daemon as a background process.
183
82
  * The daemon polls OpenCode's SQLite for completed turns and writes to daily .md files.
@@ -277,6 +176,7 @@ const MemsearchPlugin: Plugin = async ({ project, directory, worktree }) => {
277
176
  const collectionName = deriveCollectionName(projectDir);
278
177
  const memsearchDir = join(projectDir, ".memsearch");
279
178
  const memoryDir = join(memsearchDir, "memory");
179
+ const skillCandidateHint = getSkillCandidateHint(memsearchDir, memsearchCmd);
280
180
  const home = process.env.HOME || "~";
281
181
 
282
182
  // Skip capture/recall in child processes to prevent recursion
@@ -433,10 +333,12 @@ const MemsearchPlugin: Plugin = async ({ project, directory, worktree }) => {
433
333
  "experimental.chat.system.transform": async (_input: any, output: any) => {
434
334
  try {
435
335
  const context = getRecentMemories(memoryDir);
436
- if (context) {
336
+ if (context || skillCandidateHint) {
437
337
  const memoryText =
438
338
  `${MEMSEARCH_SYSTEM_MARKER} You have access to memory_search, ` +
439
- `memory_get, and memory_transcript tools for recalling past sessions.\n\n${context}`;
339
+ `memory_get, and memory_transcript tools for recalling past sessions.` +
340
+ `${skillCandidateHint ? `\n${skillCandidateHint}` : ""}` +
341
+ `${context ? `\n\n${context}` : ""}`;
440
342
  output.system = mergeSystemMemoryContext(output.system, memoryText);
441
343
  }
442
344
  } catch { /* ignore */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zilliz/memsearch-opencode",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "memsearch plugin for OpenCode — semantic memory search across sessions",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -8,6 +8,7 @@
8
8
  ".": "./index.ts"
9
9
  },
10
10
  "files": [
11
+ "context.ts",
11
12
  "index.ts",
12
13
  "scripts/*.py",
13
14
  "scripts/*.sh",
@@ -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/`.
@@ -121,8 +123,16 @@ Only these low-risk local indexing keys are honored from project config:
121
123
  - `embedding.batch_size`
122
124
  - `chunking.max_chunk_size`
123
125
  - `chunking.overlap_lines`
126
+ - `indexing.ignore_files`
127
+ - `indexing.exclude`
124
128
  - `watch.debounce_ms`
125
129
 
130
+ Index exclusions are opt-in for compatibility. Missing or empty
131
+ `indexing.ignore_files` and `indexing.exclude` keep the old scan-all behavior;
132
+ new files created by `memsearch config init` explicitly write
133
+ `ignore_files = [".gitignore"]`. Each directory passed to index/watch is its own
134
+ root, and ignore discovery never walks into parent directories.
135
+
126
136
  Trusted settings are ignored or rejected in project config. Put these in global
127
137
  config (`~/.memsearch/config.toml`) or pass explicit CLI flags instead:
128
138
 
@@ -208,6 +218,11 @@ Model guidance:
208
218
 
209
219
  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
220
 
221
+ If indexing seems silent or search looks stale, check `.memsearch/.index-state.json`
222
+ for `status`, `last_error`, and `failed_files`. `status: degraded` means the
223
+ scan completed but one or more files failed; `status: error` means the index run
224
+ did not complete.
225
+
211
226
  If advanced maintenance or `memory_to_skill` seems silent, check
212
227
  `.memsearch/.maintenance-state.json` for `<plugin>.<task>.last_error` and
213
228
  `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.