myagentmemory 0.4.8 → 0.4.11

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/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) {
@@ -863,6 +932,54 @@ export function installSkills(): InstallSkillsReport {
863
932
  };
864
933
  }
865
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
+
866
983
  export interface QmdHealthInfo {
867
984
  totalFiles: number | null;
868
985
  vectorsEmbedded: number | null;
@@ -1065,13 +1182,16 @@ export interface ToolResult {
1065
1182
  }
1066
1183
 
1067
1184
  export async function memoryWrite(params: {
1068
- target: "long_term" | "daily";
1185
+ target?: "long_term" | "daily" | "topic";
1069
1186
  content: string;
1070
1187
  mode?: "append" | "overwrite";
1071
1188
  sessionId?: string;
1189
+ topic?: string;
1190
+ date?: string;
1072
1191
  }): Promise<ToolResult> {
1073
1192
  ensureDirs();
1074
- const { target, content, mode } = params;
1193
+ const target = params.target ?? "daily";
1194
+ const { content, mode } = params;
1075
1195
  const sid = shortSessionId(params.sessionId ?? "cli");
1076
1196
  const ts = nowTimestamp();
1077
1197
 
@@ -1106,6 +1226,51 @@ export async function memoryWrite(params: {
1106
1226
  };
1107
1227
  }
1108
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
+
1109
1274
  // long_term
1110
1275
  const memFile = getMemoryFile();
1111
1276
  const existing = readFileSafe(memFile) ?? "";
@@ -1286,11 +1451,12 @@ export async function scratchpadAction(params: {
1286
1451
  }
1287
1452
 
1288
1453
  export async function memoryRead(params: {
1289
- target: "long_term" | "scratchpad" | "daily" | "list";
1454
+ target: "long_term" | "scratchpad" | "daily" | "list" | "topic" | "topics";
1290
1455
  date?: string;
1456
+ topic?: string;
1291
1457
  }): Promise<ToolResult> {
1292
1458
  ensureDirs();
1293
- const { target, date } = params;
1459
+ const { target, date, topic } = params;
1294
1460
 
1295
1461
  if (target === "list") {
1296
1462
  try {
@@ -1321,6 +1487,39 @@ export async function memoryRead(params: {
1321
1487
  return { text: content, details: { path: filePath, date: d } };
1322
1488
  }
1323
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
+
1324
1523
  if (target === "scratchpad") {
1325
1524
  const content = readFileSafe(getScratchpadFile());
1326
1525
  if (!content?.trim()) {
@@ -1439,3 +1638,394 @@ export async function memorySearch(params: {
1439
1638
  };
1440
1639
  }
1441
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
+ }