@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 +128 -0
- package/index.ts +13 -111
- package/package.json +2 -1
- package/skills/memory-config/SKILL.md +15 -0
- package/skills/memory-to-skill/SKILL.md +6 -0
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
|
|
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.
|
|
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.
|