pi-hermes-memory 0.7.5 → 0.7.7

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,116 @@
1
+ import * as fs from "node:fs/promises";
2
+ import type { SkillDocument, SkillScope } from "../types.js";
3
+
4
+ export interface ParsedSkillFile {
5
+ meta: Record<string, string>;
6
+ body: string;
7
+ }
8
+
9
+ export function parseFrontmatter(raw: string): ParsedSkillFile {
10
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
11
+ if (!match) return { meta: {}, body: raw.trim() };
12
+
13
+ const meta: Record<string, string> = {};
14
+ for (const line of match[1].split("\n")) {
15
+ const idx = line.indexOf(":");
16
+ if (idx > 0) {
17
+ const key = line.slice(0, idx).trim();
18
+ const value = line.slice(idx + 1).trim();
19
+ meta[key] = value;
20
+ }
21
+ }
22
+
23
+ return { meta, body: match[2].trim() };
24
+ }
25
+
26
+ export function formatFrontmatter(doc: Pick<SkillDocument, "name" | "displayName" | "description" | "version" | "created" | "updated" | "body">): string {
27
+ const lines = [
28
+ "---",
29
+ `name: ${doc.name}`,
30
+ `description: ${doc.description}`,
31
+ `version: ${doc.version}`,
32
+ `created: ${doc.created}`,
33
+ `updated: ${doc.updated}`,
34
+ ];
35
+
36
+ if (doc.displayName && doc.displayName.trim() && doc.displayName.trim() !== doc.name) {
37
+ lines.push(`display_name: ${doc.displayName.trim()}`);
38
+ }
39
+
40
+ lines.push("---", doc.body);
41
+ return lines.join("\n");
42
+ }
43
+
44
+ export function slugify(name: string): string {
45
+ return name
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, "-")
48
+ .replace(/^-|-$/g, "")
49
+ .replace(/--+/g, "-")
50
+ .slice(0, 64);
51
+ }
52
+
53
+ export function today(): string {
54
+ return new Date().toISOString().split("T")[0];
55
+ }
56
+
57
+ const SKILL_SIMILARITY_STOP_WORDS = new Set([
58
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how", "in", "into", "is", "it",
59
+ "of", "on", "or", "that", "the", "this", "to", "use", "using", "with", "workflow", "procedure", "step",
60
+ "steps", "guide", "skill", "skills", "repo", "project",
61
+ ]);
62
+
63
+ export function tokenizeForSimilarity(input: string): string[] {
64
+ return input
65
+ .toLowerCase()
66
+ .replace(/[^a-z0-9]+/g, " ")
67
+ .split(/\s+/)
68
+ .map((token) => token.trim())
69
+ .filter((token) => token.length > 1 && !SKILL_SIMILARITY_STOP_WORDS.has(token));
70
+ }
71
+
72
+ export function jaccardSimilarity(a: string[], b: string[]): number {
73
+ const aSet = new Set(a);
74
+ const bSet = new Set(b);
75
+ if (aSet.size === 0 || bSet.size === 0) return 0;
76
+
77
+ let intersection = 0;
78
+ for (const token of aSet) {
79
+ if (bSet.has(token)) intersection++;
80
+ }
81
+
82
+ const union = new Set([...aSet, ...bSet]).size;
83
+ return union === 0 ? 0 : intersection / union;
84
+ }
85
+
86
+ export function buildSkillId(scope: SkillScope, slug: string, projectName?: string | null): string {
87
+ return scope === "project" ? `project:${projectName ?? ""}:${slug}` : `global:${slug}`;
88
+ }
89
+
90
+ export function parseSkillId(skillId: string): { scope: SkillScope; projectName?: string; slug: string } | null {
91
+ if (skillId.startsWith("global:")) {
92
+ return { scope: "global", slug: skillId.slice("global:".length) };
93
+ }
94
+
95
+ if (skillId.startsWith("project:")) {
96
+ const rest = skillId.slice("project:".length);
97
+ const idx = rest.indexOf(":");
98
+ if (idx <= 0 || idx === rest.length - 1) return null;
99
+ return {
100
+ scope: "project",
101
+ projectName: rest.slice(0, idx),
102
+ slug: rest.slice(idx + 1),
103
+ };
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ export async function exists(filePath: string): Promise<boolean> {
110
+ try {
111
+ await fs.access(filePath);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
@@ -67,6 +67,11 @@ export interface SqliteMemoryRemoveResult {
67
67
  removed: number;
68
68
  }
69
69
 
70
+ export interface SqliteMemoryRemoveOptions {
71
+ target: 'memory' | 'user' | 'failure';
72
+ project?: string | null;
73
+ }
74
+
70
75
  export interface ParsedMarkdownMemoryEntry extends SqliteMemorySyncInput {}
71
76
 
72
77
  function today(): string {
@@ -481,10 +486,7 @@ export function replaceSyncedMemories(
481
486
  export function removeSyncedMemories(
482
487
  dbManager: DatabaseManager,
483
488
  oldText: string,
484
- options: {
485
- target: 'memory' | 'user' | 'failure';
486
- project?: string | null;
487
- },
489
+ options: SqliteMemoryRemoveOptions,
488
490
  ): SqliteMemoryRemoveResult {
489
491
  const db = dbManager.getDb();
490
492
  const params: unknown[] = [];
@@ -512,6 +514,42 @@ export function removeSyncedMemories(
512
514
  };
513
515
  }
514
516
 
517
+ /**
518
+ * Exact removal for Markdown entries whose full content is known.
519
+ * Used for FIFO eviction cleanup, where substring matching could remove
520
+ * unrelated SQLite mirror rows that merely contain the evicted text.
521
+ */
522
+ export function removeExactSyncedMemories(
523
+ dbManager: DatabaseManager,
524
+ content: string,
525
+ options: SqliteMemoryRemoveOptions,
526
+ ): SqliteMemoryRemoveResult {
527
+ const db = dbManager.getDb();
528
+ const params: unknown[] = [];
529
+ const conditions = buildScopeConditions(params, options.target, options.project ?? undefined);
530
+ conditions.push('content = ?');
531
+ params.push(content.trim());
532
+
533
+ const matchingIds = db.prepare(`
534
+ SELECT id
535
+ FROM memories
536
+ WHERE ${conditions.join(' AND ')}
537
+ `).all(...params) as Array<{ id: number }>;
538
+
539
+ if (matchingIds.length === 0) {
540
+ return { matched: 0, removed: 0 };
541
+ }
542
+
543
+ const deleteParams = matchingIds.map((row) => row.id);
544
+ const placeholders = deleteParams.map(() => '?').join(', ');
545
+ const result = db.prepare(`DELETE FROM memories WHERE id IN (${placeholders})`).run(...deleteParams);
546
+
547
+ return {
548
+ matched: matchingIds.length,
549
+ removed: result.changes,
550
+ };
551
+ }
552
+
515
553
  /**
516
554
  * Escape a string for FTS5 query syntax.
517
555
  * Wraps the query in double quotes to treat it as a literal phrase.
@@ -1,6 +1,6 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { StringEnum } from "@mariozechner/pi-ai";
3
+ import { StringEnum } from "@earendil-works/pi-ai";
4
4
  import { DatabaseManager } from '../store/db.js';
5
5
  import { searchMemories, getMemoryStats } from '../store/sqlite-memory-store.js';
6
6
  import type { MemoryCategory } from '../types.js';
@@ -4,13 +4,14 @@
4
4
  * See PLAN.md → "Hermes Source File Reference Map" for source lines.
5
5
  */
6
6
 
7
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
- import { StringEnum } from "@mariozechner/pi-ai";
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
10
  import { MemoryStore } from "../store/memory-store.js";
11
11
  import { DatabaseManager } from "../store/db.js";
12
12
  import {
13
13
  formatFailureMemoryContent,
14
+ removeExactSyncedMemories,
14
15
  removeSyncedMemories,
15
16
  replaceSyncedMemories,
16
17
  syncMemoryEntry,
@@ -159,6 +160,31 @@ async function syncRemoveFromSqlite(
159
160
  }
160
161
  }
161
162
 
163
+ async function syncEvictionsFromSqlite(
164
+ rawTarget: "memory" | "user" | "project" | "failure",
165
+ evictedEntries: string[] | undefined,
166
+ dbManager: DatabaseManager | null,
167
+ projectName?: string | null,
168
+ ): Promise<void> {
169
+ if (!dbManager) return;
170
+ if (!evictedEntries || evictedEntries.length === 0) return;
171
+
172
+ const sqliteTarget = sqliteTargetFor(rawTarget);
173
+ const sqliteProject = sqliteProjectFor(rawTarget, projectName);
174
+
175
+ for (const entry of evictedEntries) {
176
+ try {
177
+ removeExactSyncedMemories(dbManager, entry, {
178
+ target: sqliteTarget,
179
+ project: sqliteProject,
180
+ });
181
+ } catch {
182
+ // FIFO already updated the Markdown source of truth. SQLite is only a
183
+ // best-effort search mirror, so eviction cleanup must not fail the write.
184
+ }
185
+ }
186
+ }
187
+
162
188
  export function registerMemoryTool(
163
189
  pi: ExtensionAPI,
164
190
  store: MemoryStore,
@@ -247,6 +273,7 @@ export function registerMemoryTool(
247
273
  } else {
248
274
  result = await store_.add(target, content);
249
275
  if (result.success) {
276
+ await syncEvictionsFromSqlite(rawTarget, result.evicted_entries, dbManager, projectName);
250
277
  syncWarning = await syncAddToSqlite(rawTarget, content, undefined, undefined, dbManager, projectName);
251
278
  }
252
279
  }
@@ -1,6 +1,6 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { StringEnum } from "@mariozechner/pi-ai";
3
+ import { StringEnum } from "@earendil-works/pi-ai";
4
4
  import { DatabaseManager } from '../store/db.js';
5
5
  import { searchSessions, getIndexedMessageCount } from '../store/session-search.js';
6
6
 
@@ -3,9 +3,9 @@
3
3
  * Complements the `memory` tool (declarative knowledge) with procedural knowledge.
4
4
  */
5
5
 
6
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { Type } from "typebox";
8
- import { StringEnum } from "@mariozechner/pi-ai";
8
+ import { StringEnum } from "@earendil-works/pi-ai";
9
9
  import { SkillStore } from "../store/skill-store.js";
10
10
  import { SKILL_TOOL_DESCRIPTION } from "../constants.js";
11
11
 
@@ -17,7 +17,8 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
17
17
  promptSnippet: "Save or manage reusable procedures and patterns",
18
18
  promptGuidelines: [
19
19
  "Use the skill tool after completing complex tasks that required trial and error or multiple tool calls.",
20
- "Use 'create' to save a new reusable procedure, 'patch' to update a section of an existing skill.",
20
+ "Use 'create' to save a new reusable procedure, 'patch' to update a section of an existing skill by skill_id.",
21
+ "Choose scope='global' for transferable procedures and scope='project' when the workflow depends on this repo's paths, scripts, conventions, or deploy steps.",
21
22
  "Do NOT use skills for temporary task state — only for durable, reusable procedures.",
22
23
  ],
23
24
  parameters: Type.Object({
@@ -25,12 +26,15 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
25
26
  name: Type.Optional(
26
27
  Type.String({ description: "Skill name (for create). e.g., 'debug-typescript-errors'" })
27
28
  ),
28
- file_name: Type.Optional(
29
- Type.String({ description: "Skill file name (for view/patch/edit/delete). e.g., 'debug-typescript-errors.md'" })
29
+ skill_id: Type.Optional(
30
+ Type.String({ description: "Stable skill id for view/patch/edit/delete. e.g., 'global:debug-typescript-errors' or 'project:my-repo:release-app'" })
30
31
  ),
31
32
  description: Type.Optional(
32
33
  Type.String({ description: "One-line description of when to use this skill (for create/edit)" })
33
34
  ),
35
+ scope: Type.Optional(
36
+ StringEnum(["global", "project"] as const, { description: "Optional creation scope. Omit to let the extension classify it automatically." })
37
+ ),
34
38
  section: Type.Optional(
35
39
  Type.String({ description: "Section header to patch (for patch action). e.g., 'Procedure', 'Pitfalls'" })
36
40
  ),
@@ -39,7 +43,7 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
39
43
  ),
40
44
  }),
41
45
  async execute(toolCallId, params, signal, onUpdate, ctx) {
42
- const { action, name, file_name, description, section, content } = params;
46
+ const { action, name, skill_id, description, scope, section, content } = params;
43
47
 
44
48
  let result;
45
49
  switch (action) {
@@ -62,22 +66,21 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
62
66
  details: {},
63
67
  };
64
68
  }
65
- result = await store.create(name, description, content);
69
+ result = await store.create(name, description, content, scope);
66
70
  break;
67
71
 
68
72
  case "view":
69
- if (!file_name) {
70
- // List all skills
73
+ if (!skill_id) {
71
74
  const index = await store.loadIndex();
72
75
  return {
73
76
  content: [{ type: "text", text: JSON.stringify({ success: true, skills: index }) }],
74
77
  details: { skills: index },
75
78
  };
76
79
  }
77
- const doc = await store.loadSkill(file_name);
80
+ const doc = await store.loadSkill(skill_id);
78
81
  if (!doc) {
79
82
  return {
80
- content: [{ type: "text", text: JSON.stringify({ success: false, error: `Skill '${file_name}' not found.` }) }],
83
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: `Skill '${skill_id}' not found.` }) }],
81
84
  details: {},
82
85
  };
83
86
  }
@@ -85,9 +88,9 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
85
88
  break;
86
89
 
87
90
  case "patch":
88
- if (!file_name) {
91
+ if (!skill_id) {
89
92
  return {
90
- content: [{ type: "text", text: JSON.stringify({ success: false, error: "file_name is required for 'patch' action." }) }],
93
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "skill_id is required for 'patch' action." }) }],
91
94
  details: {},
92
95
  };
93
96
  }
@@ -103,27 +106,27 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
103
106
  details: {},
104
107
  };
105
108
  }
106
- result = await store.patch(file_name, section, content);
109
+ result = await store.patch(skill_id, section, content);
107
110
  break;
108
111
 
109
112
  case "edit":
110
- if (!file_name) {
113
+ if (!skill_id) {
111
114
  return {
112
- content: [{ type: "text", text: JSON.stringify({ success: false, error: "file_name is required for 'edit' action." }) }],
115
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "skill_id is required for 'edit' action." }) }],
113
116
  details: {},
114
117
  };
115
118
  }
116
- result = await store.edit(file_name, description || "", content || "");
119
+ result = await store.edit(skill_id, description || "", content || "");
117
120
  break;
118
121
 
119
122
  case "delete":
120
- if (!file_name) {
123
+ if (!skill_id) {
121
124
  return {
122
- content: [{ type: "text", text: JSON.stringify({ success: false, error: "file_name is required for 'delete' action." }) }],
125
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "skill_id is required for 'delete' action." }) }],
123
126
  details: {},
124
127
  };
125
128
  }
126
- result = await store.delete(file_name);
129
+ result = await store.delete(skill_id);
127
130
  break;
128
131
 
129
132
  default:
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Shared TypeScript types for the Hermes Memory extension.
3
3
  */
4
4
 
5
- import type { TextContent } from "@mariozechner/pi-ai";
5
+ import type { TextContent } from "@earendil-works/pi-ai";
6
6
 
7
7
  export type MemoryOverflowStrategy = "auto-consolidate" | "reject" | "fifo-evict";
8
8
 
@@ -59,6 +59,8 @@ export interface MemoryConfig {
59
59
  failureInjectionMaxEntries: number;
60
60
  /** Tool calls before triggering background review (in addition to turn count). Default: 15 */
61
61
  nudgeToolCalls: number;
62
+ /** Maximum time in milliseconds for auto-consolidation to complete. Default: 60000 */
63
+ consolidationTimeoutMs: number;
62
64
  /** Enable session history search via SQLite FTS5. Default: true */
63
65
  sessionSearchEnabled?: boolean;
64
66
  /** Days to retain session history. Default: 90 */
@@ -100,12 +102,24 @@ export interface ConsolidationResult {
100
102
  error?: string;
101
103
  }
102
104
 
105
+ export type SkillScope = "global" | "project";
106
+
103
107
  export interface SkillIndex {
104
- /** File name (slug.md) */
108
+ /** Stable id for read/update/delete operations */
109
+ skillId: string;
110
+ /** Whether the skill is global or project-scoped */
111
+ scope: SkillScope;
112
+ /** File name on disk (usually SKILL.md) */
105
113
  fileName: string;
106
- /** Human-readable name */
114
+ /** Absolute path to the skill file */
115
+ path: string;
116
+ /** Active project name for project-scoped skills */
117
+ projectName?: string;
118
+ /** Pi skill slug stored in frontmatter and folder name */
107
119
  name: string;
108
- /** Short description for system prompt index */
120
+ /** Optional human-friendly title preserved for UI output */
121
+ displayName?: string;
122
+ /** Short description shown in skill listings */
109
123
  description: string;
110
124
  }
111
125
 
@@ -125,6 +139,12 @@ export interface SkillResult {
125
139
  error?: string;
126
140
  message?: string;
127
141
  fileName?: string;
142
+ skillId?: string;
143
+ scope?: SkillScope;
144
+ path?: string;
145
+ conflictType?: "duplicate" | "similar" | "name-collision";
146
+ similarSkillIds?: string[];
147
+ suggestedAction?: "patch" | "edit" | "rename";
128
148
  }
129
149
 
130
150
  /**