myagentmemory 0.5.2 → 0.5.3

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/hooks.ts CHANGED
@@ -1,8 +1,22 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
 
5
- import { type HookMode, writeHookMode } from "./core.js";
6
+ import { getMemoryDir, type HookMode, writeHookMode } from "./core.js";
7
+
8
+ type ExecFileSyncFn = typeof execFileSync;
9
+ let execFileSyncFn: ExecFileSyncFn = execFileSync;
10
+
11
+ /** Override the execFileSync implementation used by hook installers (for testing). */
12
+ export function _setHookExecForTest(fn: ExecFileSyncFn): void {
13
+ execFileSyncFn = fn;
14
+ }
15
+
16
+ /** Reset the execFileSync implementation to the real one. */
17
+ export function _resetHookExecForTest(): void {
18
+ execFileSyncFn = execFileSync;
19
+ }
6
20
 
7
21
  let homeDirOverride: string | null = null;
8
22
 
@@ -38,7 +52,7 @@ function commandExists(command: string): boolean {
38
52
  // Hook installers (SessionStart auto-injection)
39
53
  // ---------------------------------------------------------------------------
40
54
 
41
- export type HookAgentKey = "claude" | "codex" | "cursor" | "opencode" | "pi";
55
+ export type HookAgentKey = "claude" | "codex" | "cursor" | "opencode" | "pi" | "qoder";
42
56
 
43
57
  export interface HookTargetInfo {
44
58
  key: HookAgentKey;
@@ -109,12 +123,22 @@ function hookTargets(homeDir: string): HookTargetInfo[] {
109
123
  },
110
124
  {
111
125
  key: "pi",
112
- label: "pi",
126
+ label: "pi (via pi-memory)",
113
127
  homeMarker: path.join(homeDir, ".pi"),
114
128
  detectFiles: [],
115
129
  detectCommand: "pi",
116
- supported: false,
117
- unsupportedReason: "no documented SessionStart hook mechanism",
130
+ supported: true,
131
+ },
132
+ {
133
+ key: "qoder",
134
+ label: "Qoder",
135
+ homeMarker: path.join(homeDir, ".qoder"),
136
+ detectFiles: [
137
+ path.join(homeDir, ".qoder", "settings.json"),
138
+ path.join(homeDir, ".qoder", "settings.local.json"),
139
+ ],
140
+ detectCommand: "qoder",
141
+ supported: true,
118
142
  },
119
143
  ];
120
144
  }
@@ -166,6 +190,14 @@ export function isHookInstalled(homeDir: string, key: HookAgentKey): boolean {
166
190
  const instructionsPath = path.join(homeDir, ".agent-memory", "hooks", "opencode.md");
167
191
  return list.includes(instructionsPath);
168
192
  }
193
+ if (key === "pi") {
194
+ // Live filesystem state is authoritative — pi-memory can be installed or
195
+ // removed outside agent-memory (manually, or via `pi uninstall`) at any
196
+ // time, so a recorded delegate attempt must never override what's
197
+ // actually on disk. The state file is diagnostic-only (surfaced
198
+ // separately in `doctor`'s detail text), not a substitute for this check.
199
+ return fs.existsSync(path.join(homeDir, ".pi", "agent", "memory"));
200
+ }
169
201
  } catch {
170
202
  return false;
171
203
  }
@@ -269,6 +301,81 @@ function writeJson(filePath: string, data: unknown) {
269
301
  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
270
302
  }
271
303
 
304
+ // ---------------------------------------------------------------------------
305
+ // pi hook support (delegates to the pi-memory extension rather than editing
306
+ // a JSON/TOML hook config — pi's extensibility model is a registered .ts
307
+ // extension, installed via its own package manager).
308
+ // ---------------------------------------------------------------------------
309
+
310
+ export interface PiMemoryState {
311
+ lastAttemptAt: string;
312
+ ok: boolean;
313
+ detail: string;
314
+ }
315
+
316
+ function piMemoryStatePath(): string {
317
+ return path.join(getMemoryDir(), "pi-memory-state.json");
318
+ }
319
+
320
+ function writePiMemoryState(ok: boolean, detail: string): void {
321
+ try {
322
+ writeJson(piMemoryStatePath(), { lastAttemptAt: new Date().toISOString(), ok, detail });
323
+ } catch {
324
+ // best-effort bookkeeping only — never block the actual install/uninstall result on this.
325
+ }
326
+ }
327
+
328
+ /** Read the last recorded `pi install npm:pi-memory` attempt, if any. Never throws. */
329
+ export function getPiMemoryState(): PiMemoryState | null {
330
+ try {
331
+ const filePath = piMemoryStatePath();
332
+ if (!fs.existsSync(filePath)) return null;
333
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
334
+ if (typeof parsed?.lastAttemptAt !== "string" || typeof parsed?.ok !== "boolean") return null;
335
+ return { lastAttemptAt: parsed.lastAttemptAt, ok: parsed.ok, detail: String(parsed.detail ?? "") };
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ function installPiMemoryDelegate(homeDir: string): HookInstallResult {
342
+ const memoryPath = path.join(homeDir, ".pi", "agent", "memory");
343
+ try {
344
+ const stdout = execFileSyncFn("pi", ["install", "npm:pi-memory"], {
345
+ encoding: "utf-8",
346
+ timeout: 30_000,
347
+ });
348
+ const detail = typeof stdout === "string" ? stdout.trim() : "";
349
+ writePiMemoryState(true, detail);
350
+ return { key: "pi", label: "pi (via pi-memory)", installed: true, path: memoryPath, reason: detail || undefined };
351
+ } catch (err) {
352
+ const detail =
353
+ err && typeof err === "object" && "stderr" in err && err.stderr
354
+ ? String(err.stderr).trim()
355
+ : err instanceof Error
356
+ ? err.message
357
+ : String(err);
358
+ writePiMemoryState(false, detail);
359
+ return { key: "pi", label: "pi (via pi-memory)", installed: false, reason: detail };
360
+ }
361
+ }
362
+
363
+ function uninstallPiMemoryDelegate(homeDir: string): HookInstallResult {
364
+ // agent-memory never owns this install, so it never runs `pi uninstall pi-memory` — but
365
+ // silently doing nothing would let an `agent-memory uninstall` report read as "fully cleaned
366
+ // up" while pi-memory keeps running. Phrase the reason distinctly when it's actually still
367
+ // active so callers (and cmdUninstall's step detail) can surface that honestly.
368
+ const stillActive = fs.existsSync(path.join(homeDir, ".pi", "agent", "memory"));
369
+ return {
370
+ key: "pi",
371
+ label: "pi (via pi-memory)",
372
+ installed: false,
373
+ reason: stillActive
374
+ ? "pi-memory left installed (not managed by agent-memory) — run `pi uninstall pi-memory` to remove it"
375
+ : "not installed",
376
+ };
377
+ }
378
+
272
379
  /**
273
380
  * Idempotently upsert the agent-memory-managed hook group for `eventKey`
274
381
  * (SessionStart or UserPromptSubmit) with `command`. Returns `{ changed,
@@ -581,6 +688,88 @@ function installOpencodeInstructions(homeDir: string): HookInstallResult {
581
688
  return { key: "opencode", label: "opencode", installed: true, path: configPath, backup };
582
689
  }
583
690
 
691
+ function installQoderHook(homeDir: string): HookInstallResult {
692
+ const settingsPath = path.join(homeDir, ".qoder", "settings.json");
693
+ const backup = backupOnce(settingsPath);
694
+ const settings = readJsonConfig(settingsPath);
695
+ const hooks = (settings.hooks as Record<string, unknown>) ?? {};
696
+ const sessionStart = Array.isArray(hooks.SessionStart) ? [...(hooks.SessionStart as unknown[])] : [];
697
+
698
+ const command = "agent-memory context";
699
+ let managed = 0;
700
+ let updated = 0;
701
+ for (const group of sessionStart) {
702
+ if (!group || typeof group !== "object") continue;
703
+ const g = group as Record<string, unknown>;
704
+ const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
705
+ for (const hook of list) {
706
+ if (!hook || typeof hook !== "object") continue;
707
+ const managedHook = hook as Record<string, unknown>;
708
+ if (managedHook[HOOK_MARKER_JSON] !== true) continue;
709
+ managed++;
710
+ if (managedHook.command !== command) {
711
+ managedHook.command = command;
712
+ updated++;
713
+ }
714
+ }
715
+ }
716
+ if (managed && !updated) {
717
+ return { key: "qoder", label: "Qoder", installed: false, path: settingsPath, reason: "already installed" };
718
+ }
719
+ if (updated) {
720
+ hooks.SessionStart = sessionStart;
721
+ settings.hooks = hooks;
722
+ writeJson(settingsPath, settings);
723
+ return { key: "qoder", label: "Qoder", installed: true, path: settingsPath, backup, reason: "updated" };
724
+ }
725
+
726
+ sessionStart.push({
727
+ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }],
728
+ });
729
+ hooks.SessionStart = sessionStart;
730
+ settings.hooks = hooks;
731
+ writeJson(settingsPath, settings);
732
+ return { key: "qoder", label: "Qoder", installed: true, path: settingsPath, backup };
733
+ }
734
+
735
+ function uninstallQoderHook(homeDir: string): HookInstallResult {
736
+ const settingsPath = path.join(homeDir, ".qoder", "settings.json");
737
+ if (!fs.existsSync(settingsPath)) {
738
+ return { key: "qoder", label: "Qoder", installed: false, reason: "not installed" };
739
+ }
740
+ const settings = readJsonConfig(settingsPath);
741
+ const hooks = (settings.hooks as Record<string, unknown>) ?? {};
742
+ const sessionStart = Array.isArray(hooks.SessionStart) ? (hooks.SessionStart as unknown[]) : [];
743
+ let removed = 0;
744
+ const filtered = sessionStart
745
+ .map((group) => {
746
+ if (!group || typeof group !== "object") return group;
747
+ const g = { ...(group as Record<string, unknown>) };
748
+ const list = Array.isArray(g.hooks) ? (g.hooks as unknown[]) : [];
749
+ const kept = list.filter((h) => {
750
+ const isOurs = h && typeof h === "object" && (h as Record<string, unknown>)[HOOK_MARKER_JSON] === true;
751
+ if (isOurs) removed++;
752
+ return !isOurs;
753
+ });
754
+ g.hooks = kept;
755
+ return g;
756
+ })
757
+ .filter((group) => {
758
+ if (!group || typeof group !== "object") return true;
759
+ const g = group as Record<string, unknown>;
760
+ return Array.isArray(g.hooks) && (g.hooks as unknown[]).length > 0;
761
+ });
762
+ if (removed === 0) {
763
+ return { key: "qoder", label: "Qoder", installed: false, reason: "not installed" };
764
+ }
765
+ hooks.SessionStart = filtered;
766
+ if (filtered.length === 0) delete (hooks as Record<string, unknown>).SessionStart;
767
+ if (Object.keys(hooks).length === 0) delete (settings as Record<string, unknown>).hooks;
768
+ else settings.hooks = hooks;
769
+ writeJson(settingsPath, settings);
770
+ return { key: "qoder", label: "Qoder", installed: true, path: settingsPath };
771
+ }
772
+
584
773
  export function installHooks(agents: Set<HookAgentKey>, mode: HookMode = "per-turn"): InstallHooksReport {
585
774
  const { homeDir, targets } = detectHookAgents();
586
775
  if (!homeDir) {
@@ -619,6 +808,8 @@ export function installHooks(agents: Set<HookAgentKey>, mode: HookMode = "per-tu
619
808
  else if (target.key === "codex") result = installCodexHook(homeDir, mode);
620
809
  else if (target.key === "cursor") result = installCursorHook(homeDir);
621
810
  else if (target.key === "opencode") result = installOpencodeInstructions(homeDir);
811
+ else if (target.key === "pi") result = installPiMemoryDelegate(homeDir);
812
+ else if (target.key === "qoder") result = installQoderHook(homeDir);
622
813
  else continue;
623
814
  results.push(result);
624
815
  if (result.installed) anyInstalled = true;
@@ -773,7 +964,7 @@ export function uninstallHooks(agents?: Set<HookAgentKey>): UninstallHooksReport
773
964
  error: "Home directory not found. Set HOME (or USERPROFILE on Windows) and retry.",
774
965
  };
775
966
  }
776
- const keys: HookAgentKey[] = ["claude", "codex", "cursor", "opencode"];
967
+ const keys: HookAgentKey[] = ["claude", "codex", "cursor", "opencode", "pi", "qoder"];
777
968
  const results: HookInstallResult[] = [];
778
969
  for (const key of keys) {
779
970
  if (agents && !agents.has(key)) continue;
@@ -782,6 +973,8 @@ export function uninstallHooks(agents?: Set<HookAgentKey>): UninstallHooksReport
782
973
  else if (key === "codex") results.push(uninstallCodexHook(homeDir));
783
974
  else if (key === "cursor") results.push(uninstallCursorHook(homeDir));
784
975
  else if (key === "opencode") results.push(uninstallOpencodeInstructions(homeDir));
976
+ else if (key === "pi") results.push(uninstallPiMemoryDelegate(homeDir));
977
+ else if (key === "qoder") results.push(uninstallQoderHook(homeDir));
785
978
  } catch (err) {
786
979
  results.push({
787
980
  key,