@massu/core 0.4.2 → 0.5.0

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.
@@ -1606,9 +1606,66 @@ function storeSecurityScore(db, sessionId, filePath, riskScore, findings) {
1606
1606
  }
1607
1607
 
1608
1608
  // src/hooks/post-tool-use.ts
1609
- import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
1609
+ import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
1610
1610
  import { join as join2 } from "path";
1611
+ import { parse as parseYaml3 } from "yaml";
1612
+
1613
+ // src/memory-file-ingest.ts
1614
+ import { readFileSync as readFileSync5, existsSync as existsSync6, readdirSync } from "fs";
1611
1615
  import { parse as parseYaml2 } from "yaml";
1616
+ function ingestMemoryFile(db, sessionId, filePath) {
1617
+ if (!existsSync6(filePath)) return "skipped";
1618
+ const content = readFileSync5(filePath, "utf-8");
1619
+ const basename2 = (filePath.split("/").pop() ?? "").replace(".md", "");
1620
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
1621
+ let name = basename2;
1622
+ let description = "";
1623
+ let type = "discovery";
1624
+ let confidence;
1625
+ if (frontmatterMatch) {
1626
+ try {
1627
+ const fm = parseYaml2(frontmatterMatch[1]);
1628
+ name = fm.name ?? basename2;
1629
+ description = fm.description ?? "";
1630
+ type = fm.type ?? "discovery";
1631
+ confidence = fm.confidence != null ? Number(fm.confidence) : void 0;
1632
+ } catch {
1633
+ }
1634
+ }
1635
+ const obsType = mapMemoryTypeToObservationType(type);
1636
+ const importance = confidence != null ? Math.max(1, Math.min(5, Math.round(confidence * 4 + 1))) : 4;
1637
+ const bodyMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)/);
1638
+ const body = bodyMatch ? bodyMatch[1].trim().slice(0, 500) : "";
1639
+ const title = `[memory-file] ${name}`;
1640
+ const detail = description ? `${description}
1641
+
1642
+ ${body}` : body;
1643
+ const existing = db.prepare(
1644
+ "SELECT id FROM observations WHERE title = ? LIMIT 1"
1645
+ ).get(title);
1646
+ if (existing) {
1647
+ db.prepare("UPDATE observations SET detail = ?, importance = ? WHERE id = ?").run(detail, importance, existing.id);
1648
+ return "updated";
1649
+ } else {
1650
+ addObservation(db, sessionId, obsType, title, detail, { importance });
1651
+ return "inserted";
1652
+ }
1653
+ }
1654
+ function mapMemoryTypeToObservationType(memoryType) {
1655
+ switch (memoryType) {
1656
+ case "user":
1657
+ case "feedback":
1658
+ return "decision";
1659
+ case "project":
1660
+ return "feature";
1661
+ case "reference":
1662
+ return "discovery";
1663
+ default:
1664
+ return "discovery";
1665
+ }
1666
+ }
1667
+
1668
+ // src/hooks/post-tool-use.ts
1612
1669
  var seenReads = /* @__PURE__ */ new Set();
1613
1670
  var currentSessionId = null;
1614
1671
  async function main() {
@@ -1705,6 +1762,18 @@ async function main() {
1705
1762
  }
1706
1763
  } catch (_memoryErr) {
1707
1764
  }
1765
+ try {
1766
+ if (tool_name === "Edit" || tool_name === "Write") {
1767
+ const filePath = tool_input.file_path ?? "";
1768
+ if (filePath && filePath.includes("/memory/") && filePath.endsWith(".md")) {
1769
+ const basename2 = filePath.split("/").pop() ?? "";
1770
+ if (basename2 !== "MEMORY.md") {
1771
+ ingestMemoryFile(db, session_id, filePath);
1772
+ }
1773
+ }
1774
+ }
1775
+ } catch (_memoryIngestErr) {
1776
+ }
1708
1777
  try {
1709
1778
  if (tool_name === "Edit" || tool_name === "Write") {
1710
1779
  const filePath = tool_input.file_path ?? "";
@@ -1768,9 +1837,9 @@ function readConventions(cwd) {
1768
1837
  try {
1769
1838
  const projectRoot = cwd ?? process.cwd();
1770
1839
  const configPath = join2(projectRoot, "massu.config.yaml");
1771
- if (!existsSync6(configPath)) return defaults;
1772
- const content = readFileSync5(configPath, "utf-8");
1773
- const parsed = parseYaml2(content);
1840
+ if (!existsSync7(configPath)) return defaults;
1841
+ const content = readFileSync6(configPath, "utf-8");
1842
+ const parsed = parseYaml3(content);
1774
1843
  if (!parsed || typeof parsed !== "object") return defaults;
1775
1844
  const conventions = parsed.conventions;
1776
1845
  if (!conventions || typeof conventions !== "object") return defaults;
@@ -1797,11 +1866,11 @@ function isKnowledgeSourceFile(filePath) {
1797
1866
  function checkMemoryFileIntegrity(filePath) {
1798
1867
  const issues = [];
1799
1868
  try {
1800
- if (!existsSync6(filePath)) {
1869
+ if (!existsSync7(filePath)) {
1801
1870
  issues.push("MEMORY.md file does not exist after write");
1802
1871
  return issues;
1803
1872
  }
1804
- const content = readFileSync5(filePath, "utf-8");
1873
+ const content = readFileSync6(filePath, "utf-8");
1805
1874
  const lines = content.split("\n");
1806
1875
  const MAX_LINES = 200;
1807
1876
  if (lines.length > MAX_LINES) {
@@ -951,6 +951,22 @@ async function main() {
951
951
  }
952
952
  } catch (_knowledgeErr) {
953
953
  }
954
+ try {
955
+ const significantSignals = ["fix", "implement", "migrate", "refactor", "debug", "decision", "chose", "architecture", "redesign", "rewrite"];
956
+ const promptLower = prompt.toLowerCase();
957
+ const signalCount = significantSignals.filter((s) => promptLower.includes(s)).length;
958
+ if (signalCount >= 2) {
959
+ const memoryFileCount = db.prepare(
960
+ "SELECT COUNT(*) as count FROM observations WHERE session_id = ? AND title LIKE '[memory-file] %'"
961
+ ).get(session_id);
962
+ if (memoryFileCount.count === 0) {
963
+ process.stderr.write(
964
+ "\n[MEMORY REMINDER] Significant work detected but no memory files have been written.\nConsider saving learnings to memory/*.md files for future sessions.\n\n"
965
+ );
966
+ }
967
+ }
968
+ } catch (_memoryNagErr) {
969
+ }
954
970
  } finally {
955
971
  db.close();
956
972
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 43 workflow commands",
6
6
  "main": "src/server.ts",
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
17
17
  import { resolve, basename, dirname } from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { homedir } from 'os';
20
+ import { backfillMemoryFiles } from '../memory-file-ingest.ts';
20
21
 
21
22
  const __filename = fileURLToPath(import.meta.url);
22
23
  const __dirname = dirname(__filename);
@@ -588,6 +589,32 @@ export async function runInit(): Promise<void> {
588
589
  console.log(' Created initial MEMORY.md');
589
590
  }
590
591
 
592
+ // Step 6b: Auto-backfill existing memory files into database
593
+ try {
594
+ const claudeDirName = '.claude';
595
+ const encodedRoot = projectRoot.replace(/\//g, '-');
596
+ const computedMemoryDir = resolve(homedir(), claudeDirName, 'projects', encodedRoot, 'memory');
597
+
598
+ const memFiles = existsSync(computedMemoryDir)
599
+ ? readdirSync(computedMemoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
600
+ : [];
601
+
602
+ if (memFiles.length > 0) {
603
+ const { getMemoryDb } = await import('../memory-db.ts');
604
+ const db = getMemoryDb();
605
+ try {
606
+ const stats = backfillMemoryFiles(db, computedMemoryDir, `init-${Date.now()}`);
607
+ if (stats.inserted > 0 || stats.updated > 0) {
608
+ console.log(` Backfilled ${stats.inserted + stats.updated} memory files into database (${stats.inserted} new, ${stats.updated} updated)`);
609
+ }
610
+ } finally {
611
+ db.close();
612
+ }
613
+ }
614
+ } catch (_backfillErr) {
615
+ // Best-effort: don't fail init if backfill fails
616
+ }
617
+
591
618
  // Step 7: Databases info
592
619
  console.log(' Databases will auto-create on first session');
593
620
 
@@ -17,6 +17,7 @@ import { scoreFileSecurity, storeSecurityScore } from '../security-scorer.ts';
17
17
  import { readFileSync, existsSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { parse as parseYaml } from 'yaml';
20
+ import { ingestMemoryFile } from '../memory-file-ingest.ts';
20
21
 
21
22
  interface HookInput {
22
23
  session_id: string;
@@ -149,6 +150,22 @@ async function main(): Promise<void> {
149
150
  // Best-effort: never block post-tool-use
150
151
  }
151
152
 
153
+ // Memory file auto-ingest: when Claude writes a memory/*.md file,
154
+ // parse frontmatter and ingest into observations table
155
+ try {
156
+ if (tool_name === 'Edit' || tool_name === 'Write') {
157
+ const filePath = (tool_input.file_path as string) ?? '';
158
+ if (filePath && filePath.includes('/memory/') && filePath.endsWith('.md')) {
159
+ const basename = filePath.split('/').pop() ?? '';
160
+ if (basename !== 'MEMORY.md') {
161
+ ingestMemoryFile(db, session_id, filePath);
162
+ }
163
+ }
164
+ }
165
+ } catch (_memoryIngestErr) {
166
+ // Best-effort: never block post-tool-use
167
+ }
168
+
152
169
  // Knowledge index staleness check on knowledge file edits
153
170
  try {
154
171
  if (tool_name === 'Edit' || tool_name === 'Write') {
@@ -86,6 +86,27 @@ async function main(): Promise<void> {
86
86
  } catch (_knowledgeErr) {
87
87
  // Best-effort: never block prompt capture
88
88
  }
89
+ // 6. Memory enforcement: nag when significant work detected but no memory ingestion
90
+ try {
91
+ const significantSignals = ['fix', 'implement', 'migrate', 'refactor', 'debug', 'decision', 'chose', 'architecture', 'redesign', 'rewrite'];
92
+ const promptLower = prompt.toLowerCase();
93
+ const signalCount = significantSignals.filter(s => promptLower.includes(s)).length;
94
+
95
+ if (signalCount >= 2) {
96
+ const memoryFileCount = db.prepare(
97
+ "SELECT COUNT(*) as count FROM observations WHERE session_id = ? AND title LIKE '[memory-file] %'"
98
+ ).get(session_id) as { count: number };
99
+
100
+ if (memoryFileCount.count === 0) {
101
+ process.stderr.write(
102
+ '\n[MEMORY REMINDER] Significant work detected but no memory files have been written.\n' +
103
+ 'Consider saving learnings to memory/*.md files for future sessions.\n\n'
104
+ );
105
+ }
106
+ }
107
+ } catch (_memoryNagErr) {
108
+ // Best-effort: never block prompt capture
109
+ }
89
110
  } finally {
90
111
  db.close();
91
112
  }
@@ -0,0 +1,127 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ // ============================================================
5
+ // Memory File Auto-Ingest
6
+ // Shared module for parsing memory/*.md files and ingesting
7
+ // their YAML frontmatter + content into the observations table.
8
+ // Used by: post-tool-use.ts, memory-tools.ts, init.ts
9
+ // ============================================================
10
+
11
+ import type Database from 'better-sqlite3';
12
+ import { readFileSync, existsSync, readdirSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { parse as parseYaml } from 'yaml';
15
+ import { addObservation } from './memory-db.ts';
16
+
17
+ export type IngestResult = 'inserted' | 'updated' | 'skipped';
18
+
19
+ /**
20
+ * Parse a memory/*.md file's YAML frontmatter and ingest it into the
21
+ * observations table. Deduplicates by title prefix `[memory-file] {name}`.
22
+ *
23
+ * @returns 'inserted' | 'updated' | 'skipped'
24
+ */
25
+ export function ingestMemoryFile(
26
+ db: Database.Database,
27
+ sessionId: string,
28
+ filePath: string,
29
+ ): IngestResult {
30
+ if (!existsSync(filePath)) return 'skipped';
31
+
32
+ const content = readFileSync(filePath, 'utf-8');
33
+ const basename = (filePath.split('/').pop() ?? '').replace('.md', '');
34
+
35
+ // Parse YAML frontmatter (between first --- and second ---)
36
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
37
+
38
+ let name = basename;
39
+ let description = '';
40
+ let type = 'discovery';
41
+ let confidence: number | undefined;
42
+
43
+ if (frontmatterMatch) {
44
+ try {
45
+ const fm = parseYaml(frontmatterMatch[1]) as Record<string, unknown>;
46
+ name = (fm.name as string) ?? basename;
47
+ description = (fm.description as string) ?? '';
48
+ type = (fm.type as string) ?? 'discovery';
49
+ confidence = fm.confidence != null ? Number(fm.confidence) : undefined;
50
+ } catch {
51
+ // Use defaults if YAML parsing fails
52
+ }
53
+ }
54
+
55
+ // Map memory types to observation types
56
+ const obsType = mapMemoryTypeToObservationType(type);
57
+
58
+ // Calculate importance from confidence (0.0-1.0 -> 1-5)
59
+ const importance = confidence != null
60
+ ? Math.max(1, Math.min(5, Math.round(confidence * 4 + 1)))
61
+ : 4;
62
+
63
+ // Extract body (after second ---)
64
+ const bodyMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)/);
65
+ const body = bodyMatch ? bodyMatch[1].trim().slice(0, 500) : '';
66
+
67
+ const title = `[memory-file] ${name}`;
68
+ const detail = description ? `${description}\n\n${body}` : body;
69
+
70
+ // Deduplicate: check if this exact title exists
71
+ const existing = db.prepare(
72
+ 'SELECT id FROM observations WHERE title = ? LIMIT 1'
73
+ ).get(title) as { id: number } | undefined;
74
+
75
+ if (existing) {
76
+ db.prepare('UPDATE observations SET detail = ?, importance = ? WHERE id = ?')
77
+ .run(detail, importance, existing.id);
78
+ return 'updated';
79
+ } else {
80
+ addObservation(db, sessionId, obsType, title, detail, { importance });
81
+ return 'inserted';
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Bulk-ingest all memory/*.md files from a directory.
87
+ * Skips MEMORY.md (the index file).
88
+ *
89
+ * @returns { inserted, updated, skipped, total }
90
+ */
91
+ export function backfillMemoryFiles(
92
+ db: Database.Database,
93
+ memoryDir: string,
94
+ sessionId?: string,
95
+ ): { inserted: number; updated: number; skipped: number; total: number } {
96
+ const stats = { inserted: 0, updated: 0, skipped: 0, total: 0 };
97
+
98
+ if (!existsSync(memoryDir)) return stats;
99
+
100
+ const files = readdirSync(memoryDir).filter(
101
+ f => f.endsWith('.md') && f !== 'MEMORY.md'
102
+ );
103
+ stats.total = files.length;
104
+
105
+ const sid = sessionId ?? `backfill-${Date.now()}`;
106
+
107
+ for (const file of files) {
108
+ const result = ingestMemoryFile(db, sid, join(memoryDir, file));
109
+ stats[result]++;
110
+ }
111
+
112
+ return stats;
113
+ }
114
+
115
+ function mapMemoryTypeToObservationType(memoryType: string): string {
116
+ switch (memoryType) {
117
+ case 'user':
118
+ case 'feedback':
119
+ return 'decision';
120
+ case 'project':
121
+ return 'feature';
122
+ case 'reference':
123
+ return 'discovery';
124
+ default:
125
+ return 'discovery';
126
+ }
127
+ }
@@ -13,7 +13,8 @@ import {
13
13
  assignImportance,
14
14
  createSession,
15
15
  } from './memory-db.ts';
16
- import { getConfig } from './config.ts';
16
+ import { getConfig, getResolvedPaths } from './config.ts';
17
+ import { backfillMemoryFiles } from './memory-file-ingest.ts';
17
18
 
18
19
  /** Prefix a base tool name with the configured tool prefix. */
19
20
  function p(baseName: string): string {
@@ -101,6 +102,16 @@ export function getMemoryToolDefinitions(): ToolDefinition[] {
101
102
  required: [],
102
103
  },
103
104
  },
105
+ // P4-007: memory_backfill
106
+ {
107
+ name: p('memory_backfill'),
108
+ description: 'Scan all memory/*.md files and ingest into database. Run after massu init or to recover from DB loss. Parses YAML frontmatter and deduplicates by title.',
109
+ inputSchema: {
110
+ type: 'object',
111
+ properties: {},
112
+ required: [],
113
+ },
114
+ },
104
115
  // P4-006: memory_ingest
105
116
  {
106
117
  name: p('memory_ingest'),
@@ -154,6 +165,8 @@ export function handleMemoryToolCall(
154
165
  return handleFailures(args, memoryDb);
155
166
  case 'memory_ingest':
156
167
  return handleIngest(args, memoryDb);
168
+ case 'memory_backfill':
169
+ return handleBackfill(memoryDb);
157
170
  default:
158
171
  return text(`Unknown memory tool: ${name}`);
159
172
  }
@@ -374,6 +387,26 @@ function handleIngest(args: Record<string, unknown>, db: Database.Database): Too
374
387
  return text(`Observation #${id} recorded successfully.\nType: ${type}\nTitle: ${title}\nImportance: ${importance}\nSession: ${activeSession.session_id.slice(0, 8)}...`);
375
388
  }
376
389
 
390
+ function handleBackfill(db: Database.Database): ToolResult {
391
+ const memoryDir = getResolvedPaths().memoryDir;
392
+ const stats = backfillMemoryFiles(db, memoryDir);
393
+
394
+ const lines = [
395
+ '## Memory Backfill Results',
396
+ '',
397
+ `- **Total files scanned**: ${stats.total}`,
398
+ `- **Inserted (new)**: ${stats.inserted}`,
399
+ `- **Updated (existing)**: ${stats.updated}`,
400
+ `- **Skipped (not found)**: ${stats.skipped}`,
401
+ '',
402
+ stats.total === 0
403
+ ? 'No memory files found in memory directory.'
404
+ : `Successfully processed ${stats.inserted + stats.updated} of ${stats.total} memory files.`,
405
+ ];
406
+
407
+ return text(lines.join('\n'));
408
+ }
409
+
377
410
  // ============================================================
378
411
  // Helpers
379
412
  // ============================================================