myagentmemory 0.4.7 → 0.4.10
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 +32 -7
- package/dist/agent-memory +0 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +151 -12
- package/dist/core.d.ts +77 -2
- package/dist/core.js +502 -4
- package/package.json +1 -1
- package/scripts/install-skills.ps1 +5 -2
- package/scripts/install-skills.sh +54 -11
- package/skills/agent/SKILL.md +39 -14
- package/skills/claude-code/SKILL.md +39 -14
- package/skills/codex/SKILL.md +39 -14
- package/skills/cursor/SKILL.md +39 -14
- package/src/cli.ts +167 -14
- package/src/core.ts +605 -6
package/src/core.ts
CHANGED
|
@@ -20,6 +20,7 @@ let MEMORY_DIR = DEFAULT_MEMORY_DIR;
|
|
|
20
20
|
let MEMORY_FILE = path.join(MEMORY_DIR, "MEMORY.md");
|
|
21
21
|
let SCRATCHPAD_FILE = path.join(MEMORY_DIR, "SCRATCHPAD.md");
|
|
22
22
|
let DAILY_DIR = path.join(MEMORY_DIR, "daily");
|
|
23
|
+
let TOPICS_DIR = path.join(MEMORY_DIR, "topics");
|
|
23
24
|
|
|
24
25
|
/** Override base directory (for testing or platform-specific defaults). */
|
|
25
26
|
export function _setBaseDir(baseDir: string) {
|
|
@@ -27,6 +28,7 @@ export function _setBaseDir(baseDir: string) {
|
|
|
27
28
|
MEMORY_FILE = path.join(baseDir, "MEMORY.md");
|
|
28
29
|
SCRATCHPAD_FILE = path.join(baseDir, "SCRATCHPAD.md");
|
|
29
30
|
DAILY_DIR = path.join(baseDir, "daily");
|
|
31
|
+
TOPICS_DIR = path.join(baseDir, "topics");
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** Reset to default paths. */
|
|
@@ -54,6 +56,11 @@ export function getDailyDir(): string {
|
|
|
54
56
|
return DAILY_DIR;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/** Get the current topics directory path. */
|
|
60
|
+
export function getTopicsDir(): string {
|
|
61
|
+
return TOPICS_DIR;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
// ---------------------------------------------------------------------------
|
|
58
65
|
// Utilities
|
|
59
66
|
// ---------------------------------------------------------------------------
|
|
@@ -61,6 +68,7 @@ export function getDailyDir(): string {
|
|
|
61
68
|
export function ensureDirs() {
|
|
62
69
|
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
63
70
|
fs.mkdirSync(DAILY_DIR, { recursive: true });
|
|
71
|
+
fs.mkdirSync(TOPICS_DIR, { recursive: true });
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
export function todayStr(): string {
|
|
@@ -97,6 +105,19 @@ export function dailyPath(date: string): string {
|
|
|
97
105
|
return path.join(DAILY_DIR, `${date}.md`);
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
export function topicPath(slug: string): string {
|
|
109
|
+
return path.join(TOPICS_DIR, `${slug}.md`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function slugifyTopic(name: string): string {
|
|
113
|
+
return name
|
|
114
|
+
.trim()
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
117
|
+
.replace(/^-+|-+$/g, "")
|
|
118
|
+
.replace(/--+/g, "-");
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
// ---------------------------------------------------------------------------
|
|
101
122
|
// Limits + preview helpers
|
|
102
123
|
// ---------------------------------------------------------------------------
|
|
@@ -108,6 +129,8 @@ const CONTEXT_LONG_TERM_MAX_CHARS = 4_000;
|
|
|
108
129
|
const CONTEXT_LONG_TERM_MAX_LINES = 150;
|
|
109
130
|
const CONTEXT_SCRATCHPAD_MAX_CHARS = 2_000;
|
|
110
131
|
const CONTEXT_SCRATCHPAD_MAX_LINES = 120;
|
|
132
|
+
const CONTEXT_TOPICS_MAX_CHARS = 2_000;
|
|
133
|
+
const CONTEXT_TOPICS_MAX_LINES = 100;
|
|
111
134
|
const CONTEXT_DAILY_MAX_CHARS = 3_000;
|
|
112
135
|
const CONTEXT_DAILY_MAX_LINES = 120;
|
|
113
136
|
const CONTEXT_SEARCH_MAX_CHARS = 2_500;
|
|
@@ -298,7 +321,7 @@ export function serializeScratchpad(items: ScratchpadItem[]): string {
|
|
|
298
321
|
|
|
299
322
|
export function buildMemoryContext(searchResults?: string): string {
|
|
300
323
|
ensureDirs();
|
|
301
|
-
// Priority order: scratchpad > today's daily > search results > MEMORY.md > yesterday's daily
|
|
324
|
+
// Priority order: scratchpad > topics > today's daily > search results > MEMORY.md > yesterday's daily
|
|
302
325
|
const sections: string[] = [];
|
|
303
326
|
|
|
304
327
|
const scratchpad = readFileSafe(SCRATCHPAD_FILE);
|
|
@@ -317,6 +340,9 @@ export function buildMemoryContext(searchResults?: string): string {
|
|
|
317
340
|
}
|
|
318
341
|
}
|
|
319
342
|
|
|
343
|
+
const topicsSection = buildTopicsContextSection();
|
|
344
|
+
if (topicsSection) sections.push(topicsSection);
|
|
345
|
+
|
|
320
346
|
const today = todayStr();
|
|
321
347
|
const yesterday = yesterdayStr();
|
|
322
348
|
|
|
@@ -387,6 +413,48 @@ export function buildMemoryContext(searchResults?: string): string {
|
|
|
387
413
|
return context;
|
|
388
414
|
}
|
|
389
415
|
|
|
416
|
+
function buildTopicsContextSection(): string | null {
|
|
417
|
+
let topicFiles: string[];
|
|
418
|
+
try {
|
|
419
|
+
topicFiles = fs
|
|
420
|
+
.readdirSync(TOPICS_DIR)
|
|
421
|
+
.filter((f) => f.endsWith(".md"))
|
|
422
|
+
.sort();
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (topicFiles.length === 0) return null;
|
|
428
|
+
|
|
429
|
+
const entries: TopicEntry[] = [];
|
|
430
|
+
for (const file of topicFiles) {
|
|
431
|
+
const slug = file.replace(/\.md$/, "");
|
|
432
|
+
const content = readFileSafe(path.join(TOPICS_DIR, file));
|
|
433
|
+
if (!content?.trim()) continue;
|
|
434
|
+
const titleMatch = content.match(/^# Topic:\s*(.+)$/m);
|
|
435
|
+
const title = titleMatch?.[1]?.trim() || slug;
|
|
436
|
+
entries.push(...parseTopicEntries(title, slug, content));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (entries.length === 0) return null;
|
|
440
|
+
|
|
441
|
+
const recent = sortRecentFirst(entries).slice(0, 8);
|
|
442
|
+
const lines = recent.map((entry) => {
|
|
443
|
+
const firstLine = entry.content.split("\n")[0].slice(0, 120);
|
|
444
|
+
const suffix = entry.content.split("\n")[0].length > 120 ? "..." : "";
|
|
445
|
+
const datePart = entry.date ? ` → [[${entry.date}]]` : "";
|
|
446
|
+
return `- ${entry.topic}: ${firstLine}${suffix}${datePart}`;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
return formatContextSection(
|
|
450
|
+
"## Topics (recent)",
|
|
451
|
+
lines.join("\n"),
|
|
452
|
+
"start",
|
|
453
|
+
CONTEXT_TOPICS_MAX_LINES,
|
|
454
|
+
CONTEXT_TOPICS_MAX_CHARS,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
390
458
|
// ---------------------------------------------------------------------------
|
|
391
459
|
// QMD integration
|
|
392
460
|
// ---------------------------------------------------------------------------
|
|
@@ -482,7 +550,7 @@ function commandExists(cmd: string): boolean {
|
|
|
482
550
|
|
|
483
551
|
const dirs = envPath.split(path.delimiter).filter(Boolean);
|
|
484
552
|
const isWin = process.platform === "win32";
|
|
485
|
-
const rawExts = isWin ? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM" : "";
|
|
553
|
+
const rawExts = isWin ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM") : "";
|
|
486
554
|
const exts = isWin ? rawExts.split(";").filter(Boolean) : [""];
|
|
487
555
|
|
|
488
556
|
for (const dir of dirs) {
|
|
@@ -579,6 +647,7 @@ export async function setupQmdCollection(): Promise<boolean> {
|
|
|
579
647
|
// Add path contexts (best-effort, ignore errors)
|
|
580
648
|
const contexts: [string, string][] = [
|
|
581
649
|
["/daily", "Daily append-only work logs organized by date"],
|
|
650
|
+
["/topics", "Topic and event notes linked back to daily logs"],
|
|
582
651
|
["/", "Curated long-term memory: decisions, preferences, facts, lessons"],
|
|
583
652
|
];
|
|
584
653
|
for (const [ctxPath, desc] of contexts) {
|
|
@@ -738,6 +807,7 @@ export interface InstallSkillsReport {
|
|
|
738
807
|
ok: boolean;
|
|
739
808
|
projectDir?: string;
|
|
740
809
|
homeDir?: string;
|
|
810
|
+
checked: Array<{ label: string; status: "detected" | "skipped"; reason?: string }>;
|
|
741
811
|
detected: Array<{ label: string; homeMarker: string }>;
|
|
742
812
|
installed: Array<{ label: string; path: string }>;
|
|
743
813
|
skipped: Array<{ label: string; reason: string }>;
|
|
@@ -752,6 +822,7 @@ export function installSkills(): InstallSkillsReport {
|
|
|
752
822
|
detected: [],
|
|
753
823
|
installed: [],
|
|
754
824
|
skipped: [],
|
|
825
|
+
checked: [],
|
|
755
826
|
error: "Home directory not found. Set HOME (or USERPROFILE on Windows) and retry.",
|
|
756
827
|
};
|
|
757
828
|
}
|
|
@@ -764,6 +835,7 @@ export function installSkills(): InstallSkillsReport {
|
|
|
764
835
|
detected: [],
|
|
765
836
|
installed: [],
|
|
766
837
|
skipped: [],
|
|
838
|
+
checked: [],
|
|
767
839
|
error: "Could not locate the skills directory. Ensure the package includes skills/ (reinstall if needed).",
|
|
768
840
|
};
|
|
769
841
|
}
|
|
@@ -777,6 +849,7 @@ export function installSkills(): InstallSkillsReport {
|
|
|
777
849
|
detected: [],
|
|
778
850
|
installed: [],
|
|
779
851
|
skipped: [],
|
|
852
|
+
checked: [],
|
|
780
853
|
error: `Skills directory not found: ${skillsDir}`,
|
|
781
854
|
};
|
|
782
855
|
}
|
|
@@ -817,10 +890,12 @@ export function installSkills(): InstallSkillsReport {
|
|
|
817
890
|
const detected: Array<{ label: string; homeMarker: string }> = [];
|
|
818
891
|
const installed: Array<{ label: string; path: string }> = [];
|
|
819
892
|
const skipped: Array<{ label: string; reason: string }> = [];
|
|
893
|
+
const checked: Array<{ label: string; status: "detected" | "skipped"; reason?: string }> = [];
|
|
820
894
|
|
|
821
895
|
for (const target of targets) {
|
|
822
896
|
if (!fs.existsSync(target.homeMarker)) {
|
|
823
897
|
skipped.push({ label: target.label, reason: `${target.homeMarker} not found` });
|
|
898
|
+
checked.push({ label: target.label, status: "skipped", reason: `${target.homeMarker} not found` });
|
|
824
899
|
continue;
|
|
825
900
|
}
|
|
826
901
|
const detectedByFile = (target.detectFiles ?? []).some((file) => fs.existsSync(file));
|
|
@@ -828,10 +903,12 @@ export function installSkills(): InstallSkillsReport {
|
|
|
828
903
|
const requiresDetection = (target.detectFiles?.length ?? 0) > 0 || !!target.detectCommand;
|
|
829
904
|
if (requiresDetection && !detectedByFile && !detectedByCommand) {
|
|
830
905
|
skipped.push({ label: target.label, reason: "not detected" });
|
|
906
|
+
checked.push({ label: target.label, status: "skipped", reason: "not detected" });
|
|
831
907
|
continue;
|
|
832
908
|
}
|
|
833
909
|
|
|
834
910
|
detected.push({ label: target.label, homeMarker: target.homeMarker });
|
|
911
|
+
checked.push({ label: target.label, status: "detected" });
|
|
835
912
|
|
|
836
913
|
const skillFile = path.join(target.srcDir, "SKILL.md");
|
|
837
914
|
if (!fs.existsSync(skillFile)) {
|
|
@@ -848,12 +925,61 @@ export function installSkills(): InstallSkillsReport {
|
|
|
848
925
|
ok: true,
|
|
849
926
|
projectDir,
|
|
850
927
|
homeDir,
|
|
928
|
+
checked,
|
|
851
929
|
detected,
|
|
852
930
|
installed,
|
|
853
931
|
skipped,
|
|
854
932
|
};
|
|
855
933
|
}
|
|
856
934
|
|
|
935
|
+
export interface UninstallSkillsReport {
|
|
936
|
+
ok: boolean;
|
|
937
|
+
homeDir?: string;
|
|
938
|
+
removed: Array<{ label: string; path: string }>;
|
|
939
|
+
skipped: Array<{ label: string; reason: string }>;
|
|
940
|
+
error?: string;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export function uninstallSkills(): UninstallSkillsReport {
|
|
944
|
+
const homeDir = resolveHomeDir();
|
|
945
|
+
if (!homeDir) {
|
|
946
|
+
return {
|
|
947
|
+
ok: false,
|
|
948
|
+
removed: [],
|
|
949
|
+
skipped: [],
|
|
950
|
+
error: "Home directory not found. Set HOME (or USERPROFILE on Windows) and retry.",
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const targets = [
|
|
955
|
+
{ label: "Claude Code skill", destDir: path.join(homeDir, ".claude", "skills", "agent-memory") },
|
|
956
|
+
{ label: "Codex skill", destDir: path.join(homeDir, ".codex", "skills", "agent-memory") },
|
|
957
|
+
{ label: "Cursor skill", destDir: path.join(homeDir, ".cursor", "skills", "agent-memory") },
|
|
958
|
+
{ label: "Agent CLI skill", destDir: path.join(homeDir, ".agents", "skills", "agent-memory") },
|
|
959
|
+
];
|
|
960
|
+
|
|
961
|
+
const removed: Array<{ label: string; path: string }> = [];
|
|
962
|
+
const skipped: Array<{ label: string; reason: string }> = [];
|
|
963
|
+
|
|
964
|
+
for (const target of targets) {
|
|
965
|
+
const skillFile = path.join(target.destDir, "SKILL.md");
|
|
966
|
+
if (fs.existsSync(skillFile)) {
|
|
967
|
+
fs.unlinkSync(skillFile);
|
|
968
|
+
// Clean up empty directory
|
|
969
|
+
try {
|
|
970
|
+
fs.rmdirSync(target.destDir);
|
|
971
|
+
} catch {
|
|
972
|
+
// directory not empty or already gone — fine
|
|
973
|
+
}
|
|
974
|
+
removed.push({ label: target.label, path: skillFile });
|
|
975
|
+
} else {
|
|
976
|
+
skipped.push({ label: target.label, reason: "not installed" });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return { ok: true, homeDir, removed, skipped };
|
|
981
|
+
}
|
|
982
|
+
|
|
857
983
|
export interface QmdHealthInfo {
|
|
858
984
|
totalFiles: number | null;
|
|
859
985
|
vectorsEmbedded: number | null;
|
|
@@ -1056,13 +1182,16 @@ export interface ToolResult {
|
|
|
1056
1182
|
}
|
|
1057
1183
|
|
|
1058
1184
|
export async function memoryWrite(params: {
|
|
1059
|
-
target
|
|
1185
|
+
target?: "long_term" | "daily" | "topic";
|
|
1060
1186
|
content: string;
|
|
1061
1187
|
mode?: "append" | "overwrite";
|
|
1062
1188
|
sessionId?: string;
|
|
1189
|
+
topic?: string;
|
|
1190
|
+
date?: string;
|
|
1063
1191
|
}): Promise<ToolResult> {
|
|
1064
1192
|
ensureDirs();
|
|
1065
|
-
const
|
|
1193
|
+
const target = params.target ?? "daily";
|
|
1194
|
+
const { content, mode } = params;
|
|
1066
1195
|
const sid = shortSessionId(params.sessionId ?? "cli");
|
|
1067
1196
|
const ts = nowTimestamp();
|
|
1068
1197
|
|
|
@@ -1097,6 +1226,51 @@ export async function memoryWrite(params: {
|
|
|
1097
1226
|
};
|
|
1098
1227
|
}
|
|
1099
1228
|
|
|
1229
|
+
if (target === "topic") {
|
|
1230
|
+
const topic = params.topic?.trim();
|
|
1231
|
+
if (!topic) {
|
|
1232
|
+
return { text: "Error: 'topic' is required for target 'topic'.", details: {}, isError: true };
|
|
1233
|
+
}
|
|
1234
|
+
const slug = slugifyTopic(topic);
|
|
1235
|
+
if (!slug) {
|
|
1236
|
+
return { text: "Error: 'topic' must include at least one letter or number.", details: {}, isError: true };
|
|
1237
|
+
}
|
|
1238
|
+
const filePath = topicPath(slug);
|
|
1239
|
+
const existing = readFileSafe(filePath) ?? "";
|
|
1240
|
+
const existingPreview = buildPreview(existing, {
|
|
1241
|
+
maxLines: RESPONSE_PREVIEW_MAX_LINES,
|
|
1242
|
+
maxChars: RESPONSE_PREVIEW_MAX_CHARS,
|
|
1243
|
+
mode: "end",
|
|
1244
|
+
});
|
|
1245
|
+
const existingSnippet = existingPreview.preview
|
|
1246
|
+
? `\n\n${formatPreviewBlock("Existing topic preview", existing, "end")}`
|
|
1247
|
+
: "\n\nTopic file was empty.";
|
|
1248
|
+
|
|
1249
|
+
const linkDate = params.date?.trim() || todayStr();
|
|
1250
|
+
const header = `# Topic: ${topic}\n\n<!-- created: ${ts} [${sid}] -->\n`;
|
|
1251
|
+
const separator = existing.trim() ? "\n\n" : "";
|
|
1252
|
+
const base = existing.trim() ? existing : header.trimEnd();
|
|
1253
|
+
const stamped = `<!-- ${ts} [${sid}] -->\n${content.trim()}\nDaily: [[${linkDate}]]`;
|
|
1254
|
+
fs.writeFileSync(filePath, `${base}${separator}${stamped}`, "utf-8");
|
|
1255
|
+
await ensureQmdAvailableForUpdate();
|
|
1256
|
+
scheduleQmdUpdate();
|
|
1257
|
+
return {
|
|
1258
|
+
text: `Appended to topic: ${filePath}${existingSnippet}`,
|
|
1259
|
+
details: {
|
|
1260
|
+
path: filePath,
|
|
1261
|
+
target,
|
|
1262
|
+
mode: "append",
|
|
1263
|
+
sessionId: sid,
|
|
1264
|
+
timestamp: ts,
|
|
1265
|
+
topic,
|
|
1266
|
+
slug,
|
|
1267
|
+
date: linkDate,
|
|
1268
|
+
qmdUpdateMode: getQmdUpdateMode(),
|
|
1269
|
+
existingPreview,
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1100
1274
|
// long_term
|
|
1101
1275
|
const memFile = getMemoryFile();
|
|
1102
1276
|
const existing = readFileSafe(memFile) ?? "";
|
|
@@ -1277,11 +1451,12 @@ export async function scratchpadAction(params: {
|
|
|
1277
1451
|
}
|
|
1278
1452
|
|
|
1279
1453
|
export async function memoryRead(params: {
|
|
1280
|
-
target: "long_term" | "scratchpad" | "daily" | "list";
|
|
1454
|
+
target: "long_term" | "scratchpad" | "daily" | "list" | "topic" | "topics";
|
|
1281
1455
|
date?: string;
|
|
1456
|
+
topic?: string;
|
|
1282
1457
|
}): Promise<ToolResult> {
|
|
1283
1458
|
ensureDirs();
|
|
1284
|
-
const { target, date } = params;
|
|
1459
|
+
const { target, date, topic } = params;
|
|
1285
1460
|
|
|
1286
1461
|
if (target === "list") {
|
|
1287
1462
|
try {
|
|
@@ -1312,6 +1487,39 @@ export async function memoryRead(params: {
|
|
|
1312
1487
|
return { text: content, details: { path: filePath, date: d } };
|
|
1313
1488
|
}
|
|
1314
1489
|
|
|
1490
|
+
if (target === "topics") {
|
|
1491
|
+
try {
|
|
1492
|
+
const files = fs
|
|
1493
|
+
.readdirSync(getTopicsDir())
|
|
1494
|
+
.filter((f) => f.endsWith(".md"))
|
|
1495
|
+
.sort()
|
|
1496
|
+
.reverse();
|
|
1497
|
+
if (files.length === 0) {
|
|
1498
|
+
return { text: "No topics found.", details: {} };
|
|
1499
|
+
}
|
|
1500
|
+
return {
|
|
1501
|
+
text: `Topics:\n${files.map((f) => `- ${f}`).join("\n")}`,
|
|
1502
|
+
details: { files },
|
|
1503
|
+
};
|
|
1504
|
+
} catch {
|
|
1505
|
+
return { text: "No topics directory.", details: {} };
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if (target === "topic") {
|
|
1510
|
+
const name = topic?.trim();
|
|
1511
|
+
if (!name) {
|
|
1512
|
+
return { text: "Error: 'topic' is required for target 'topic'.", details: {}, isError: true };
|
|
1513
|
+
}
|
|
1514
|
+
const slug = slugifyTopic(name);
|
|
1515
|
+
const filePath = topicPath(slug);
|
|
1516
|
+
const content = readFileSafe(filePath);
|
|
1517
|
+
if (!content) {
|
|
1518
|
+
return { text: `No topic file found for ${name}.`, details: {} };
|
|
1519
|
+
}
|
|
1520
|
+
return { text: content, details: { path: filePath, topic: name, slug } };
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1315
1523
|
if (target === "scratchpad") {
|
|
1316
1524
|
const content = readFileSafe(getScratchpadFile());
|
|
1317
1525
|
if (!content?.trim()) {
|
|
@@ -1430,3 +1638,394 @@ export async function memorySearch(params: {
|
|
|
1430
1638
|
};
|
|
1431
1639
|
}
|
|
1432
1640
|
}
|
|
1641
|
+
|
|
1642
|
+
// ---------------------------------------------------------------------------
|
|
1643
|
+
// Distil — extraction helpers
|
|
1644
|
+
// ---------------------------------------------------------------------------
|
|
1645
|
+
|
|
1646
|
+
/** Extract #tag patterns, deduplicated and lowercased. */
|
|
1647
|
+
export function extractTags(content: string): string[] {
|
|
1648
|
+
const matches = content.match(/(?:^|\s)#([a-zA-Z][\w-]*)/g);
|
|
1649
|
+
if (!matches) return [];
|
|
1650
|
+
const tags = new Set(matches.map((m) => m.trim().toLowerCase()));
|
|
1651
|
+
return [...tags].sort();
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/** Extract [[link]] patterns. */
|
|
1655
|
+
export function extractLinks(content: string): string[] {
|
|
1656
|
+
const matches = content.match(/\[\[([^\]]+)\]\]/g);
|
|
1657
|
+
if (!matches) return [];
|
|
1658
|
+
const links = new Set(matches.map((m) => m.slice(2, -2).trim()));
|
|
1659
|
+
return [...links].sort();
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/** Extract file-path-like patterns (e.g. src/foo.ts), filtering out URLs. */
|
|
1663
|
+
export function extractFilePaths(content: string): string[] {
|
|
1664
|
+
const matches = content.match(/(?:^|\s)((?:[\w.-]+\/)+[\w.-]+\.\w+)/g);
|
|
1665
|
+
if (!matches) return [];
|
|
1666
|
+
const paths = new Set<string>();
|
|
1667
|
+
for (const m of matches) {
|
|
1668
|
+
const p = m.trim();
|
|
1669
|
+
// Skip URLs
|
|
1670
|
+
if (p.startsWith("http://") || p.startsWith("https://") || p.startsWith("//")) continue;
|
|
1671
|
+
paths.add(p);
|
|
1672
|
+
}
|
|
1673
|
+
return [...paths].sort();
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/** Extract backtick-quoted commands. */
|
|
1677
|
+
export function extractCommands(content: string): string[] {
|
|
1678
|
+
const matches = content.match(/`([^`]+)`/g);
|
|
1679
|
+
if (!matches) return [];
|
|
1680
|
+
const cmds = new Set(matches.map((m) => m.slice(1, -1).trim()).filter((c) => c.length > 2 && c.includes(" ")));
|
|
1681
|
+
return [...cmds];
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// ---------------------------------------------------------------------------
|
|
1685
|
+
// Distil — daily entry parsing
|
|
1686
|
+
// ---------------------------------------------------------------------------
|
|
1687
|
+
|
|
1688
|
+
export interface DailyEntry {
|
|
1689
|
+
date: string;
|
|
1690
|
+
timestamp: string;
|
|
1691
|
+
sessionId: string;
|
|
1692
|
+
content: string;
|
|
1693
|
+
tags: string[];
|
|
1694
|
+
links: string[];
|
|
1695
|
+
filePaths: string[];
|
|
1696
|
+
commands: string[];
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** Split a daily file on <!-- timestamp --> markers into individual entries. */
|
|
1700
|
+
export function parseDailyEntries(date: string, content: string): DailyEntry[] {
|
|
1701
|
+
const entries: DailyEntry[] = [];
|
|
1702
|
+
// Split on timestamp markers: <!-- 2026-02-21 14:30:00 [sid] -->
|
|
1703
|
+
const parts = content.split(/(?=<!-- \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[)/);
|
|
1704
|
+
|
|
1705
|
+
for (const part of parts) {
|
|
1706
|
+
const trimmed = part.trim();
|
|
1707
|
+
if (!trimmed) continue;
|
|
1708
|
+
|
|
1709
|
+
const metaMatch = trimmed.match(/^<!-- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[([^\]]*)\] -->/);
|
|
1710
|
+
const timestamp = metaMatch?.[1] ?? "";
|
|
1711
|
+
const sessionId = metaMatch?.[2] ?? "";
|
|
1712
|
+
const body = metaMatch ? trimmed.slice(metaMatch[0].length).trim() : trimmed;
|
|
1713
|
+
if (!body) continue;
|
|
1714
|
+
|
|
1715
|
+
entries.push({
|
|
1716
|
+
date,
|
|
1717
|
+
timestamp,
|
|
1718
|
+
sessionId,
|
|
1719
|
+
content: body,
|
|
1720
|
+
tags: extractTags(body),
|
|
1721
|
+
links: extractLinks(body),
|
|
1722
|
+
filePaths: extractFilePaths(body),
|
|
1723
|
+
commands: extractCommands(body),
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
return entries;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
export interface TopicEntry {
|
|
1731
|
+
topic: string;
|
|
1732
|
+
slug: string;
|
|
1733
|
+
date: string;
|
|
1734
|
+
timestamp: string;
|
|
1735
|
+
sessionId: string;
|
|
1736
|
+
content: string;
|
|
1737
|
+
tags: string[];
|
|
1738
|
+
links: string[];
|
|
1739
|
+
filePaths: string[];
|
|
1740
|
+
commands: string[];
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function stripDailyLinkLines(content: string): string {
|
|
1744
|
+
const lines = content.split("\n");
|
|
1745
|
+
const filtered = lines.filter(
|
|
1746
|
+
(line) =>
|
|
1747
|
+
!/^\s*Daily:\s*\[\[\d{4}-\d{2}-\d{2}\]\]\s*$/i.test(line) && !/^\s*\[\[\d{4}-\d{2}-\d{2}\]\]\s*$/i.test(line),
|
|
1748
|
+
);
|
|
1749
|
+
const cleaned = filtered.join("\n").trim();
|
|
1750
|
+
return cleaned || content.trim();
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/** Split a topic file on <!-- timestamp --> markers into entries. */
|
|
1754
|
+
export function parseTopicEntries(topic: string, slug: string, content: string): TopicEntry[] {
|
|
1755
|
+
const entries: TopicEntry[] = [];
|
|
1756
|
+
const parts = content.split(/(?=<!-- \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[)/);
|
|
1757
|
+
|
|
1758
|
+
for (const part of parts) {
|
|
1759
|
+
const trimmed = part.trim();
|
|
1760
|
+
if (!trimmed) continue;
|
|
1761
|
+
|
|
1762
|
+
const metaMatch = trimmed.match(/^<!-- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[([^\]]*)\] -->/);
|
|
1763
|
+
if (!metaMatch) continue;
|
|
1764
|
+
|
|
1765
|
+
const timestamp = metaMatch[1] ?? "";
|
|
1766
|
+
const sessionId = metaMatch[2] ?? "";
|
|
1767
|
+
const body = trimmed.slice(metaMatch[0].length).trim();
|
|
1768
|
+
if (!body) continue;
|
|
1769
|
+
|
|
1770
|
+
const dateMatch = body.match(/\[\[(\d{4}-\d{2}-\d{2})\]\]/);
|
|
1771
|
+
const date = dateMatch?.[1] ?? timestamp.slice(0, 10);
|
|
1772
|
+
const cleaned = stripDailyLinkLines(body);
|
|
1773
|
+
|
|
1774
|
+
entries.push({
|
|
1775
|
+
topic,
|
|
1776
|
+
slug,
|
|
1777
|
+
date,
|
|
1778
|
+
timestamp,
|
|
1779
|
+
sessionId,
|
|
1780
|
+
content: cleaned,
|
|
1781
|
+
tags: extractTags(cleaned),
|
|
1782
|
+
links: extractLinks(cleaned),
|
|
1783
|
+
filePaths: extractFilePaths(cleaned),
|
|
1784
|
+
commands: extractCommands(cleaned),
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
return entries;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// ---------------------------------------------------------------------------
|
|
1792
|
+
// Distil — main function
|
|
1793
|
+
// ---------------------------------------------------------------------------
|
|
1794
|
+
|
|
1795
|
+
export interface DistilResult {
|
|
1796
|
+
ok: boolean;
|
|
1797
|
+
dryRun: boolean;
|
|
1798
|
+
totalDailyFiles: number;
|
|
1799
|
+
totalTopicFiles: number;
|
|
1800
|
+
totalEntries: number;
|
|
1801
|
+
totalTopicEntries: number;
|
|
1802
|
+
totalTags: number;
|
|
1803
|
+
tagCounts: Record<string, number>;
|
|
1804
|
+
output: string;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/** Summarize entry content: first line, truncated at 80 chars. */
|
|
1808
|
+
type DistilEntry = DailyEntry | TopicEntry;
|
|
1809
|
+
|
|
1810
|
+
function summarizeEntry(entry: DistilEntry): string {
|
|
1811
|
+
const firstLine = entry.content.split("\n")[0].slice(0, 80);
|
|
1812
|
+
const suffix = entry.content.split("\n")[0].length > 80 ? "..." : "";
|
|
1813
|
+
return `${firstLine}${suffix}`;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Sort entries recent-first by date then timestamp. */
|
|
1817
|
+
function sortRecentFirst<T extends { date: string; timestamp: string }>(entries: T[]): T[] {
|
|
1818
|
+
return [...entries].sort((a, b) => {
|
|
1819
|
+
if (a.date !== b.date) return b.date.localeCompare(a.date);
|
|
1820
|
+
return b.timestamp.localeCompare(a.timestamp);
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function entryKey(entry: DistilEntry): string {
|
|
1825
|
+
const base = `${entry.date}:${entry.timestamp}:${entry.sessionId}`;
|
|
1826
|
+
return "topic" in entry ? `topic:${entry.slug}:${base}` : `daily:${base}`;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
export async function distilMemories(params?: { dryRun?: boolean; sessionId?: string }): Promise<DistilResult> {
|
|
1830
|
+
ensureDirs();
|
|
1831
|
+
const dryRun = params?.dryRun ?? false;
|
|
1832
|
+
|
|
1833
|
+
// 1. Read all daily + topic files
|
|
1834
|
+
let dailyFiles: string[];
|
|
1835
|
+
try {
|
|
1836
|
+
dailyFiles = fs
|
|
1837
|
+
.readdirSync(DAILY_DIR)
|
|
1838
|
+
.filter((f) => f.endsWith(".md"))
|
|
1839
|
+
.sort();
|
|
1840
|
+
} catch {
|
|
1841
|
+
dailyFiles = [];
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
let topicFiles: string[];
|
|
1845
|
+
try {
|
|
1846
|
+
topicFiles = fs
|
|
1847
|
+
.readdirSync(TOPICS_DIR)
|
|
1848
|
+
.filter((f) => f.endsWith(".md"))
|
|
1849
|
+
.sort();
|
|
1850
|
+
} catch {
|
|
1851
|
+
topicFiles = [];
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (dailyFiles.length === 0 && topicFiles.length === 0) {
|
|
1855
|
+
return {
|
|
1856
|
+
ok: true,
|
|
1857
|
+
dryRun,
|
|
1858
|
+
totalDailyFiles: 0,
|
|
1859
|
+
totalTopicFiles: 0,
|
|
1860
|
+
totalEntries: 0,
|
|
1861
|
+
totalTopicEntries: 0,
|
|
1862
|
+
totalTags: 0,
|
|
1863
|
+
tagCounts: {},
|
|
1864
|
+
output: "# Memory Index\n\nNo daily logs or topics to distil.\n",
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// 2. Parse all entries
|
|
1869
|
+
const allEntries: DistilEntry[] = [];
|
|
1870
|
+
for (const file of dailyFiles) {
|
|
1871
|
+
const date = file.replace(/\.md$/, "");
|
|
1872
|
+
const content = readFileSafe(path.join(DAILY_DIR, file));
|
|
1873
|
+
if (!content?.trim()) continue;
|
|
1874
|
+
allEntries.push(...parseDailyEntries(date, content));
|
|
1875
|
+
}
|
|
1876
|
+
const topicEntriesByTopic = new Map<string, TopicEntry[]>();
|
|
1877
|
+
let totalTopicEntries = 0;
|
|
1878
|
+
for (const file of topicFiles) {
|
|
1879
|
+
const slug = file.replace(/\.md$/, "");
|
|
1880
|
+
const content = readFileSafe(path.join(TOPICS_DIR, file));
|
|
1881
|
+
if (!content?.trim()) continue;
|
|
1882
|
+
const titleMatch = content.match(/^# Topic:\s*(.+)$/m);
|
|
1883
|
+
const title = titleMatch?.[1]?.trim() || slug;
|
|
1884
|
+
const entries = parseTopicEntries(title, slug, content);
|
|
1885
|
+
if (entries.length === 0) continue;
|
|
1886
|
+
totalTopicEntries += entries.length;
|
|
1887
|
+
allEntries.push(...entries);
|
|
1888
|
+
topicEntriesByTopic.set(title, entries);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
if (allEntries.length === 0) {
|
|
1892
|
+
return {
|
|
1893
|
+
ok: true,
|
|
1894
|
+
dryRun,
|
|
1895
|
+
totalDailyFiles: dailyFiles.length,
|
|
1896
|
+
totalTopicFiles: topicFiles.length,
|
|
1897
|
+
totalEntries: 0,
|
|
1898
|
+
totalTopicEntries: 0,
|
|
1899
|
+
totalTags: 0,
|
|
1900
|
+
tagCounts: {},
|
|
1901
|
+
output: "# Memory Index\n\nNo entries found in daily logs or topics.\n",
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// 3. Build tag → entries map and tag → dates map
|
|
1906
|
+
const tagEntries = new Map<string, DistilEntry[]>();
|
|
1907
|
+
const tagDates = new Map<string, Set<string>>();
|
|
1908
|
+
const untagged: DistilEntry[] = [];
|
|
1909
|
+
|
|
1910
|
+
for (const entry of allEntries) {
|
|
1911
|
+
if (entry.tags.length === 0) {
|
|
1912
|
+
untagged.push(entry);
|
|
1913
|
+
} else {
|
|
1914
|
+
for (const tag of entry.tags) {
|
|
1915
|
+
if (!tagEntries.has(tag)) tagEntries.set(tag, []);
|
|
1916
|
+
tagEntries.get(tag)!.push(entry);
|
|
1917
|
+
if (!tagDates.has(tag)) tagDates.set(tag, new Set());
|
|
1918
|
+
tagDates.get(tag)!.add(entry.date);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// Sort tags by entry count (most used first), take top 15 for sections
|
|
1924
|
+
const sortedTags = [...tagEntries.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
1925
|
+
const sectionTags = sortedTags.slice(0, 15);
|
|
1926
|
+
const indexTags = sortedTags.slice(0, 20);
|
|
1927
|
+
|
|
1928
|
+
// 4. Preserve existing ## Pinned section from MEMORY.md
|
|
1929
|
+
let pinnedSection = "";
|
|
1930
|
+
const existingMemory = readFileSafe(MEMORY_FILE);
|
|
1931
|
+
if (existingMemory) {
|
|
1932
|
+
const pinnedMatch = existingMemory.match(/## Pinned\n([\s\S]*?)(?=\n## |\n# |$)/);
|
|
1933
|
+
if (pinnedMatch) {
|
|
1934
|
+
pinnedSection = pinnedMatch[1].trim();
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// 5. Generate output
|
|
1939
|
+
const lines: string[] = [];
|
|
1940
|
+
const ts = nowTimestamp();
|
|
1941
|
+
lines.push("# Memory Index");
|
|
1942
|
+
lines.push(`<!-- last distilled: ${ts} -->`);
|
|
1943
|
+
lines.push("");
|
|
1944
|
+
|
|
1945
|
+
// Pinned section
|
|
1946
|
+
if (pinnedSection) {
|
|
1947
|
+
lines.push("## Pinned");
|
|
1948
|
+
lines.push(pinnedSection);
|
|
1949
|
+
lines.push("");
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// Topics section — list topics with latest entry summary
|
|
1953
|
+
if (topicEntriesByTopic.size > 0) {
|
|
1954
|
+
lines.push("## Topics");
|
|
1955
|
+
const sortedTopics = [...topicEntriesByTopic.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
1956
|
+
for (const [topic, entries] of sortedTopics) {
|
|
1957
|
+
const recent = sortRecentFirst(entries)[0];
|
|
1958
|
+
const summary = summarizeEntry(recent);
|
|
1959
|
+
const dateLink = recent.date ? `[[${recent.date}]]` : "";
|
|
1960
|
+
const filePath = `topics/${entries[0].slug}.md`;
|
|
1961
|
+
const parts = [
|
|
1962
|
+
`${topic} — ${summary}`,
|
|
1963
|
+
dateLink ? `→ ${dateLink}` : "",
|
|
1964
|
+
`(${entries.length} entries)`,
|
|
1965
|
+
`(${filePath})`,
|
|
1966
|
+
].filter(Boolean);
|
|
1967
|
+
lines.push(`- ${parts.join(" ")}`);
|
|
1968
|
+
}
|
|
1969
|
+
lines.push("");
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
// Tag-based sections — top tags become section headers, each capped at 3 entries
|
|
1973
|
+
const tagCounts: Record<string, number> = {};
|
|
1974
|
+
const shownEntryIds = new Set<string>();
|
|
1975
|
+
|
|
1976
|
+
for (const [tag, entries] of sectionTags) {
|
|
1977
|
+
tagCounts[tag] = entries.length;
|
|
1978
|
+
lines.push(`## ${tag}`);
|
|
1979
|
+
const recent = sortRecentFirst(entries).slice(0, 3);
|
|
1980
|
+
for (const entry of recent) {
|
|
1981
|
+
const entryId = entryKey(entry);
|
|
1982
|
+
shownEntryIds.add(entryId);
|
|
1983
|
+
lines.push(`- ${summarizeEntry(entry)} → [[${entry.date}]]`);
|
|
1984
|
+
}
|
|
1985
|
+
lines.push("");
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// Recent untagged entries (up to 5, not already shown)
|
|
1989
|
+
const unseenUntagged = sortRecentFirst(untagged)
|
|
1990
|
+
.filter((e) => !shownEntryIds.has(entryKey(e)))
|
|
1991
|
+
.slice(0, 5);
|
|
1992
|
+
if (unseenUntagged.length > 0) {
|
|
1993
|
+
lines.push("## Recent");
|
|
1994
|
+
for (const entry of unseenUntagged) {
|
|
1995
|
+
lines.push(`- ${summarizeEntry(entry)} → [[${entry.date}]]`);
|
|
1996
|
+
}
|
|
1997
|
+
lines.push("");
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// Tag index — all tags with their dates
|
|
2001
|
+
if (indexTags.length > 0) {
|
|
2002
|
+
lines.push("## Tags");
|
|
2003
|
+
for (const [tag] of indexTags) {
|
|
2004
|
+
const dates = tagDates.get(tag)!;
|
|
2005
|
+
const recentDates = [...dates].sort().reverse().slice(0, 5);
|
|
2006
|
+
lines.push(`${tag} → ${recentDates.join(", ")}`);
|
|
2007
|
+
}
|
|
2008
|
+
lines.push("");
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
const output = lines.join("\n");
|
|
2012
|
+
|
|
2013
|
+
// 6. Write if not dry-run
|
|
2014
|
+
if (!dryRun) {
|
|
2015
|
+
fs.writeFileSync(MEMORY_FILE, output, "utf-8");
|
|
2016
|
+
await ensureQmdAvailableForUpdate();
|
|
2017
|
+
scheduleQmdUpdate();
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
return {
|
|
2021
|
+
ok: true,
|
|
2022
|
+
dryRun,
|
|
2023
|
+
totalDailyFiles: dailyFiles.length,
|
|
2024
|
+
totalTopicFiles: topicFiles.length,
|
|
2025
|
+
totalEntries: allEntries.length,
|
|
2026
|
+
totalTopicEntries,
|
|
2027
|
+
totalTags: indexTags.length,
|
|
2028
|
+
tagCounts,
|
|
2029
|
+
output,
|
|
2030
|
+
};
|
|
2031
|
+
}
|