pi-hermes-memory 0.1.0 → 0.2.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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Auto-consolidation — when memory hits capacity, trigger automatic
3
+ * consolidation instead of returning an error.
4
+ *
5
+ * Uses pi.exec() to spawn a one-shot consolidation process.
6
+ * The child process modifies files on disk, so the parent MUST reload
7
+ * from disk after consolidation completes.
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+ import { MemoryStore } from "../store/memory-store.js";
12
+ import { CONSOLIDATION_PROMPT, ENTRY_DELIMITER } from "../constants.js";
13
+ import type { ConsolidationResult } from "../types.js";
14
+
15
+ export async function triggerConsolidation(
16
+ pi: ExtensionAPI,
17
+ store: MemoryStore,
18
+ target: "memory" | "user",
19
+ signal?: AbortSignal,
20
+ ): Promise<ConsolidationResult> {
21
+ const entries =
22
+ target === "memory" ? store.getMemoryEntries() : store.getUserEntries();
23
+ const currentContent = entries.join(ENTRY_DELIMITER);
24
+
25
+ const prompt = [
26
+ CONSOLIDATION_PROMPT,
27
+ "",
28
+ `--- Current ${target === "user" ? "User Profile" : "Memory"} Entries ---`,
29
+ currentContent || "(empty)",
30
+ "",
31
+ `Use the memory tool to consolidate. Target: '${target}'`,
32
+ ].join("\n");
33
+
34
+ try {
35
+ const result = await pi.exec("pi", ["-p", "--no-session", prompt], {
36
+ signal,
37
+ timeout: 60000,
38
+ });
39
+
40
+ if (result.code === 0) {
41
+ return { consolidated: true };
42
+ }
43
+ return {
44
+ consolidated: false,
45
+ error: `Consolidation process exited with code ${result.code}: ${result.stderr?.slice(0, 200) || "unknown error"}`,
46
+ };
47
+ } catch (err) {
48
+ return {
49
+ consolidated: false,
50
+ error: `Consolidation failed: ${String(err).slice(0, 200)}`,
51
+ };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Register the /memory-consolidate command for manual consolidation.
57
+ */
58
+ export function registerConsolidateCommand(
59
+ pi: ExtensionAPI,
60
+ store: MemoryStore,
61
+ ): void {
62
+ pi.registerCommand("memory-consolidate", {
63
+ description: "Manually trigger memory consolidation to free up space",
64
+ handler: async (_args, ctx) => {
65
+ const results: string[] = [];
66
+
67
+ for (const target of ["memory", "user"] as const) {
68
+ const entries =
69
+ target === "memory"
70
+ ? store.getMemoryEntries()
71
+ : store.getUserEntries();
72
+
73
+ if (entries.length === 0) {
74
+ results.push(`${target}: (empty, nothing to consolidate)`);
75
+ continue;
76
+ }
77
+
78
+ const result = await triggerConsolidation(pi, store, target, ctx.signal);
79
+
80
+ if (result.consolidated) {
81
+ await store.loadFromDisk();
82
+ results.push(`${target}: ✅ consolidated`);
83
+ } else {
84
+ results.push(`${target}: ❌ ${result.error}`);
85
+ }
86
+ }
87
+
88
+ ctx.ui.notify(
89
+ `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`,
90
+ "info",
91
+ );
92
+ },
93
+ });
94
+ }
@@ -19,6 +19,7 @@ export function setupBackgroundReview(
19
19
  config: MemoryConfig,
20
20
  ): void {
21
21
  let turnsSinceReview = 0;
22
+ let toolCallsSinceReview = 0;
22
23
  let userTurnCount = 0;
23
24
  let reviewInProgress = false;
24
25
 
@@ -33,10 +34,35 @@ export function setupBackgroundReview(
33
34
 
34
35
  if (!config.reviewEnabled) return;
35
36
  if (reviewInProgress) return;
36
- if (turnsSinceReview < config.nudgeInterval) return;
37
+
38
+ // Count tool-use entries from the branch for tool-call-aware nudge
39
+ try {
40
+ const branch = ctx.sessionManager.getBranch();
41
+ for (const entry of branch) {
42
+ if (entry.type === "message" && entry.message?.role === "assistant") {
43
+ const content = entry.message?.content;
44
+ if (Array.isArray(content)) {
45
+ for (const block of content) {
46
+ if (block && typeof block === "object" && block.type === "toolCall") {
47
+ toolCallsSinceReview++;
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ } catch {
54
+ // If we can't count tool calls, fall back to turn-based only
55
+ }
56
+
57
+ // Trigger on EITHER turn count OR tool call count
58
+ const turnThresholdMet = turnsSinceReview >= config.nudgeInterval;
59
+ const toolCallThresholdMet = toolCallsSinceReview >= config.nudgeToolCalls;
60
+
61
+ if (!turnThresholdMet && !toolCallThresholdMet) return;
37
62
  if (userTurnCount < 3) return;
38
63
 
39
64
  turnsSinceReview = 0;
65
+ toolCallsSinceReview = 0;
40
66
  reviewInProgress = true;
41
67
 
42
68
  try {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Correction detection — detects user corrections in real-time and triggers
3
+ * an immediate memory save instead of waiting for the next nudge interval.
4
+ *
5
+ * Uses a two-pass filter:
6
+ * - Strong patterns: always trigger (high confidence)
7
+ * - Weak patterns: only trigger if followed by a directive clause
8
+ * - Negative patterns: suppress even if a positive pattern matched
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import { MemoryStore } from "../store/memory-store.js";
13
+ import {
14
+ CORRECTION_SAVE_PROMPT,
15
+ CORRECTION_STRONG_PATTERNS,
16
+ CORRECTION_WEAK_PATTERNS,
17
+ CORRECTION_NEGATIVE_PATTERNS,
18
+ ENTRY_DELIMITER,
19
+ } from "../constants.js";
20
+ import type { MemoryConfig } from "../types.js";
21
+ import { getMessageText } from "../types.js";
22
+
23
+ /**
24
+ * Check if a user message is a correction using the two-pass filter.
25
+ * Returns true if the message should trigger an immediate save.
26
+ */
27
+ export function isCorrection(text: string): boolean {
28
+ // Check negative patterns first — suppress even if positive matches
29
+ for (const pattern of CORRECTION_NEGATIVE_PATTERNS) {
30
+ if (pattern.test(text)) return false;
31
+ }
32
+
33
+ // Check strong patterns — always trigger
34
+ for (const pattern of CORRECTION_STRONG_PATTERNS) {
35
+ if (pattern.test(text)) return true;
36
+ }
37
+
38
+ // Check weak patterns — only trigger if followed by a directive clause
39
+ for (const pattern of CORRECTION_WEAK_PATTERNS) {
40
+ if (pattern.test(text)) {
41
+ // Look for a directive after the weak pattern match
42
+ // Directive = a verb or "the/that/this" in the remainder of the text
43
+ const match = pattern.exec(text);
44
+ if (match && match.index === 0) {
45
+ const remainder = text.slice(match[0].length).trim();
46
+ // Simple heuristic: remainder contains something directive-ish
47
+ if (/\b(use|don'?t|do|try|make|run|install|add|remove|delete|change|fix|put|set|write|go|stop|start|the|that|this|it)\b/i.test(remainder)) {
48
+ return true;
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ return false;
55
+ }
56
+
57
+ export function setupCorrectionDetector(
58
+ pi: ExtensionAPI,
59
+ store: MemoryStore,
60
+ config: MemoryConfig,
61
+ ): void {
62
+ if (!config.correctionDetection) return;
63
+
64
+ let pendingCorrection = false;
65
+ let turnsSinceLastCorrection = 3; // Start at threshold so first correction can fire immediately
66
+ let correctionInProgress = false;
67
+
68
+ // Flag on message_end (user role)
69
+ pi.on("message_end", async (event, _ctx) => {
70
+ if (event.message.role !== "user") return;
71
+ const text = getMessageText(event.message);
72
+ if (!text) return;
73
+ if (isCorrection(text)) {
74
+ pendingCorrection = true;
75
+ }
76
+ });
77
+
78
+ // Trigger on turn_end (we need full context: user correction + what agent said)
79
+ pi.on("turn_end", async (event, ctx) => {
80
+ if (!pendingCorrection) {
81
+ turnsSinceLastCorrection++;
82
+ return;
83
+ }
84
+ pendingCorrection = false;
85
+
86
+ // Rate limit: max 1 correction save per 3 turns
87
+ if (turnsSinceLastCorrection < 3) return;
88
+ if (correctionInProgress) return;
89
+
90
+ turnsSinceLastCorrection = 0;
91
+ correctionInProgress = true;
92
+
93
+ try {
94
+ // Build conversation snapshot
95
+ const entries = ctx.sessionManager.getBranch();
96
+ const parts: string[] = [];
97
+
98
+ for (const entry of entries) {
99
+ if (entry.type !== "message") continue;
100
+ const msg = entry.message;
101
+ const text = getMessageText(msg);
102
+ if (!text) continue;
103
+ const prefix = msg.role === "user" ? "[USER]" : "[ASSISTANT]";
104
+ parts.push(`${prefix}: ${text}`);
105
+ }
106
+
107
+ // Only include last few exchanges (correction context is recent)
108
+ const recentParts = parts.slice(-6);
109
+
110
+ const currentMemory = store.getMemoryEntries().join(ENTRY_DELIMITER);
111
+ const currentUser = store.getUserEntries().join(ENTRY_DELIMITER);
112
+
113
+ const prompt = [
114
+ CORRECTION_SAVE_PROMPT,
115
+ "",
116
+ "--- Current Memory ---",
117
+ currentMemory || "(empty)",
118
+ "",
119
+ "--- Current User Profile ---",
120
+ currentUser || "(empty)",
121
+ "",
122
+ "--- Recent Conversation ---",
123
+ recentParts.join("\n\n"),
124
+ ].join("\n");
125
+
126
+ const result = await pi.exec("pi", ["-p", "--no-session", prompt], {
127
+ signal: ctx.signal,
128
+ timeout: 30000,
129
+ });
130
+
131
+ if (result.code === 0 && result.stdout) {
132
+ const output = result.stdout.trim();
133
+ if (output && !output.toLowerCase().includes("nothing to save")) {
134
+ ctx.ui.notify("🔧 Correction detected — memory updated", "info");
135
+ }
136
+ }
137
+ } catch {
138
+ // Best-effort — don't block the session
139
+ } finally {
140
+ correctionInProgress = false;
141
+ }
142
+ });
143
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Skill auto-trigger — after complex tasks (8+ tool calls, 2+ distinct tool types),
3
+ * trigger automatic skill extraction via pi.exec().
4
+ *
5
+ * This implements Hermes' "self-evaluation checkpoint" pattern.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { MemoryStore } from "../store/memory-store.js";
10
+ import { SkillStore } from "../store/skill-store.js";
11
+ import { COMBINED_REVIEW_PROMPT, DEFAULT_SKILL_TRIGGER_TOOL_CALLS, ENTRY_DELIMITER } from "../constants.js";
12
+ import type { MemoryConfig } from "../types.js";
13
+ import { getMessageText } from "../types.js";
14
+
15
+ export function setupSkillAutoTrigger(
16
+ pi: ExtensionAPI,
17
+ store: MemoryStore,
18
+ skillStore: SkillStore,
19
+ config: MemoryConfig,
20
+ ): void {
21
+ let triggeredThisSession = false;
22
+
23
+ pi.on("turn_end", async (event, ctx) => {
24
+ if (triggeredThisSession) return;
25
+
26
+ // Count tool-use entries from this turn's branch
27
+ let toolCallCount = 0;
28
+ const toolTypes = new Set<string>();
29
+
30
+ try {
31
+ const branch = ctx.sessionManager.getBranch();
32
+ for (const entry of branch) {
33
+ if (entry.type === "message" && entry.message?.role === "assistant") {
34
+ const content = entry.message?.content;
35
+ if (Array.isArray(content)) {
36
+ for (const block of content) {
37
+ if (block && typeof block === "object" && block.type === "toolCall") {
38
+ toolCallCount++;
39
+ if ((block as { name?: string }).name) toolTypes.add((block as { name: string }).name);
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ } catch {
46
+ return;
47
+ }
48
+
49
+ // Require 8+ tool calls AND 2+ distinct tool types
50
+ if (toolCallCount < DEFAULT_SKILL_TRIGGER_TOOL_CALLS) return;
51
+ if (toolTypes.size < 2) return;
52
+
53
+ triggeredThisSession = true;
54
+
55
+ try {
56
+ // Build conversation context
57
+ const branch = ctx.sessionManager.getBranch();
58
+ const parts: string[] = [];
59
+
60
+ for (const entry of branch) {
61
+ if (entry.type !== "message") continue;
62
+ const msg = entry.message;
63
+ const text = getMessageText(msg);
64
+ if (!text) continue;
65
+ const prefix = msg.role === "user" ? "[USER]" : "[ASSISTANT]";
66
+ parts.push(`${prefix}: ${text}`);
67
+ }
68
+
69
+ // Only include recent context
70
+ const recentParts = parts.slice(-10);
71
+
72
+ const currentMemory = store.getMemoryEntries().join(ENTRY_DELIMITER);
73
+ const skillIndex = await skillStore.loadIndex();
74
+ const skillSummary = skillIndex.map((s) => `${s.fileName}: ${s.name} - ${s.description}`).join("\n");
75
+
76
+ const prompt = [
77
+ "This was a complex task that required multiple tool calls. Extract any reusable procedures as skills.",
78
+ "",
79
+ "--- Existing Skills ---",
80
+ skillSummary || "(none)",
81
+ "",
82
+ "--- Current Memory ---",
83
+ currentMemory || "(empty)",
84
+ "",
85
+ "--- Recent Conversation ---",
86
+ recentParts.join("\n\n"),
87
+ "",
88
+ "If a skill should be created, use the skill tool with action 'create'.",
89
+ "If a related skill already exists, use 'patch' to update it.",
90
+ "If nothing reusable happened, say 'Nothing to extract.' and stop.",
91
+ ].join("\n");
92
+
93
+ const result = await pi.exec("pi", ["-p", "--no-session", prompt], {
94
+ signal: ctx.signal,
95
+ timeout: 60000,
96
+ });
97
+
98
+ if (result.code === 0 && result.stdout) {
99
+ const output = result.stdout.trim();
100
+ if (output && !output.toLowerCase().includes("nothing to extract")) {
101
+ ctx.ui.notify("🧠 Complex task detected — skill extracted", "info");
102
+ }
103
+ }
104
+ } catch {
105
+ // Best-effort — don't block
106
+ }
107
+ });
108
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Skills command — /memory-skills lists all agent-created skills.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import { SkillStore } from "../store/skill-store.js";
7
+
8
+ export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void {
9
+ pi.registerCommand("memory-skills", {
10
+ description: "List all agent-created skills (procedural memory)",
11
+ handler: async (_args, ctx) => {
12
+ const skills = await store.loadIndex();
13
+
14
+ const lines: string[] = [];
15
+ lines.push("");
16
+ lines.push(" ╔══════════════════════════════════════════════╗");
17
+ lines.push(" ║ 🧠 Procedural Skills ║");
18
+ lines.push(" ╚══════════════════════════════════════════════╝");
19
+ lines.push("");
20
+
21
+ if (skills.length === 0) {
22
+ lines.push(" (no skills created yet)");
23
+ lines.push("");
24
+ lines.push(" Skills are auto-created after complex tasks,");
25
+ lines.push(" or you can ask the agent to create one.");
26
+ } else {
27
+ for (const skill of skills) {
28
+ lines.push(` 📄 ${skill.name}`);
29
+ lines.push(` ${skill.description}`);
30
+ lines.push(` file: ${skill.fileName}`);
31
+ lines.push("");
32
+ }
33
+ }
34
+
35
+ ctx.ui.notify(lines.join("\n"), "info");
36
+ },
37
+ });
38
+ }
package/src/index.ts CHANGED
@@ -7,35 +7,57 @@
7
7
  * 1. Persistent Memory — MEMORY.md + USER.md that survive across sessions
8
8
  * 2. Background Learning Loop — auto-saves notable facts every N turns
9
9
  * 3. Session-End Flush — saves memories before compaction/shutdown
10
- * 4. /memory-insightsshows what's stored
10
+ * 4. Auto-Consolidationmerges memory when full instead of erroring
11
+ * 5. Correction Detection — immediate save on user corrections
12
+ * 6. Procedural Skills — SKILL.md files for reusable procedures
13
+ * 7. Tool-Call-Aware Nudge — review triggers on tool call count too
14
+ * 8. /memory-insights — shows what's stored
15
+ * 9. /memory-skills — lists procedural skills
16
+ * 10. /memory-consolidate — manual consolidation trigger
11
17
  *
12
- * See PLAN.md for full architecture and Hermes source references.
18
+ * See docs/ROADMAP.md for full roadmap and Hermes competitive analysis.
13
19
  */
14
20
 
21
+ import * as path from "node:path";
22
+ import * as os from "node:os";
15
23
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
24
  import { MemoryStore } from "./store/memory-store.js";
25
+ import { SkillStore } from "./store/skill-store.js";
17
26
  import { registerMemoryTool } from "./tools/memory-tool.js";
27
+ import { registerSkillTool } from "./tools/skill-tool.js";
18
28
  import { setupBackgroundReview } from "./handlers/background-review.js";
19
29
  import { setupSessionFlush } from "./handlers/session-flush.js";
20
30
  import { registerInsightsCommand } from "./handlers/insights.js";
31
+ import { triggerConsolidation, registerConsolidateCommand } from "./handlers/auto-consolidate.js";
32
+ import { setupCorrectionDetector } from "./handlers/correction-detector.js";
33
+ import { setupSkillAutoTrigger } from "./handlers/skill-auto-trigger.js";
34
+ import { registerSkillsCommand } from "./handlers/skills-command.js";
21
35
  import { loadConfig } from "./config.js";
22
36
 
23
37
  export default function (pi: ExtensionAPI) {
24
38
  const config = loadConfig();
25
39
 
40
+ const memoryDir = config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
26
41
  const store = new MemoryStore(config);
42
+ const skillStore = new SkillStore(path.join(memoryDir, "skills"));
27
43
 
28
44
  // ── 1. Load memory from disk on session start ──
29
45
  pi.on("session_start", async (_event, _ctx) => {
30
46
  await store.loadFromDisk();
31
47
  });
32
48
 
33
- // ── 2. Inject frozen snapshot into system prompt ──
49
+ // ── 2. Inject frozen snapshot + skill index into system prompt ──
34
50
  pi.on("before_agent_start", async (event, _ctx) => {
35
51
  const memoryBlock = store.formatForSystemPrompt();
36
- if (memoryBlock) {
52
+ const skillIndex = await skillStore.formatIndexForSystemPrompt();
53
+
54
+ const parts: string[] = [];
55
+ if (memoryBlock) parts.push(memoryBlock);
56
+ if (skillIndex) parts.push(skillIndex);
57
+
58
+ if (parts.length > 0) {
37
59
  return {
38
- systemPrompt: event.systemPrompt + "\n\n" + memoryBlock,
60
+ systemPrompt: event.systemPrompt + "\n\n" + parts.join("\n\n"),
39
61
  };
40
62
  }
41
63
  });
@@ -43,12 +65,28 @@ export default function (pi: ExtensionAPI) {
43
65
  // ── 3. Register the memory tool ──
44
66
  registerMemoryTool(pi, store);
45
67
 
46
- // ── 4. Setup background learning loop ──
68
+ // ── 4. Register the skill tool ──
69
+ registerSkillTool(pi, skillStore);
70
+
71
+ // ── 5. Setup background learning loop (with tool-call-aware nudge) ──
47
72
  setupBackgroundReview(pi, store, config);
48
73
 
49
- // ── 5. Setup session-end flush ──
74
+ // ── 6. Setup session-end flush ──
50
75
  setupSessionFlush(pi, store, config);
51
76
 
52
- // ── 6. Register insights command ──
77
+ // ── 7. Setup auto-consolidation (inject consolidator into store) ──
78
+ store.setConsolidator(async (target, signal) => {
79
+ return triggerConsolidation(pi, store, target, signal);
80
+ });
81
+ registerConsolidateCommand(pi, store);
82
+
83
+ // ── 8. Setup correction detection ──
84
+ setupCorrectionDetector(pi, store, config);
85
+
86
+ // ── 9. Setup skill auto-trigger ──
87
+ setupSkillAutoTrigger(pi, store, skillStore, config);
88
+
89
+ // ── 10. Register commands ──
53
90
  registerInsightsCommand(pi, store);
91
+ registerSkillsCommand(pi, skillStore);
54
92
  }
@@ -22,15 +22,24 @@ import {
22
22
  MEMORY_FILE,
23
23
  USER_FILE,
24
24
  } from "../constants.js";
25
- import type { MemoryConfig, MemoryResult, MemorySnapshot } from "../types.js";
25
+ import type { MemoryConfig, MemoryResult, MemorySnapshot, ConsolidationResult } from "../types.js";
26
26
 
27
27
  export class MemoryStore {
28
28
  private memoryEntries: string[] = [];
29
29
  private userEntries: string[] = [];
30
30
  private snapshot: MemorySnapshot = { memory: "", user: "" };
31
+ private consolidator: ((target: "memory" | "user", signal?: AbortSignal) => Promise<ConsolidationResult>) | null = null;
31
32
 
32
33
  constructor(private config: MemoryConfig) {}
33
34
 
35
+ /**
36
+ * Inject a consolidation function (avoids circular imports).
37
+ * Called from index.ts after both store and pi are available.
38
+ */
39
+ setConsolidator(fn: (target: "memory" | "user", signal?: AbortSignal) => Promise<ConsolidationResult>): void {
40
+ this.consolidator = fn;
41
+ }
42
+
34
43
  // ─── Path helpers ───
35
44
 
36
45
  private get memoryDir(): string {
@@ -79,7 +88,7 @@ export class MemoryStore {
79
88
 
80
89
  // ─── CRUD ───
81
90
 
82
- add(target: "memory" | "user", content: string): MemoryResult {
91
+ async add(target: "memory" | "user", content: string, signal?: AbortSignal): Promise<MemoryResult> {
83
92
  content = content.trim();
84
93
  if (!content) return { success: false, error: "Content cannot be empty." };
85
94
 
@@ -95,6 +104,20 @@ export class MemoryStore {
95
104
 
96
105
  const newTotal = [...entries, content].join(ENTRY_DELIMITER).length;
97
106
  if (newTotal > limit) {
107
+ // Auto-consolidate if configured and consolidator available
108
+ if (this.config.autoConsolidate && this.consolidator) {
109
+ try {
110
+ const result = await this.consolidator(target, signal);
111
+ if (result.consolidated) {
112
+ // CRITICAL: reload from disk — child process modified files, our arrays are stale
113
+ await this.loadFromDisk();
114
+ // Retry the add with fresh data
115
+ return this.add(target, content, signal);
116
+ }
117
+ } catch {
118
+ // Consolidation failed — fall through to error
119
+ }
120
+ }
98
121
  const current = this.charCount(target);
99
122
  return {
100
123
  success: false,