@zilliz/memsearch-opencode 0.3.12 → 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 +8 -126
- package/package.json +2 -1
- package/skills/memory-config/SKILL.md +8 -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,131 +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
|
-
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
|
-
|
|
198
80
|
/**
|
|
199
81
|
* Start the capture daemon as a background process.
|
|
200
82
|
* The daemon polls OpenCode's SQLite for completed turns and writes to daily .md files.
|
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",
|
|
@@ -123,8 +123,16 @@ Only these low-risk local indexing keys are honored from project config:
|
|
|
123
123
|
- `embedding.batch_size`
|
|
124
124
|
- `chunking.max_chunk_size`
|
|
125
125
|
- `chunking.overlap_lines`
|
|
126
|
+
- `indexing.ignore_files`
|
|
127
|
+
- `indexing.exclude`
|
|
126
128
|
- `watch.debounce_ms`
|
|
127
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
|
+
|
|
128
136
|
Trusted settings are ignored or rejected in project config. Put these in global
|
|
129
137
|
config (`~/.memsearch/config.toml`) or pass explicit CLI flags instead:
|
|
130
138
|
|