pi-hermes-memory 0.8.1 → 0.9.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.
@@ -1,5 +1,11 @@
1
1
  import { DatabaseManager } from './db.js';
2
- import { buildFallbackFts5Query, isFts5QueryError, normalizeFts5Query } from './fts-query.js';
2
+ import {
3
+ buildFallbackFts5Query,
4
+ buildNaturalLanguageFallbackQuery,
5
+ isFts5QueryError,
6
+ normalizeFts5Query,
7
+ normalizeNaturalLanguageFts5Query,
8
+ } from './fts-query.js';
3
9
  import { normalizeMemoryLookupText } from './memory-lookup.js';
4
10
  import type { MemoryCategory } from '../types.js';
5
11
 
@@ -697,6 +703,8 @@ export function searchMemories(
697
703
  return [];
698
704
  }
699
705
 
706
+ let ftsParseError = false;
707
+
700
708
  const runSearch = (matchQuery: string): SqliteMemoryEntry[] => {
701
709
  const conditions: string[] = [];
702
710
  const params: unknown[] = [];
@@ -750,6 +758,7 @@ export function searchMemories(
750
758
  return rows.map(mapRow);
751
759
  } catch (err) {
752
760
  if (isFts5QueryError(err)) {
761
+ ftsParseError = true;
753
762
  return [];
754
763
  }
755
764
  throw err;
@@ -761,6 +770,26 @@ export function searchMemories(
761
770
  return exactResults;
762
771
  }
763
772
 
773
+ // A query with uppercase operator words (e.g. "DO NOT USE FIND /") passes
774
+ // through as raw FTS5 syntax; when that fails to parse, retry it as natural
775
+ // language instead of silently returning nothing. Valid operator queries
776
+ // that legitimately match nothing keep their exact semantics.
777
+ if (ftsParseError) {
778
+ const nlQuery = normalizeNaturalLanguageFts5Query(query);
779
+ if (nlQuery.length === 0 || nlQuery === normalizedQuery) {
780
+ return [];
781
+ }
782
+ const nlResults = runSearch(nlQuery);
783
+ if (nlResults.length > 0) {
784
+ return nlResults;
785
+ }
786
+ const nlFallback = buildNaturalLanguageFallbackQuery(query);
787
+ if (nlFallback && nlFallback !== nlQuery) {
788
+ return runSearch(nlFallback);
789
+ }
790
+ return nlResults;
791
+ }
792
+
764
793
  const fallbackQuery = buildFallbackFts5Query(query);
765
794
  if (!fallbackQuery || fallbackQuery === normalizedQuery) {
766
795
  return exactResults;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Shared better-sqlite3 loader with ABI mismatch recovery.
3
+ *
4
+ * Pi installs extension deps under ~/.pi/agent/npm. When the host Node that
5
+ * runs Pi (e.g. Homebrew) differs from the Node that compiled better-sqlite3,
6
+ * require() throws NODE_MODULE_VERSION errors. Detect that, attempt one
7
+ * npm rebuild against the current runtime, and surface a clear recovery path.
8
+ */
9
+
10
+ import { createRequire } from "node:module";
11
+ import { spawnSync } from "node:child_process";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ export type BetterSqlite3DatabaseCtor = new (
16
+ dbPath: string,
17
+ options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number },
18
+ ) => unknown;
19
+
20
+ export interface SqliteNativeLoadOptions {
21
+ /** Override createRequire base URL (tests). */
22
+ requireFrom?: string | URL;
23
+ /** Inject require() for tests. */
24
+ requireImpl?: NodeRequire;
25
+ /** Inject rebuild runner for tests. */
26
+ rebuild?: (packageRoot: string) => { ok: boolean; detail: string };
27
+ /** Force treating the first load failure as rebuild-eligible (tests). */
28
+ allowRebuild?: boolean;
29
+ }
30
+
31
+ export class BetterSqlite3LoadError extends Error {
32
+ readonly code = "BETTER_SQLITE3_LOAD_FAILED";
33
+ readonly packageRoot: string | null;
34
+ readonly causeError: unknown;
35
+
36
+ constructor(message: string, options: { packageRoot?: string | null; cause?: unknown } = {}) {
37
+ super(message);
38
+ this.name = "BetterSqlite3LoadError";
39
+ this.packageRoot = options.packageRoot ?? null;
40
+ this.causeError = options.cause;
41
+ }
42
+ }
43
+
44
+ const ABI_MISMATCH_RE =
45
+ /NODE_MODULE_VERSION|was compiled against a different Node\.js version|ERR_DLOPEN_FAILED/i;
46
+
47
+ /**
48
+ * True when running under Bun (including the compiled Pi binary).
49
+ *
50
+ * Compiled Pi cannot resolve `better-sqlite3` — neither the bare specifier nor
51
+ * the package's own transitive `require("bindings")` resolve from an on-disk
52
+ * extension file, so every code path that needs SQLite must route through
53
+ * `bun:sqlite` instead of loading the native module.
54
+ */
55
+ export function isBunRuntime(): boolean {
56
+ return "Bun" in globalThis;
57
+ }
58
+
59
+ export function isNativeModuleAbiMismatch(error: unknown): boolean {
60
+ if (!error) return false;
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ if (ABI_MISMATCH_RE.test(message)) return true;
63
+ if (typeof error === "object" && error !== null) {
64
+ const code = "code" in error ? String((error as { code?: unknown }).code ?? "") : "";
65
+ if (code === "ERR_DLOPEN_FAILED") return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ export function resolveBetterSqlite3PackageRoot(requireImpl: NodeRequire): string | null {
71
+ try {
72
+ const entry = requireImpl.resolve("better-sqlite3");
73
+ let dir = path.dirname(entry);
74
+ while (true) {
75
+ const pkgPath = path.join(dir, "package.json");
76
+ if (fs.existsSync(pkgPath)) {
77
+ try {
78
+ const raw = fs.readFileSync(pkgPath, "utf-8");
79
+ const pkg = JSON.parse(raw) as { name?: unknown };
80
+ if (pkg && typeof pkg === "object" && pkg.name === "better-sqlite3") return dir;
81
+ } catch {
82
+ // keep walking
83
+ }
84
+ }
85
+ const parent = path.dirname(dir);
86
+ if (parent === dir) break;
87
+ dir = parent;
88
+ }
89
+
90
+ // Classic layout fallback: .../node_modules/better-sqlite3/<...>
91
+ const parts = entry.split(path.sep);
92
+ const idx = parts.lastIndexOf("better-sqlite3");
93
+ if (idx >= 0) {
94
+ return parts.slice(0, idx + 1).join(path.sep) || null;
95
+ }
96
+ return path.dirname(entry);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+ function defaultRebuild(packageRoot: string): { ok: boolean; detail: string } {
102
+ const npmExecPath = typeof process.env.npm_execpath === "string" && process.env.npm_execpath
103
+ ? process.env.npm_execpath
104
+ : null;
105
+
106
+ const attempts: Array<{ command: string; args: string[]; shell?: boolean }> = [];
107
+ if (npmExecPath) {
108
+ attempts.push({ command: process.execPath, args: [npmExecPath, "rebuild", "better-sqlite3"] });
109
+ }
110
+ attempts.push({ command: "npm", args: ["rebuild", "better-sqlite3"] });
111
+
112
+ const details: string[] = [];
113
+ for (const attempt of attempts) {
114
+ const result = spawnSync(attempt.command, attempt.args, {
115
+ cwd: packageRoot,
116
+ encoding: "utf-8",
117
+ env: process.env,
118
+ timeout: 120_000,
119
+ shell: attempt.shell ?? false,
120
+ });
121
+ if (result.error) {
122
+ details.push(`${attempt.command}: ${result.error.message}`);
123
+ continue;
124
+ }
125
+ if (result.status === 0) {
126
+ return { ok: true, detail: (result.stdout || result.stderr || "rebuild ok").trim() };
127
+ }
128
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
129
+ details.push(`${attempt.command} exited ${result.status}${output ? `: ${output}` : ""}`);
130
+ }
131
+
132
+ return { ok: false, detail: details.join(" | ") || "rebuild failed" };
133
+ }
134
+
135
+ function unwrapModule(mod: { default?: BetterSqlite3DatabaseCtor } | BetterSqlite3DatabaseCtor): BetterSqlite3DatabaseCtor {
136
+ return (mod as { default?: BetterSqlite3DatabaseCtor }).default ?? (mod as BetterSqlite3DatabaseCtor);
137
+ }
138
+
139
+ function clearBetterSqlite3RequireCache(requireImpl: NodeRequire, packageRoot: string | null): void {
140
+ for (const key of Object.keys(requireImpl.cache)) {
141
+ if (!key.includes(`${path.sep}better-sqlite3${path.sep}`) && !key.endsWith(`${path.sep}better-sqlite3`)) {
142
+ continue;
143
+ }
144
+ if (packageRoot && !key.startsWith(packageRoot)) continue;
145
+ delete requireImpl.cache[key];
146
+ }
147
+ }
148
+
149
+ export function formatBetterSqlite3AbiError(options: {
150
+ originalError: unknown;
151
+ packageRoot: string | null;
152
+ rebuildAttempted: boolean;
153
+ rebuildDetail?: string;
154
+ }): string {
155
+ const original = options.originalError instanceof Error
156
+ ? options.originalError.message
157
+ : String(options.originalError);
158
+ const runtime = `Node ${process.version} (NODE_MODULE_VERSION ${process.versions.modules}) via ${process.execPath}`;
159
+ const location = options.packageRoot ?? "(better-sqlite3 package root unknown)";
160
+ const rebuildLine = options.rebuildAttempted
161
+ ? (options.rebuildDetail
162
+ ? `Automatic rebuild was attempted and failed: ${options.rebuildDetail}`
163
+ : "Automatic rebuild was attempted and failed.")
164
+ : "Automatic rebuild was not attempted.";
165
+
166
+ return [
167
+ "pi-hermes-memory could not load the native better-sqlite3 module for this Node runtime.",
168
+ `Runtime: ${runtime}`,
169
+ `Module: ${location}`,
170
+ `Original error: ${original}`,
171
+ rebuildLine,
172
+ "Fix: rebuild the extension install against the same Node that runs Pi, then restart Pi:",
173
+ options.packageRoot
174
+ ? ` cd "${options.packageRoot}" && npm rebuild better-sqlite3`
175
+ : " cd ~/.pi/agent/npm && npm rebuild better-sqlite3",
176
+ "If you installed Pi with Homebrew, either rebuild as above after brew Node upgrades, or install Pi with npm so the extension and host share one Node toolchain.",
177
+ ].join("\n");
178
+ }
179
+
180
+ /**
181
+ * Load better-sqlite3, attempting one rebuild on ABI/dlopen mismatch.
182
+ */
183
+ export function loadBetterSqlite3(options: SqliteNativeLoadOptions = {}): BetterSqlite3DatabaseCtor {
184
+ const requireImpl = options.requireImpl
185
+ ?? createRequire(options.requireFrom ?? import.meta.url);
186
+
187
+ const loadOnce = (): BetterSqlite3DatabaseCtor => {
188
+ const mod = requireImpl("better-sqlite3") as { default?: BetterSqlite3DatabaseCtor } | BetterSqlite3DatabaseCtor;
189
+ return unwrapModule(mod);
190
+ };
191
+
192
+ try {
193
+ return loadOnce();
194
+ } catch (firstError) {
195
+ const packageRoot = resolveBetterSqlite3PackageRoot(requireImpl);
196
+ const canRebuild = options.allowRebuild ?? isNativeModuleAbiMismatch(firstError);
197
+ if (!canRebuild || !packageRoot) {
198
+ if (isNativeModuleAbiMismatch(firstError)) {
199
+ throw new BetterSqlite3LoadError(
200
+ formatBetterSqlite3AbiError({
201
+ originalError: firstError,
202
+ packageRoot,
203
+ rebuildAttempted: false,
204
+ }),
205
+ { packageRoot, cause: firstError },
206
+ );
207
+ }
208
+ throw firstError;
209
+ }
210
+
211
+ const rebuild = options.rebuild ?? defaultRebuild;
212
+ const rebuildResult = rebuild(packageRoot);
213
+ clearBetterSqlite3RequireCache(requireImpl, packageRoot);
214
+
215
+ if (rebuildResult.ok) {
216
+ try {
217
+ return loadOnce();
218
+ } catch (secondError) {
219
+ throw new BetterSqlite3LoadError(
220
+ formatBetterSqlite3AbiError({
221
+ originalError: secondError,
222
+ packageRoot,
223
+ rebuildAttempted: true,
224
+ rebuildDetail: rebuildResult.detail,
225
+ }),
226
+ { packageRoot, cause: secondError },
227
+ );
228
+ }
229
+ }
230
+
231
+ throw new BetterSqlite3LoadError(
232
+ formatBetterSqlite3AbiError({
233
+ originalError: firstError,
234
+ packageRoot,
235
+ rebuildAttempted: true,
236
+ rebuildDetail: rebuildResult.detail,
237
+ }),
238
+ { packageRoot, cause: firstError },
239
+ );
240
+ }
241
+ }
@@ -66,22 +66,22 @@ const SKILL_TOOL_PARAMETERS = Type.Object({
66
66
  description: "Required for create. Use 'global' for portable procedures and 'project' for repo-specific workflows.",
67
67
  })),
68
68
  section: Type.Optional(Type.String({
69
- description: "Required for patch. Section header to patch. e.g., 'Procedure', 'Pitfalls'.",
69
+ description: "Required for patch. Section header to patch. e.g., 'Procedure', 'Pitfalls', 'Verification', 'When to Use'.",
70
70
  })),
71
71
  content: Type.Optional(Type.String({
72
- description: "Raw markdown body for create/update/edit, or new section content for patch. For create/update/edit you can provide this or the structured fields below.",
72
+ description: "Raw markdown body for create/update/edit, or Markdown section body for patch. Prefer structured fields over free-form content when possible. For patch, JSON arrays are auto-coerced for list sections; JSON objects are rejected.",
73
73
  })),
74
74
  when_to_use: Type.Optional(Type.String({
75
- description: "Structured create/update/edit field. Explain when this skill should be used and where its boundaries are.",
75
+ description: "Structured create/update/edit field, or structured patch body when section is 'When to Use'.",
76
76
  })),
77
77
  procedure_steps: Type.Optional(Type.Array(Type.String(), {
78
- description: "Structured create/update/edit field. Ordered concrete steps for the workflow.",
78
+ description: "Structured create/update/edit field, or structured patch body when section is 'Procedure'. Ordered concrete steps.",
79
79
  })),
80
80
  pitfalls: Type.Optional(Type.Array(Type.String(), {
81
- description: "Structured create/update/edit field. Optional common mistakes, caveats, or failure modes to avoid.",
81
+ description: "Structured create/update/edit field, or structured patch body when section is 'Pitfalls'.",
82
82
  })),
83
83
  verification_steps: Type.Optional(Type.Array(Type.String(), {
84
- description: "Structured create/update/edit field. Concrete checks that confirm the workflow succeeded.",
84
+ description: "Structured create/update/edit field, or structured patch body when section is 'Verification'.",
85
85
  })),
86
86
  }, { additionalProperties: false });
87
87
 
@@ -97,7 +97,9 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
97
97
  "Use the skill_manage tool after completing complex tasks that required trial and error or multiple tool calls.",
98
98
  "Use 'create' to save a new reusable procedure, 'patch' to update a section of an existing skill by skill_id, and 'update' for a full rewrite.",
99
99
  "Scope is required on create: choose scope='global' for transferable procedures and scope='project' when the workflow depends on this repo's paths, scripts, conventions, or deploy steps.",
100
- "Prefer structured fields for create/update: when_to_use, procedure_steps, pitfalls, and verification_steps. The tool will render valid SKILL.md sections for you.",
100
+ "Prefer structured fields for create/update/patch: when_to_use, procedure_steps, pitfalls, and verification_steps. The tool renders valid SKILL.md sections for you.",
101
+ "For patch, pass section plus the matching structured field (e.g. section='Procedure' with procedure_steps). Avoid free-form content that is a JSON array/object string.",
102
+ "Prefer 'update' for multi-section rewrites when patch content would be large or format-unstable.",
101
103
  "Use 'view' before patching or updating when you need to inspect an existing skill.",
102
104
  "Do NOT use skills for temporary task state — only for durable, reusable procedures.",
103
105
  ],
@@ -206,7 +208,7 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
206
208
  result = { success: true, ...doc };
207
209
  break;
208
210
 
209
- case "patch":
211
+ case "patch": {
210
212
  if (!skill_id) {
211
213
  return {
212
214
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "skill_id is required for 'patch' action." }) }],
@@ -219,14 +221,60 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
219
221
  details: {},
220
222
  };
221
223
  }
222
- if (!content) {
224
+
225
+ const sectionKey = section.replace(/^#+\s*/, "").trim().toLowerCase();
226
+ let patchContent = content?.trim() ?? "";
227
+
228
+ // Prefer structured fields matching the target section so the LLM
229
+ // does not have to invent Markdown list formatting.
230
+ if (sectionKey === "procedure" && procedureSteps.length > 0) {
231
+ patchContent = formatOrderedList(procedureSteps);
232
+ } else if (sectionKey === "pitfalls" && pitfallItems.length > 0) {
233
+ patchContent = formatBulletList(pitfallItems, "No notable pitfalls recorded yet.");
234
+ } else if (sectionKey === "verification" && verificationSteps.length > 0) {
235
+ patchContent = formatOrderedList(verificationSteps);
236
+ } else if ((sectionKey === "when to use" || sectionKey === "when_to_use") && whenToUse) {
237
+ patchContent = whenToUse;
238
+ } else if (!patchContent && hasStructuredBody) {
239
+ // Allow a single structured field even if section name is non-standard.
240
+ if (procedureSteps.length > 0 && pitfallItems.length === 0 && verificationSteps.length === 0 && !whenToUse) {
241
+ patchContent = formatOrderedList(procedureSteps);
242
+ } else if (pitfallItems.length > 0 && procedureSteps.length === 0 && verificationSteps.length === 0 && !whenToUse) {
243
+ patchContent = formatBulletList(pitfallItems, "No notable pitfalls recorded yet.");
244
+ } else if (verificationSteps.length > 0 && procedureSteps.length === 0 && pitfallItems.length === 0 && !whenToUse) {
245
+ patchContent = formatOrderedList(verificationSteps);
246
+ } else if (whenToUse && procedureSteps.length === 0 && pitfallItems.length === 0 && verificationSteps.length === 0) {
247
+ patchContent = whenToUse;
248
+ } else {
249
+ return {
250
+ content: [{
251
+ type: "text",
252
+ text: JSON.stringify({
253
+ success: false,
254
+ error: "For patch, provide content or exactly one structured field matching the target section (procedure_steps, pitfalls, verification_steps, or when_to_use). Use update for multi-section rewrites.",
255
+ }),
256
+ }],
257
+ details: {},
258
+ };
259
+ }
260
+ }
261
+
262
+ if (!patchContent) {
223
263
  return {
224
- content: [{ type: "text", text: JSON.stringify({ success: false, error: "content is required for 'patch' action." }) }],
264
+ content: [{
265
+ type: "text",
266
+ text: JSON.stringify({
267
+ success: false,
268
+ error: "content or a matching structured field is required for 'patch' action. Prefer procedure_steps/pitfalls/verification_steps/when_to_use.",
269
+ }),
270
+ }],
225
271
  details: {},
226
272
  };
227
273
  }
228
- result = await store.patch(skill_id, section, content);
274
+
275
+ result = await store.patch(skill_id, section, patchContent);
229
276
  break;
277
+ }
230
278
 
231
279
  case "update":
232
280
  case "edit": {