myagentmemory 0.4.12 → 0.4.14
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/README.md +118 -49
- package/dist/cli-spec.d.ts +25 -0
- package/dist/cli-spec.js +211 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +435 -71
- package/dist/completions.d.ts +13 -0
- package/dist/completions.js +429 -0
- package/dist/core.d.ts +22 -1
- package/dist/core.js +299 -62
- package/dist/hooks.d.ts +42 -0
- package/dist/hooks.js +444 -0
- package/dist/plugin-bootstrap.d.ts +190 -0
- package/dist/plugin-bootstrap.js +628 -0
- package/dist/plugin-host.d.ts +136 -0
- package/dist/plugin-host.js +98 -0
- package/dist/plugin-runtime.d.ts +21 -0
- package/dist/plugin-runtime.js +208 -0
- package/dist/plugin-service.d.ts +45 -0
- package/dist/plugin-service.js +395 -0
- package/docs/official-plugin-bootstrap.md +335 -0
- package/package.json +62 -11
- package/scripts/install-skills.sh +4 -1
- package/scripts/postinstall.cjs +23 -4
- package/src/cli-spec.ts +236 -0
- package/src/cli.ts +455 -82
- package/src/completions.ts +501 -0
- package/src/core.ts +314 -62
- package/src/hooks.ts +485 -0
- package/src/plugin-bootstrap.ts +931 -0
- package/src/plugin-host.ts +255 -0
- package/src/plugin-runtime.ts +296 -0
- package/src/plugin-service.ts +451 -0
- package/dist/agent-memory +0 -0
package/dist/core.js
CHANGED
|
@@ -115,6 +115,100 @@ const CONTEXT_MAX_CHARS = 16_000;
|
|
|
115
115
|
function normalizeContent(content) {
|
|
116
116
|
return content.trim();
|
|
117
117
|
}
|
|
118
|
+
const SECRET_PATTERNS = [
|
|
119
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
|
120
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
121
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
122
|
+
/\b(?:api[_-]?key|access[_-]?token|secret)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{16,}["']?/gi,
|
|
123
|
+
];
|
|
124
|
+
/** Redact common credential shapes before content reaches disk or agent context. */
|
|
125
|
+
export function redactSecrets(content) {
|
|
126
|
+
let redactedContent = content;
|
|
127
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
128
|
+
redactedContent = redactedContent.replace(pattern, "[REDACTED_SECRET]");
|
|
129
|
+
}
|
|
130
|
+
return { content: redactedContent, redacted: redactedContent !== content };
|
|
131
|
+
}
|
|
132
|
+
function isInactiveMemoryEntry(entry, now) {
|
|
133
|
+
const metadataHeader = entry.split(/\n\s*\n/, 1)[0];
|
|
134
|
+
if (/^\s*(?:[-*]\s*)?Trust:\s*untrusted\s*\.?\s*$/im.test(metadataHeader))
|
|
135
|
+
return true;
|
|
136
|
+
if (/^\s*(?:[-*]\s*)?Status:\s*(?:expired|superseded|revoked|retired)\s*\.?\s*$/im.test(metadataHeader)) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
const validUntil = metadataHeader.match(/^\s*(?:[-*]\s*)?Valid until:?\s+(\d{4}-\d{2}-\d{2})\s*\.?\s*$/im)?.[1];
|
|
140
|
+
if (validUntil && validUntil < now.toISOString().slice(0, 10))
|
|
141
|
+
return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
function splitLogicalMemoryEntries(content) {
|
|
145
|
+
const normalized = content.replace(/^\uFEFF/, "");
|
|
146
|
+
const marker = /^<!--\s*(?:last updated:\s*)?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[[^\]]+\]\s*-->$/gm;
|
|
147
|
+
const starts = [...normalized.matchAll(marker)].map((match) => match.index ?? 0);
|
|
148
|
+
if (starts.length === 0) {
|
|
149
|
+
return { entries: normalized.split(/\n\s*\n/), timestampDelimited: false };
|
|
150
|
+
}
|
|
151
|
+
const entries = [];
|
|
152
|
+
const preamble = normalized.slice(0, starts[0]).trim();
|
|
153
|
+
if (preamble)
|
|
154
|
+
entries.push(preamble);
|
|
155
|
+
for (let i = 0; i < starts.length; i++) {
|
|
156
|
+
entries.push(normalized.slice(starts[i], starts[i + 1] ?? normalized.length).trim());
|
|
157
|
+
}
|
|
158
|
+
return { entries, timestampDelimited: true };
|
|
159
|
+
}
|
|
160
|
+
function isUnmarkedInactiveHeader(entry, now) {
|
|
161
|
+
if (!isInactiveMemoryEntry(entry, now))
|
|
162
|
+
return false;
|
|
163
|
+
return entry.split("\n").every((line) => {
|
|
164
|
+
const trimmed = line.trim();
|
|
165
|
+
return (!trimmed ||
|
|
166
|
+
/^#{1,6}\s+/.test(trimmed) ||
|
|
167
|
+
/^<!--.*-->$/.test(trimmed) ||
|
|
168
|
+
/^(?:[-*]\s*)?(?:Trust:|Status:|Valid until:?\s|Source:)/i.test(trimmed));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Apply trust, lifecycle, and secret policy to complete logical write entries. */
|
|
172
|
+
export function filterMemoryForContext(content, now = new Date()) {
|
|
173
|
+
const { entries, timestampDelimited } = splitLogicalMemoryEntries(content);
|
|
174
|
+
const activeEntries = [];
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
const inactive = isInactiveMemoryEntry(entry, now);
|
|
177
|
+
if (!timestampDelimited && isUnmarkedInactiveHeader(entry, now)) {
|
|
178
|
+
// Without write markers there is no reliable boundary after a metadata-only
|
|
179
|
+
// header. Fail closed instead of treating the following body paragraphs as
|
|
180
|
+
// independent trusted entries.
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (!inactive)
|
|
184
|
+
activeEntries.push(entry);
|
|
185
|
+
}
|
|
186
|
+
return redactSecrets(activeEntries.join("\n\n")).content.trim();
|
|
187
|
+
}
|
|
188
|
+
function sanitizeSourceUri(sourceUri) {
|
|
189
|
+
if (!sourceUri?.trim())
|
|
190
|
+
return undefined;
|
|
191
|
+
const singleLine = [...sourceUri]
|
|
192
|
+
.map((char) => {
|
|
193
|
+
const code = char.charCodeAt(0);
|
|
194
|
+
return code <= 31 || code === 127 ? " " : char;
|
|
195
|
+
})
|
|
196
|
+
.join("")
|
|
197
|
+
.trim()
|
|
198
|
+
.slice(0, 2_048);
|
|
199
|
+
return redactSecrets(singleLine).content;
|
|
200
|
+
}
|
|
201
|
+
function escapeEntryMarkers(content) {
|
|
202
|
+
return content.replace(/^<!--\s*(?:last updated:\s*)?\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[[^\]]+\]\s*-->$/gm, (line) => line.replace("<!--", "<!--"));
|
|
203
|
+
}
|
|
204
|
+
function formatStoredEntry(content, metadata, sourceUri) {
|
|
205
|
+
const safeContent = redactSecrets(escapeEntryMarkers(content));
|
|
206
|
+
const source = sanitizeSourceUri(sourceUri);
|
|
207
|
+
return {
|
|
208
|
+
entry: `${metadata}\n${safeContent.content}${source ? `\nSource: ${source}` : ""}`,
|
|
209
|
+
redacted: safeContent.redacted || (!!sourceUri && source !== sourceUri.trim()),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
118
212
|
export function truncateLines(lines, maxLines, mode) {
|
|
119
213
|
if (maxLines <= 0 || lines.length <= maxLines) {
|
|
120
214
|
return { lines, truncated: false };
|
|
@@ -251,7 +345,7 @@ export function buildMemoryContext(searchResults) {
|
|
|
251
345
|
if (scratchpad?.trim()) {
|
|
252
346
|
const openItems = parseScratchpad(scratchpad).filter((i) => !i.done);
|
|
253
347
|
if (openItems.length > 0) {
|
|
254
|
-
const serialized = serializeScratchpad(openItems);
|
|
348
|
+
const serialized = filterMemoryForContext(serializeScratchpad(openItems));
|
|
255
349
|
const section = formatContextSection("## SCRATCHPAD.md (working context)", serialized, "start", CONTEXT_SCRATCHPAD_MAX_LINES, CONTEXT_SCRATCHPAD_MAX_CHARS);
|
|
256
350
|
if (section)
|
|
257
351
|
sections.push(section);
|
|
@@ -263,25 +357,29 @@ export function buildMemoryContext(searchResults) {
|
|
|
263
357
|
const today = todayStr();
|
|
264
358
|
const yesterday = yesterdayStr();
|
|
265
359
|
const todayContent = readFileSafe(dailyPath(today));
|
|
266
|
-
|
|
267
|
-
|
|
360
|
+
const safeTodayContent = todayContent ? filterMemoryForContext(todayContent) : "";
|
|
361
|
+
if (safeTodayContent) {
|
|
362
|
+
const section = formatContextSection(`## Daily log: ${today} (today)`, safeTodayContent, "middle", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
|
|
268
363
|
if (section)
|
|
269
364
|
sections.push(section);
|
|
270
365
|
}
|
|
271
|
-
|
|
272
|
-
|
|
366
|
+
const safeSearchResults = searchResults ? filterMemoryForContext(searchResults) : "";
|
|
367
|
+
if (safeSearchResults) {
|
|
368
|
+
const section = formatContextSection("## Relevant memories (auto-retrieved)", safeSearchResults, "start", CONTEXT_SEARCH_MAX_LINES, CONTEXT_SEARCH_MAX_CHARS);
|
|
273
369
|
if (section)
|
|
274
370
|
sections.push(section);
|
|
275
371
|
}
|
|
276
372
|
const longTerm = readFileSafe(MEMORY_FILE);
|
|
277
|
-
|
|
278
|
-
|
|
373
|
+
const safeLongTerm = longTerm ? filterMemoryForContext(longTerm) : "";
|
|
374
|
+
if (safeLongTerm) {
|
|
375
|
+
const section = formatContextSection("## MEMORY.md (long-term)", safeLongTerm, "middle", CONTEXT_LONG_TERM_MAX_LINES, CONTEXT_LONG_TERM_MAX_CHARS);
|
|
279
376
|
if (section)
|
|
280
377
|
sections.push(section);
|
|
281
378
|
}
|
|
282
379
|
const yesterdayContent = readFileSafe(dailyPath(yesterday));
|
|
283
|
-
|
|
284
|
-
|
|
380
|
+
const safeYesterdayContent = yesterdayContent ? filterMemoryForContext(yesterdayContent) : "";
|
|
381
|
+
if (safeYesterdayContent) {
|
|
382
|
+
const section = formatContextSection(`## Daily log: ${yesterday} (yesterday)`, safeYesterdayContent, "end", CONTEXT_DAILY_MAX_LINES, CONTEXT_DAILY_MAX_CHARS);
|
|
285
383
|
if (section)
|
|
286
384
|
sections.push(section);
|
|
287
385
|
}
|
|
@@ -290,15 +388,8 @@ export function buildMemoryContext(searchResults) {
|
|
|
290
388
|
}
|
|
291
389
|
const context = `# Memory\n\n${sections.join("\n\n---\n\n")}`;
|
|
292
390
|
if (context.length > CONTEXT_MAX_CHARS) {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
maxChars: CONTEXT_MAX_CHARS,
|
|
296
|
-
mode: "start",
|
|
297
|
-
});
|
|
298
|
-
const note = result.truncated
|
|
299
|
-
? `\n\n[truncated overall context: showing ${result.previewChars}/${result.totalChars} chars]`
|
|
300
|
-
: "";
|
|
301
|
-
return `${result.preview}${note}`;
|
|
391
|
+
const note = "\n\n[truncated overall context to 16000 chars]";
|
|
392
|
+
return context.slice(0, CONTEXT_MAX_CHARS - note.length).trimEnd() + note;
|
|
302
393
|
}
|
|
303
394
|
return context;
|
|
304
395
|
}
|
|
@@ -319,11 +410,12 @@ function buildTopicsContextSection() {
|
|
|
319
410
|
for (const file of topicFiles) {
|
|
320
411
|
const slug = file.replace(/\.md$/, "");
|
|
321
412
|
const content = readFileSafe(path.join(TOPICS_DIR, file));
|
|
322
|
-
|
|
413
|
+
const safeContent = content ? filterMemoryForContext(content) : "";
|
|
414
|
+
if (!safeContent)
|
|
323
415
|
continue;
|
|
324
|
-
const titleMatch =
|
|
416
|
+
const titleMatch = safeContent.match(/^# Topic:\s*(.+)$/m);
|
|
325
417
|
const title = titleMatch?.[1]?.trim() || slug;
|
|
326
|
-
entries.push(...parseTopicEntries(title, slug,
|
|
418
|
+
entries.push(...parseTopicEntries(title, slug, safeContent));
|
|
327
419
|
}
|
|
328
420
|
if (entries.length === 0)
|
|
329
421
|
return null;
|
|
@@ -711,6 +803,12 @@ export function installSkills() {
|
|
|
711
803
|
destDir: path.join(homeDir, ".cursor", "skills", "agent-memory"),
|
|
712
804
|
homeMarker: path.join(homeDir, ".cursor"),
|
|
713
805
|
},
|
|
806
|
+
{
|
|
807
|
+
label: "Agent CLI skill",
|
|
808
|
+
srcDir: path.join(skillsDir, "agent"),
|
|
809
|
+
destDir: path.join(homeDir, ".agents", "skills", "agent-memory"),
|
|
810
|
+
homeMarker: path.join(homeDir, ".agents"),
|
|
811
|
+
},
|
|
714
812
|
];
|
|
715
813
|
const detected = [];
|
|
716
814
|
const installed = [];
|
|
@@ -765,6 +863,7 @@ export function uninstallSkills() {
|
|
|
765
863
|
{ label: "Claude Code skill", destDir: path.join(homeDir, ".claude", "skills", "agent-memory") },
|
|
766
864
|
{ label: "Codex skill", destDir: path.join(homeDir, ".codex", "skills", "agent-memory") },
|
|
767
865
|
{ label: "Cursor skill", destDir: path.join(homeDir, ".cursor", "skills", "agent-memory") },
|
|
866
|
+
{ label: "Agent CLI skill", destDir: path.join(homeDir, ".agents", "skills", "agent-memory") },
|
|
768
867
|
];
|
|
769
868
|
const removed = [];
|
|
770
869
|
const skipped = [];
|
|
@@ -844,10 +943,63 @@ export async function getQmdHealth() {
|
|
|
844
943
|
});
|
|
845
944
|
});
|
|
846
945
|
}
|
|
946
|
+
function resolveCaseInsensitivePath(root, relativePath) {
|
|
947
|
+
let current = root;
|
|
948
|
+
for (const segment of relativePath.split(/[/\\]+/).filter(Boolean)) {
|
|
949
|
+
if (segment === "." || segment === "..")
|
|
950
|
+
return null;
|
|
951
|
+
let children;
|
|
952
|
+
try {
|
|
953
|
+
children = fs.readdirSync(current);
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
const child = children.find((name) => name === segment) ??
|
|
959
|
+
children.find((name) => name.toLowerCase() === segment.toLowerCase());
|
|
960
|
+
if (!child)
|
|
961
|
+
return null;
|
|
962
|
+
current = path.join(current, child);
|
|
963
|
+
}
|
|
964
|
+
return current;
|
|
965
|
+
}
|
|
966
|
+
function resolveQmdSourcePath(filePath) {
|
|
967
|
+
const qmdUri = filePath.match(/^qmd:\/\/([^/]+)\/?(.*)$/i);
|
|
968
|
+
let relativePath = qmdUri ? qmdUri[2] : filePath;
|
|
969
|
+
if (relativePath.toLowerCase().startsWith(`${QMD_COLLECTION_NAME.toLowerCase()}/`)) {
|
|
970
|
+
relativePath = relativePath.slice(QMD_COLLECTION_NAME.length + 1);
|
|
971
|
+
}
|
|
972
|
+
const root = path.resolve(MEMORY_DIR);
|
|
973
|
+
if (path.isAbsolute(relativePath)) {
|
|
974
|
+
const candidate = path.resolve(relativePath);
|
|
975
|
+
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`))
|
|
976
|
+
return null;
|
|
977
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
978
|
+
}
|
|
979
|
+
return resolveCaseInsensitivePath(root, relativePath.replace(/^\/+/, ""));
|
|
980
|
+
}
|
|
981
|
+
function qmdResultPassesSourcePolicy(filePath, snippet) {
|
|
982
|
+
if (!filePath)
|
|
983
|
+
return false;
|
|
984
|
+
const sourcePath = resolveQmdSourcePath(filePath);
|
|
985
|
+
if (!sourcePath)
|
|
986
|
+
return false;
|
|
987
|
+
const source = readFileSafe(sourcePath);
|
|
988
|
+
if (!source)
|
|
989
|
+
return false;
|
|
990
|
+
const activeSource = filterMemoryForContext(source);
|
|
991
|
+
const snippetLines = snippet
|
|
992
|
+
.split("\n")
|
|
993
|
+
.map((line) => line.trim())
|
|
994
|
+
.filter((line) => line.length >= 8);
|
|
995
|
+
return snippetLines.length > 0 && snippetLines.every((line) => activeSource.includes(line));
|
|
996
|
+
}
|
|
847
997
|
/** Search for memories relevant to the user's prompt. Returns formatted markdown or empty string on error. */
|
|
848
998
|
export async function searchRelevantMemories(prompt) {
|
|
849
999
|
if (!qmdAvailable || !prompt.trim())
|
|
850
1000
|
return "";
|
|
1001
|
+
let timer;
|
|
1002
|
+
const controller = new AbortController();
|
|
851
1003
|
// Sanitize: strip control chars, limit to 200 chars for the search query
|
|
852
1004
|
const sanitized = prompt
|
|
853
1005
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: we intentionally strip control chars.
|
|
@@ -861,19 +1013,26 @@ export async function searchRelevantMemories(prompt) {
|
|
|
861
1013
|
if (!hasCollection)
|
|
862
1014
|
return "";
|
|
863
1015
|
const results = await Promise.race([
|
|
864
|
-
runQmdSearch("keyword", sanitized, 3),
|
|
865
|
-
new Promise((_, reject) =>
|
|
1016
|
+
runQmdSearch("keyword", sanitized, 3, { signal: controller.signal }),
|
|
1017
|
+
new Promise((_, reject) => {
|
|
1018
|
+
timer = setTimeout(() => {
|
|
1019
|
+
controller.abort();
|
|
1020
|
+
reject(new Error("timeout"));
|
|
1021
|
+
}, 3_000);
|
|
1022
|
+
}),
|
|
866
1023
|
]);
|
|
867
1024
|
if (!results || results.results.length === 0)
|
|
868
1025
|
return "";
|
|
869
1026
|
const snippets = results.results
|
|
870
1027
|
.map((r) => {
|
|
871
|
-
const text = getQmdResultText(r);
|
|
872
|
-
if (!text
|
|
1028
|
+
const text = filterMemoryForContext(getQmdResultText(r));
|
|
1029
|
+
if (!text)
|
|
873
1030
|
return null;
|
|
874
1031
|
const filePath = getQmdResultPath(r);
|
|
1032
|
+
if (!qmdResultPassesSourcePolicy(filePath, text))
|
|
1033
|
+
return null;
|
|
875
1034
|
const filePart = filePath ? `_${filePath}_` : "";
|
|
876
|
-
return filePart ? `${filePart}\n${text
|
|
1035
|
+
return filePart ? `${filePart}\n${text}` : text;
|
|
877
1036
|
})
|
|
878
1037
|
.filter(Boolean);
|
|
879
1038
|
if (snippets.length === 0)
|
|
@@ -883,12 +1042,27 @@ export async function searchRelevantMemories(prompt) {
|
|
|
883
1042
|
catch {
|
|
884
1043
|
return "";
|
|
885
1044
|
}
|
|
1045
|
+
finally {
|
|
1046
|
+
clearTimeout(timer);
|
|
1047
|
+
}
|
|
886
1048
|
}
|
|
887
1049
|
export function getQmdResultPath(r) {
|
|
888
1050
|
return r.path ?? r.file;
|
|
889
1051
|
}
|
|
890
1052
|
export function getQmdResultText(r) {
|
|
891
|
-
|
|
1053
|
+
const text = r.content ?? r.chunk ?? r.snippet ?? "";
|
|
1054
|
+
const folderContext = r.context?.trim();
|
|
1055
|
+
let normalized = text.trimStart();
|
|
1056
|
+
if (folderContext) {
|
|
1057
|
+
const prefix = `Folder Context: ${folderContext}`;
|
|
1058
|
+
if (normalized.startsWith(prefix)) {
|
|
1059
|
+
const remainder = normalized.slice(prefix.length);
|
|
1060
|
+
if (/^(?:\r?\n){2}/.test(remainder)) {
|
|
1061
|
+
normalized = remainder.replace(/^(?:\r?\n){2}/, "");
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return normalized.replace(/^@@ -\d+(?:,\d+)?(?: \+\d+(?:,\d+)?)? @@ \(\d+ before, \d+ after\)(?:\r?\n){1,2}/, "");
|
|
892
1066
|
}
|
|
893
1067
|
function stripAnsi(text) {
|
|
894
1068
|
// qmd may emit spinners/progress bars even with --json, especially on first model download.
|
|
@@ -916,11 +1090,11 @@ function parseQmdJson(stdout) {
|
|
|
916
1090
|
return [];
|
|
917
1091
|
return JSON.parse(jsonText);
|
|
918
1092
|
}
|
|
919
|
-
export function runQmdSearch(mode, query, limit) {
|
|
1093
|
+
export function runQmdSearch(mode, query, limit, options = {}) {
|
|
920
1094
|
const subcommand = mode === "keyword" ? "search" : mode === "semantic" ? "vsearch" : "query";
|
|
921
1095
|
const args = [subcommand, "--json", "-c", QMD_COLLECTION_NAME, "-n", String(limit), query];
|
|
922
1096
|
return new Promise((resolve, reject) => {
|
|
923
|
-
execFileFn("qmd", args, { timeout: 60_000 }, (err, stdout, stderr) => {
|
|
1097
|
+
execFileFn("qmd", args, { timeout: 60_000, signal: options.signal }, (err, stdout, stderr) => {
|
|
924
1098
|
if (err) {
|
|
925
1099
|
reject(new Error(stderr?.trim() || err.message));
|
|
926
1100
|
return;
|
|
@@ -940,28 +1114,72 @@ export function runQmdSearch(mode, query, limit) {
|
|
|
940
1114
|
});
|
|
941
1115
|
});
|
|
942
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Best-effort check of whether vector embeddings are actually usable for
|
|
1119
|
+
* semantic/deep search right now. Runs a tiny semantic probe and looks for
|
|
1120
|
+
* qmd's "need embeddings" warning. Bounded by a short timeout because the very
|
|
1121
|
+
* first semantic query can trigger a model download — returns "unknown" rather
|
|
1122
|
+
* than blocking on it. "ready" means the probe ran without the warning; it does
|
|
1123
|
+
* not prove the index has content.
|
|
1124
|
+
*/
|
|
1125
|
+
export async function probeEmbeddings() {
|
|
1126
|
+
let timer;
|
|
1127
|
+
// Abort the underlying qmd child when the timeout fires so it does not keep
|
|
1128
|
+
// the event loop open until its own 60s timeout and hang the CLI.
|
|
1129
|
+
const controller = new AbortController();
|
|
1130
|
+
try {
|
|
1131
|
+
const { stderr } = await Promise.race([
|
|
1132
|
+
runQmdSearch("semantic", "memory", 1, { signal: controller.signal }),
|
|
1133
|
+
new Promise((_, reject) => {
|
|
1134
|
+
timer = setTimeout(() => {
|
|
1135
|
+
controller.abort();
|
|
1136
|
+
reject(new Error("timeout"));
|
|
1137
|
+
}, 4_000);
|
|
1138
|
+
}),
|
|
1139
|
+
]);
|
|
1140
|
+
return /need embeddings/i.test(stderr ?? "") ? "missing" : "ready";
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1144
|
+
if (/need embeddings/i.test(msg))
|
|
1145
|
+
return "missing";
|
|
1146
|
+
return "unknown";
|
|
1147
|
+
}
|
|
1148
|
+
finally {
|
|
1149
|
+
clearTimeout(timer);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
943
1152
|
export async function memoryWrite(params) {
|
|
944
|
-
|
|
1153
|
+
const memoryDir = params.directory ? path.resolve(params.directory) : getMemoryDir();
|
|
1154
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
1155
|
+
fs.mkdirSync(path.join(memoryDir, "daily"), { recursive: true });
|
|
1156
|
+
fs.mkdirSync(path.join(memoryDir, "topics"), { recursive: true });
|
|
1157
|
+
const scheduleSearchRefresh = async () => {
|
|
1158
|
+
if (path.resolve(getMemoryDir()) !== memoryDir)
|
|
1159
|
+
return;
|
|
1160
|
+
await ensureQmdAvailableForUpdate();
|
|
1161
|
+
scheduleQmdUpdate();
|
|
1162
|
+
};
|
|
945
1163
|
const target = params.target ?? "daily";
|
|
946
1164
|
const { content, mode } = params;
|
|
947
1165
|
const sid = shortSessionId(params.sessionId ?? "cli");
|
|
948
1166
|
const ts = nowTimestamp();
|
|
949
1167
|
if (target === "daily") {
|
|
950
|
-
const filePath =
|
|
1168
|
+
const filePath = path.join(memoryDir, "daily", `${params.date?.trim() || todayStr()}.md`);
|
|
951
1169
|
const existing = readFileSafe(filePath) ?? "";
|
|
952
|
-
const
|
|
1170
|
+
const safeExisting = redactSecrets(existing).content;
|
|
1171
|
+
const existingPreview = buildPreview(safeExisting, {
|
|
953
1172
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
954
1173
|
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
955
1174
|
mode: "end",
|
|
956
1175
|
});
|
|
957
1176
|
const existingSnippet = existingPreview.preview
|
|
958
|
-
? `\n\n${formatPreviewBlock("Existing daily log preview",
|
|
1177
|
+
? `\n\n${formatPreviewBlock("Existing daily log preview", safeExisting, "end")}`
|
|
959
1178
|
: "\n\nDaily log was empty.";
|
|
960
1179
|
const separator = existing.trim() ? "\n\n" : "";
|
|
961
|
-
const
|
|
962
|
-
fs.writeFileSync(filePath, existing + separator +
|
|
963
|
-
await
|
|
964
|
-
scheduleQmdUpdate();
|
|
1180
|
+
const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
|
|
1181
|
+
fs.writeFileSync(filePath, existing + separator + stored.entry, "utf-8");
|
|
1182
|
+
await scheduleSearchRefresh();
|
|
965
1183
|
return {
|
|
966
1184
|
text: `Appended to daily log: ${filePath}${existingSnippet}`,
|
|
967
1185
|
details: {
|
|
@@ -970,6 +1188,8 @@ export async function memoryWrite(params) {
|
|
|
970
1188
|
mode: "append",
|
|
971
1189
|
sessionId: sid,
|
|
972
1190
|
timestamp: ts,
|
|
1191
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1192
|
+
redacted: stored.redacted,
|
|
973
1193
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
974
1194
|
existingPreview,
|
|
975
1195
|
},
|
|
@@ -984,24 +1204,24 @@ export async function memoryWrite(params) {
|
|
|
984
1204
|
if (!slug) {
|
|
985
1205
|
return { text: "Error: 'topic' must include at least one letter or number.", details: {}, isError: true };
|
|
986
1206
|
}
|
|
987
|
-
const filePath =
|
|
1207
|
+
const filePath = path.join(memoryDir, "topics", `${slug}.md`);
|
|
988
1208
|
const existing = readFileSafe(filePath) ?? "";
|
|
989
|
-
const
|
|
1209
|
+
const safeExisting = redactSecrets(existing).content;
|
|
1210
|
+
const existingPreview = buildPreview(safeExisting, {
|
|
990
1211
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
991
1212
|
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
992
1213
|
mode: "end",
|
|
993
1214
|
});
|
|
994
1215
|
const existingSnippet = existingPreview.preview
|
|
995
|
-
? `\n\n${formatPreviewBlock("Existing topic preview",
|
|
1216
|
+
? `\n\n${formatPreviewBlock("Existing topic preview", safeExisting, "end")}`
|
|
996
1217
|
: "\n\nTopic file was empty.";
|
|
997
1218
|
const linkDate = params.date?.trim() || todayStr();
|
|
998
1219
|
const header = `# Topic: ${topic}\n\n<!-- created: ${ts} [${sid}] -->\n`;
|
|
999
1220
|
const separator = existing.trim() ? "\n\n" : "";
|
|
1000
1221
|
const base = existing.trim() ? existing : header.trimEnd();
|
|
1001
|
-
const
|
|
1002
|
-
fs.writeFileSync(filePath, `${base}${separator}${
|
|
1003
|
-
await
|
|
1004
|
-
scheduleQmdUpdate();
|
|
1222
|
+
const stored = formatStoredEntry(`${content.trim()}\nDaily: [[${linkDate}]]`, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
|
|
1223
|
+
fs.writeFileSync(filePath, `${base}${separator}${stored.entry}`, "utf-8");
|
|
1224
|
+
await scheduleSearchRefresh();
|
|
1005
1225
|
return {
|
|
1006
1226
|
text: `Appended to topic: ${filePath}${existingSnippet}`,
|
|
1007
1227
|
details: {
|
|
@@ -1013,27 +1233,29 @@ export async function memoryWrite(params) {
|
|
|
1013
1233
|
topic,
|
|
1014
1234
|
slug,
|
|
1015
1235
|
date: linkDate,
|
|
1236
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1237
|
+
redacted: stored.redacted,
|
|
1016
1238
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
1017
1239
|
existingPreview,
|
|
1018
1240
|
},
|
|
1019
1241
|
};
|
|
1020
1242
|
}
|
|
1021
1243
|
// long_term
|
|
1022
|
-
const memFile =
|
|
1244
|
+
const memFile = path.join(memoryDir, "MEMORY.md");
|
|
1023
1245
|
const existing = readFileSafe(memFile) ?? "";
|
|
1024
|
-
const
|
|
1246
|
+
const safeExisting = redactSecrets(existing).content;
|
|
1247
|
+
const existingPreview = buildPreview(safeExisting, {
|
|
1025
1248
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1026
1249
|
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1027
1250
|
mode: "middle",
|
|
1028
1251
|
});
|
|
1029
1252
|
const existingSnippet = existingPreview.preview
|
|
1030
|
-
? `\n\n${formatPreviewBlock("Existing MEMORY.md preview",
|
|
1253
|
+
? `\n\n${formatPreviewBlock("Existing MEMORY.md preview", safeExisting, "middle")}`
|
|
1031
1254
|
: "\n\nMEMORY.md was empty.";
|
|
1032
1255
|
if (mode === "overwrite") {
|
|
1033
|
-
const
|
|
1034
|
-
fs.writeFileSync(memFile,
|
|
1035
|
-
await
|
|
1036
|
-
scheduleQmdUpdate();
|
|
1256
|
+
const stored = formatStoredEntry(content, `<!-- last updated: ${ts} [${sid}] -->`, params.sourceUri);
|
|
1257
|
+
fs.writeFileSync(memFile, stored.entry, "utf-8");
|
|
1258
|
+
await scheduleSearchRefresh();
|
|
1037
1259
|
return {
|
|
1038
1260
|
text: `Overwrote MEMORY.md${existingSnippet}`,
|
|
1039
1261
|
details: {
|
|
@@ -1042,6 +1264,8 @@ export async function memoryWrite(params) {
|
|
|
1042
1264
|
mode: "overwrite",
|
|
1043
1265
|
sessionId: sid,
|
|
1044
1266
|
timestamp: ts,
|
|
1267
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1268
|
+
redacted: stored.redacted,
|
|
1045
1269
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
1046
1270
|
existingPreview,
|
|
1047
1271
|
},
|
|
@@ -1049,10 +1273,9 @@ export async function memoryWrite(params) {
|
|
|
1049
1273
|
}
|
|
1050
1274
|
// append (default)
|
|
1051
1275
|
const separator = existing.trim() ? "\n\n" : "";
|
|
1052
|
-
const
|
|
1053
|
-
fs.writeFileSync(memFile, existing + separator +
|
|
1054
|
-
await
|
|
1055
|
-
scheduleQmdUpdate();
|
|
1276
|
+
const stored = formatStoredEntry(content, `<!-- ${ts} [${sid}] -->`, params.sourceUri);
|
|
1277
|
+
fs.writeFileSync(memFile, existing + separator + stored.entry, "utf-8");
|
|
1278
|
+
await scheduleSearchRefresh();
|
|
1056
1279
|
return {
|
|
1057
1280
|
text: `Appended to MEMORY.md${existingSnippet}`,
|
|
1058
1281
|
details: {
|
|
@@ -1061,6 +1284,8 @@ export async function memoryWrite(params) {
|
|
|
1061
1284
|
mode: "append",
|
|
1062
1285
|
sessionId: sid,
|
|
1063
1286
|
timestamp: ts,
|
|
1287
|
+
sourceUri: sanitizeSourceUri(params.sourceUri),
|
|
1288
|
+
redacted: stored.redacted,
|
|
1064
1289
|
qmdUpdateMode: getQmdUpdateMode(),
|
|
1065
1290
|
existingPreview,
|
|
1066
1291
|
},
|
|
@@ -1073,7 +1298,11 @@ export async function scratchpadAction(params) {
|
|
|
1073
1298
|
const ts = nowTimestamp();
|
|
1074
1299
|
const spFile = getScratchpadFile();
|
|
1075
1300
|
const existing = readFileSafe(spFile) ?? "";
|
|
1076
|
-
let items = parseScratchpad(existing)
|
|
1301
|
+
let items = parseScratchpad(existing).map((item) => ({
|
|
1302
|
+
...item,
|
|
1303
|
+
text: redactSecrets(item.text).content,
|
|
1304
|
+
meta: redactSecrets(item.meta).content,
|
|
1305
|
+
}));
|
|
1077
1306
|
if (action === "list") {
|
|
1078
1307
|
if (items.length === 0) {
|
|
1079
1308
|
return { text: "Scratchpad is empty.", details: {} };
|
|
@@ -1097,7 +1326,8 @@ export async function scratchpadAction(params) {
|
|
|
1097
1326
|
if (!text) {
|
|
1098
1327
|
return { text: "Error: 'text' is required for add.", details: {} };
|
|
1099
1328
|
}
|
|
1100
|
-
|
|
1329
|
+
const safeText = redactSecrets(text).content;
|
|
1330
|
+
items.push({ done: false, text: safeText, meta: `<!-- ${ts} [${sid}] -->` });
|
|
1101
1331
|
const serialized = serializeScratchpad(items);
|
|
1102
1332
|
const preview = buildPreview(serialized, {
|
|
1103
1333
|
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
@@ -1108,7 +1338,7 @@ export async function scratchpadAction(params) {
|
|
|
1108
1338
|
await ensureQmdAvailableForUpdate();
|
|
1109
1339
|
scheduleQmdUpdate();
|
|
1110
1340
|
return {
|
|
1111
|
-
text: `Added: - [ ] ${
|
|
1341
|
+
text: `Added: - [ ] ${safeText}\n\n${formatPreviewBlock("Scratchpad preview", serialized, "start")}`,
|
|
1112
1342
|
details: {
|
|
1113
1343
|
action,
|
|
1114
1344
|
sessionId: sid,
|
|
@@ -1526,7 +1756,10 @@ export async function distilMemories(params) {
|
|
|
1526
1756
|
const content = readFileSafe(path.join(DAILY_DIR, file));
|
|
1527
1757
|
if (!content?.trim())
|
|
1528
1758
|
continue;
|
|
1529
|
-
|
|
1759
|
+
const safeContent = filterMemoryForContext(content);
|
|
1760
|
+
if (!safeContent)
|
|
1761
|
+
continue;
|
|
1762
|
+
allEntries.push(...parseDailyEntries(date, safeContent));
|
|
1530
1763
|
}
|
|
1531
1764
|
const topicEntriesByTopic = new Map();
|
|
1532
1765
|
let totalTopicEntries = 0;
|
|
@@ -1535,9 +1768,12 @@ export async function distilMemories(params) {
|
|
|
1535
1768
|
const content = readFileSafe(path.join(TOPICS_DIR, file));
|
|
1536
1769
|
if (!content?.trim())
|
|
1537
1770
|
continue;
|
|
1538
|
-
const
|
|
1771
|
+
const safeContent = filterMemoryForContext(content);
|
|
1772
|
+
if (!safeContent)
|
|
1773
|
+
continue;
|
|
1774
|
+
const titleMatch = safeContent.match(/^# Topic:\s*(.+)$/m);
|
|
1539
1775
|
const title = titleMatch?.[1]?.trim() || slug;
|
|
1540
|
-
const entries = parseTopicEntries(title, slug,
|
|
1776
|
+
const entries = parseTopicEntries(title, slug, safeContent);
|
|
1541
1777
|
if (entries.length === 0)
|
|
1542
1778
|
continue;
|
|
1543
1779
|
totalTopicEntries += entries.length;
|
|
@@ -1584,7 +1820,8 @@ export async function distilMemories(params) {
|
|
|
1584
1820
|
let pinnedSection = "";
|
|
1585
1821
|
const existingMemory = readFileSafe(MEMORY_FILE);
|
|
1586
1822
|
if (existingMemory) {
|
|
1587
|
-
const
|
|
1823
|
+
const safeExistingMemory = filterMemoryForContext(existingMemory);
|
|
1824
|
+
const pinnedMatch = safeExistingMemory.match(/## Pinned\n([\s\S]*?)(?=\n## |\n# |$)/);
|
|
1588
1825
|
if (pinnedMatch) {
|
|
1589
1826
|
pinnedSection = pinnedMatch[1].trim();
|
|
1590
1827
|
}
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Override the detected home directory in deterministic tests. */
|
|
2
|
+
export declare function _setHookHomeDirForTest(directory: string | null): void;
|
|
3
|
+
export type HookAgentKey = "claude" | "codex" | "cursor" | "opencode" | "pi";
|
|
4
|
+
export interface HookTargetInfo {
|
|
5
|
+
key: HookAgentKey;
|
|
6
|
+
label: string;
|
|
7
|
+
homeMarker: string;
|
|
8
|
+
detectFiles: string[];
|
|
9
|
+
detectCommand?: string;
|
|
10
|
+
supported: boolean;
|
|
11
|
+
unsupportedReason?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DetectedHookTarget extends HookTargetInfo {
|
|
14
|
+
detected: boolean;
|
|
15
|
+
detectReason?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function detectHookAgents(): {
|
|
18
|
+
homeDir: string | null;
|
|
19
|
+
targets: DetectedHookTarget[];
|
|
20
|
+
};
|
|
21
|
+
export interface HookInstallResult {
|
|
22
|
+
key: HookAgentKey;
|
|
23
|
+
label: string;
|
|
24
|
+
installed: boolean;
|
|
25
|
+
path?: string;
|
|
26
|
+
backup?: string;
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface InstallHooksReport {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
homeDir?: string;
|
|
32
|
+
results: HookInstallResult[];
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function installHooks(agents: Set<HookAgentKey>): InstallHooksReport;
|
|
36
|
+
export interface UninstallHooksReport {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
homeDir?: string;
|
|
39
|
+
results: HookInstallResult[];
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function uninstallHooks(agents?: Set<HookAgentKey>): UninstallHooksReport;
|