@oh-my-pi/pi-coding-agent 14.6.2 → 14.6.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +71 -2
  2. package/README.md +21 -0
  3. package/package.json +23 -7
  4. package/src/cli/grievances-cli.ts +89 -4
  5. package/src/commands/grievances.ts +33 -7
  6. package/src/config/prompt-templates.ts +14 -7
  7. package/src/config/settings-schema.ts +585 -100
  8. package/src/config/settings.ts +42 -0
  9. package/src/discovery/helpers.ts +13 -6
  10. package/src/edit/index.ts +3 -3
  11. package/src/edit/line-hash.ts +73 -25
  12. package/src/edit/modes/hashline.lark +10 -3
  13. package/src/edit/modes/hashline.ts +104 -38
  14. package/src/edit/renderer.ts +3 -3
  15. package/src/hindsight/backend.ts +444 -0
  16. package/src/hindsight/bank.ts +131 -0
  17. package/src/hindsight/client.ts +445 -0
  18. package/src/hindsight/config.ts +165 -0
  19. package/src/hindsight/content.ts +205 -0
  20. package/src/hindsight/index.ts +6 -0
  21. package/src/hindsight/retain-queue.ts +166 -0
  22. package/src/hindsight/transcript.ts +71 -0
  23. package/src/main.ts +7 -10
  24. package/src/memories/index.ts +1 -1
  25. package/src/memory-backend/index.ts +4 -0
  26. package/src/memory-backend/local-backend.ts +30 -0
  27. package/src/memory-backend/off-backend.ts +16 -0
  28. package/src/memory-backend/resolve.ts +24 -0
  29. package/src/memory-backend/types.ts +69 -0
  30. package/src/modes/components/settings-defs.ts +50 -451
  31. package/src/modes/components/settings-selector.ts +2 -2
  32. package/src/modes/components/status-line/presets.ts +1 -1
  33. package/src/modes/controllers/command-controller.ts +6 -5
  34. package/src/modes/controllers/event-controller.ts +12 -0
  35. package/src/modes/controllers/selector-controller.ts +3 -12
  36. package/src/modes/theme/theme.ts +4 -0
  37. package/src/prompts/tools/github.md +3 -0
  38. package/src/prompts/tools/hashline.md +20 -16
  39. package/src/prompts/tools/read.md +10 -6
  40. package/src/prompts/tools/recall.md +5 -0
  41. package/src/prompts/tools/reflect.md +5 -0
  42. package/src/prompts/tools/retain.md +5 -0
  43. package/src/prompts/tools/search.md +1 -1
  44. package/src/sdk.ts +12 -9
  45. package/src/session/agent-session.ts +75 -3
  46. package/src/slash-commands/builtin-registry.ts +2 -12
  47. package/src/tools/ast-edit.ts +14 -5
  48. package/src/tools/ast-grep.ts +12 -3
  49. package/src/tools/find.ts +47 -7
  50. package/src/tools/gh-renderer.ts +10 -1
  51. package/src/tools/gh.ts +233 -5
  52. package/src/tools/hindsight-recall.ts +70 -0
  53. package/src/tools/hindsight-reflect.ts +57 -0
  54. package/src/tools/hindsight-retain.ts +63 -0
  55. package/src/tools/index.ts +17 -0
  56. package/src/tools/path-utils.ts +55 -0
  57. package/src/tools/read.ts +1 -1
  58. package/src/tools/search.ts +45 -8
@@ -1,18 +1,15 @@
1
- import * as os from "node:os";
2
- import * as path from "node:path";
3
1
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
2
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
5
3
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
6
4
  import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
7
5
  import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
8
- import { getAgentDbPath, getConfigDirName, getProjectDir } from "@oh-my-pi/pi-utils";
9
- import { invalidate as invalidateFsCache } from "../../capability/fs";
6
+ import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
10
7
  import { getRoleInfo } from "../../config/model-registry";
11
8
  import { formatModelSelectorValue } from "../../config/model-resolver";
12
9
  import { settings } from "../../config/settings";
13
10
  import { DebugSelectorComponent } from "../../debug";
14
11
  import { disableProvider, enableProvider } from "../../discovery";
15
- import { clearClaudePluginRootsCache, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
12
+ import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
16
13
  import {
17
14
  getInstalledPluginsRegistryPath,
18
15
  getMarketplacesCacheDir,
@@ -451,13 +448,7 @@ export class SelectorController {
451
448
  projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
452
449
  marketplacesCacheDir: getMarketplacesCacheDir(),
453
450
  pluginsCacheDir: getPluginsCacheDir(),
454
- clearPluginRootsCache: (extraPaths?: readonly string[]) => {
455
- const home = os.homedir();
456
- invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
457
- invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
458
- for (const p of extraPaths ?? []) invalidateFsCache(p);
459
- clearClaudePluginRootsCache();
460
- },
451
+ clearPluginRootsCache: clearPluginRootsAndCaches,
461
452
  });
462
453
 
463
454
  const [marketplaces, installed] = await Promise.all([mgr.listMarketplaces(), mgr.listInstalledPlugins()]);
@@ -186,6 +186,7 @@ export type SymbolKey =
186
186
  | "tab.context"
187
187
  | "tab.editing"
188
188
  | "tab.tools"
189
+ | "tab.memory"
189
190
  | "tab.tasks"
190
191
  | "tab.providers";
191
192
 
@@ -346,6 +347,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
346
347
  "tab.context": "📋",
347
348
  "tab.editing": "💻",
348
349
  "tab.tools": "🔧",
350
+ "tab.memory": "🧠",
349
351
  "tab.tasks": "📦",
350
352
  "tab.providers": "🌐",
351
353
  };
@@ -599,6 +601,7 @@ const NERD_SYMBOLS: SymbolMap = {
599
601
  "tab.context": "󰘸",
600
602
  "tab.editing": "",
601
603
  "tab.tools": "󰠭",
604
+ "tab.memory": "󰧑",
602
605
  "tab.tasks": "󰐱",
603
606
  "tab.providers": "󰖟",
604
607
  };
@@ -757,6 +760,7 @@ const ASCII_SYMBOLS: SymbolMap = {
757
760
  "tab.context": "[X]",
758
761
  "tab.editing": "[E]",
759
762
  "tab.tools": "[T]",
763
+ "tab.memory": "[Y]",
760
764
  "tab.tasks": "[K]",
761
765
  "tab.providers": "[P]",
762
766
  };
@@ -10,6 +10,9 @@ Pick the operation via `op`. Each op uses a subset of the parameters:
10
10
  - `pr_push` — Push a checked-out PR branch back to its source branch. Requires the branch to have been checked out via `op: pr_checkout` (carries push metadata). Optional `branch`; defaults to the current checked-out git branch. Optional `forceWithLease`.
11
11
  - `search_issues` — Search issues using normal GitHub issue search syntax. Required `query`. Optional `repo`, `limit`.
12
12
  - `search_prs` — Search pull requests using normal GitHub PR search syntax. Required `query`. Optional `repo`, `limit`.
13
+ - `search_code` — Search code with GitHub code search syntax. Required `query`. Optional `repo`, `limit`. Returns matching paths with surrounding fragments.
14
+ - `search_commits` — Search commits across GitHub. Required `query`. Optional `repo`, `limit`. Returns short SHA, author, and the first line of each commit message.
15
+ - `search_repos` — Search repositories across GitHub. Required `query`. Optional `limit` (use query qualifiers like `org:`, `language:` instead of `repo`).
13
16
  - `run_watch` — Watch a GitHub Actions workflow run. Optional `run` (id or URL). Omitting `run` watches all workflow runs for the current HEAD commit; `branch` falls back to the current branch. Optional `tail` (log lines per failed job). Streams snapshots, fast-fails on the first detected job failure (with a brief grace period to capture concurrent failures), then fetches tailed logs for the failed jobs. The full failed-job logs are saved as a session artifact for on-demand reads.
14
17
  </instruction>
15
18
 
@@ -8,15 +8,15 @@ This format is purely textual. The tool has NO awareness of language, indentatio
8
8
 
9
9
  <ops>
10
10
  @PATH header: subsequent ops apply to PATH
11
- < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `|TEXT` lines
12
- + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `|TEXT` lines
11
+ < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
12
+ + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `{{hsep}}TEXT` lines
13
13
  - A..B delete the line range (inclusive); `- A` for one line
14
- = A..B replace the range with payload `|TEXT` lines, or with one blank line if no payload follows
14
+ = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows
15
15
  </ops>
16
16
 
17
17
  <rules>
18
- - Every line of inserted/replacement content **MUST** be emitted as a payload line starting with `|`.
19
- - `|` is syntax, not content. The inserted text begins after the first `|`; use a bare `|` to insert a blank line.
18
+ - Every line of inserted/replacement content **MUST** be emitted as a payload line starting with `{{hsep}}`.
19
+ - `{{hsep}}` is syntax, not content. The inserted text begins after the first `{{hsep}}`; use a bare `{{hsep}}` to insert a blank line.
20
20
  - `< A` inserts before line A; `+ A` inserts after line A. `< BOF` / `+ BOF` both prepend; `< EOF` / `+ EOF` both append.
21
21
  - `= A..B` replaces the inclusive range with the following payload lines. `= A` (or `= A..B`) with no payload blanks the range to a single empty line.
22
22
  - `- A..B` deletes the inclusive range; omit `..B` for one line.
@@ -35,30 +35,34 @@ This format is purely textual. The tool has NO awareness of language, indentatio
35
35
  # Replace one line (preserve the leading tab from the original)
36
36
  @a.ts
37
37
  = {{hrefr 5}}
38
- | return clean.trim().toUpperCase();
38
+ {{hsep}} return clean.trim().toUpperCase();
39
39
 
40
40
  # Replace a contiguous range with multiple lines
41
41
  @a.ts
42
42
  = {{hrefr 3}}..{{hrefr 6}}
43
- |export function label(name: string): string {
44
- | const clean = (name || DEF).trim();
45
- | return clean.length === 0 ? DEF : clean.toUpperCase();
46
- |}
43
+ {{hsep}}export function label(name: string): string {
44
+ {{hsep}} const clean = (name || DEF).trim();
45
+ {{hsep}} return clean.length === 0 ? DEF : clean.toUpperCase();
46
+ {{hsep}}}
47
47
 
48
48
  # Insert BEFORE a line
49
49
  @a.ts
50
50
  < {{hrefr 5}}
51
- | const debug = false;
51
+ {{hsep}} const debug = false;
52
52
 
53
53
  # Insert AFTER a line
54
54
  @a.ts
55
55
  + {{hrefr 4}}
56
- | if (clean.length === 0) return DEF;
56
+ {{hsep}} if (clean.length === 0) return DEF;
57
+
58
+ # Append WITHIN a line
59
+ @a.ts
60
+ + {{hrefr 4}}{{hsep}} // first run
57
61
 
58
62
  # Append to end of file
59
63
  @a.ts
60
64
  + EOF
61
- |export const done = true;
65
+ {{hsep}}export const done = true;
62
66
 
63
67
  # Delete a single line
64
68
  @a.ts
@@ -70,9 +74,9 @@ This format is purely textual. The tool has NO awareness of language, indentatio
70
74
  </examples>
71
75
 
72
76
  <critical>
73
- - Always copy anchors exactly from tool output, but **NEVER** include line content after the `|` separator in the op line.
77
+ - Always copy anchors exactly from tool output, but **NEVER** include line content after the `{{hsep}}` separator in the op line.
74
78
  - Only emit changed lines. Do not restate unchanged context as payload.
75
- - Every inserted/replacement content line **MUST** start with `|`; raw content lines are invalid.
79
+ - Every inserted/replacement content line **MUST** start with `{{hsep}}`; raw content lines are invalid.
76
80
  - Do not write unified diff syntax (`@@`, `-OLD`, `+NEW`).
77
- - To replace a block, use one `= A..B` op followed by all replacement `|TEXT` payload lines.
81
+ - To replace a block, use one `= A..B` op followed by all replacement `{{hsep}}TEXT` payload lines.
78
82
  </critical>
@@ -14,7 +14,7 @@ The `read` tool is multi-purpose and more capable than it looks — inspects fil
14
14
 
15
15
  |`sel` value|Behavior|
16
16
  |---|---|
17
- |*(omitted)*|Read full file (up to {{DEFAULT_LIMIT}} lines)|
17
+ |_(omitted)_|Read full file (up to {{DEFAULT_LIMIT}} lines)|
18
18
  |`50`|Read from line 50 onward|
19
19
  |`50-200`|Read lines 50-200|
20
20
  |`50+150`|Read 150 lines starting at line 50|
@@ -22,21 +22,24 @@ The `read` tool is multi-purpose and more capable than it looks — inspects fil
22
22
 
23
23
  # Filesystem
24
24
  - Reading a directory path returns a list of dirents.
25
- {{#if IS_HASHLINE_MODE}}
25
+ {{#if IS_HL_MODE}}
26
26
  - Reading a file returns lines prefixed with anchors (line+hash): `41th|def alpha():`
27
- {{else}}
28
- {{#if IS_LINE_NUMBER_MODE}}
27
+ {{else}}
28
+ {{#if IS_LINE_NUMBER_MODE}}
29
29
  - Reading a file returns lines prefixed with line numbers: `41|def alpha():`
30
- {{/if}}
31
- {{/if}}
30
+ {{/if}}
31
+ {{/if}}
32
32
 
33
33
  # Inspection
34
+
34
35
  Extracts text from PDF, Word, PowerPoint, Excel, RTF, EPUB, and Jupyter notebook files. Can inspect images.
35
36
 
36
37
  # Directories & Archives
38
+
37
39
  Directories and archive roots return a list of entries. Supports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read contents.
38
40
 
39
41
  # SQLite Databases
42
+
40
43
  For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
41
44
  - `file.db` — list tables with row counts
42
45
  - `file.db:table` — schema + sample rows
@@ -46,6 +49,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
46
49
  - `file.db?q=SELECT …` — read-only SELECT query
47
50
 
48
51
  # URLs
52
+
49
53
  Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout.
50
54
  </instruction>
51
55
 
@@ -0,0 +1,5 @@
1
+ Search long-term memory for relevant information. Returns raw matching entries ranked by relevance.
2
+
3
+ Use proactively — before answering questions about past conversations, user preferences, project decisions, or any topic where prior context would help accuracy. When in doubt, recall first.
4
+
5
+ Prefer `recall` when you need specific facts or entries. Use `reflect` instead when you need a synthesised answer across many memories.
@@ -0,0 +1,5 @@
1
+ Generate a synthesised answer by reasoning over long-term memory. Unlike `recall` (which returns raw entries), `reflect` blends relevant memories into a single coherent response.
2
+
3
+ Use for open-ended questions that span many stored facts: "What do you know about this user?", "Summarize project decisions.", "What are my preferences for X?"
4
+
5
+ Provide an optional `context` to focus the synthesis on a specific angle or sub-topic.
@@ -0,0 +1,5 @@
1
+ Store one or more facts in long-term memory for future sessions.
2
+
3
+ Use for durable, reusable knowledge: user preferences, project decisions, architectural choices, and anything that would improve future responses if recalled. Ephemeral task state does not belong here.
4
+
5
+ Each item must be specific and self-contained — include who, what, when, and why. Batch related facts in a single call; they are deduplicated and consolidated together.
@@ -7,7 +7,7 @@ Searches files using powerful regex matching.
7
7
  </instruction>
8
8
 
9
9
  <output>
10
- {{#if IS_HASHLINE_MODE}}
10
+ {{#if IS_HL_MODE}}
11
11
  - Text output is anchor-prefixed: `*5th|content` (match) or ` 9x}|content` (context, leading space). The 2-char suffix is a content fingerprint.
12
12
  {{else}}
13
13
  {{#if IS_LINE_NUMBER_MODE}}
package/src/sdk.ts CHANGED
@@ -82,7 +82,8 @@ import {
82
82
  selectDiscoverableMCPToolNamesByServer,
83
83
  summarizeDiscoverableMCPTools,
84
84
  } from "./mcp/discoverable-tool-metadata";
85
- import { buildMemoryToolDeveloperInstructions, getMemoryRoot, startMemoryStartupTask } from "./memories";
85
+ import { getMemoryRoot } from "./memories";
86
+ import { resolveMemoryBackend } from "./memory-backend";
86
87
  import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
87
88
  import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
88
89
  import {
@@ -1334,7 +1335,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1334
1335
  const promptTools = buildSystemPromptToolMetadata(tools, {
1335
1336
  search_tool_bm25: { description: renderSearchToolBm25Description(discoverableMCPTools) },
1336
1337
  });
1337
- const memoryInstructions = await buildMemoryToolDeveloperInstructions(agentDir, settings);
1338
+ const memoryInstructions = await resolveMemoryBackend(settings).buildDeveloperInstructions(agentDir, settings);
1338
1339
 
1339
1340
  // Build combined append prompt: memory instructions + MCP server instructions
1340
1341
  const serverInstructions = mcpManager?.getServerInstructions();
@@ -1747,13 +1748,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1747
1748
  }
1748
1749
 
1749
1750
  logger.time("startMemoryStartupTask", () =>
1750
- startMemoryStartupTask({
1751
- session,
1752
- settings,
1753
- modelRegistry,
1754
- agentDir,
1755
- taskDepth,
1756
- }),
1751
+ Promise.resolve(
1752
+ resolveMemoryBackend(settings).start({
1753
+ session,
1754
+ settings,
1755
+ modelRegistry,
1756
+ agentDir,
1757
+ taskDepth,
1758
+ }),
1759
+ ),
1757
1760
  );
1758
1761
 
1759
1762
  // Wire MCP manager callbacks to session for reactive tool updates.
@@ -111,6 +111,7 @@ import {
111
111
  isMCPToolName,
112
112
  selectDiscoverableMCPToolNamesByServer,
113
113
  } from "../mcp/discoverable-tool-metadata";
114
+ import { resolveMemoryBackend } from "../memory-backend";
114
115
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
115
116
  import type { PlanModeState } from "../plan-mode/state";
116
117
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
@@ -192,7 +193,8 @@ export type AgentSessionEvent =
192
193
  | { type: "ttsr_triggered"; rules: Rule[] }
193
194
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
194
195
  | { type: "todo_auto_clear" }
195
- | { type: "irc_message"; message: CustomMessage };
196
+ | { type: "irc_message"; message: CustomMessage }
197
+ | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string };
196
198
 
197
199
  /** Listener function for agent session events */
198
200
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -742,6 +744,19 @@ export class AgentSession {
742
744
  }
743
745
  }
744
746
 
747
+ /**
748
+ * Emit a UI-only notice to the session. Surfaces in interactive mode as a
749
+ * `showWarning` / `showError` / `showStatus` line; non-interactive modes
750
+ * receive the event through the normal subscribe stream.
751
+ *
752
+ * Notices are NOT added to agent state and never reach the LLM — use this
753
+ * for out-of-band conditions the user should see but the model shouldn't
754
+ * react to (e.g. background queue flush failures).
755
+ */
756
+ emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void {
757
+ this.#emit({ type: "notice", level, message, source });
758
+ }
759
+
745
760
  #queuedExtensionEvents: Promise<void> = Promise.resolve();
746
761
 
747
762
  #queueExtensionEvent(event: AgentSessionEvent): Promise<void> {
@@ -2289,6 +2304,23 @@ export class AgentSession {
2289
2304
  this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
2290
2305
  }
2291
2306
 
2307
+ async #buildSystemPromptForAgentStart(promptText: string): Promise<string> {
2308
+ const backend = resolveMemoryBackend(this.settings);
2309
+ if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
2310
+
2311
+ try {
2312
+ const injected = await backend.beforeAgentStartPrompt(this, promptText);
2313
+ if (!injected) return this.#baseSystemPrompt;
2314
+ return `${this.#baseSystemPrompt}\n\n${injected}`;
2315
+ } catch (err) {
2316
+ logger.debug("Memory backend beforeAgentStartPrompt failed", {
2317
+ backend: backend.id,
2318
+ error: String(err),
2319
+ });
2320
+ return this.#baseSystemPrompt;
2321
+ }
2322
+ }
2323
+
2292
2324
  /**
2293
2325
  * Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
2294
2326
  * Two calls producing identical signatures are guaranteed to produce identical
@@ -2908,12 +2940,14 @@ export class AgentSession {
2908
2940
  messages.push(...fileMentionMessages);
2909
2941
  }
2910
2942
 
2943
+ const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
2944
+
2911
2945
  // Emit before_agent_start extension event
2912
2946
  if (this.#extensionRunner) {
2913
2947
  const result = await this.#extensionRunner.emitBeforeAgentStart(
2914
2948
  expandedText,
2915
2949
  options?.images,
2916
- this.#baseSystemPrompt,
2950
+ beforeAgentStartSystemPrompt,
2917
2951
  );
2918
2952
  if (result?.messages) {
2919
2953
  const promptAttribution: "user" | "agent" | undefined =
@@ -2934,8 +2968,10 @@ export class AgentSession {
2934
2968
  if (result?.systemPrompt !== undefined) {
2935
2969
  this.agent.setSystemPrompt(result.systemPrompt);
2936
2970
  } else {
2937
- this.agent.setSystemPrompt(this.#baseSystemPrompt);
2971
+ this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
2938
2972
  }
2973
+ } else {
2974
+ this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
2939
2975
  }
2940
2976
 
2941
2977
  // Bail out if a newer abort/prompt cycle has started since we began setup
@@ -4118,6 +4154,11 @@ export class AgentSession {
4118
4154
  preserveData = result?.preserveData;
4119
4155
  }
4120
4156
 
4157
+ const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
4158
+ if (memoryBackendContext) {
4159
+ hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
4160
+ }
4161
+
4121
4162
  let summary: string;
4122
4163
  let shortSummary: string | undefined;
4123
4164
  let firstKeptEntryId: string;
@@ -4204,6 +4245,32 @@ export class AgentSession {
4204
4245
  }
4205
4246
  }
4206
4247
 
4248
+ /**
4249
+ * Ask the active memory backend for an extra-context block to splice into
4250
+ * the compaction summary prompt. Both the manual and auto compaction paths
4251
+ * funnel through this helper so the behaviour stays identical.
4252
+ *
4253
+ * Failures are swallowed: a memory backend going sideways MUST NOT block
4254
+ * compaction (which is itself the recovery path for context overflow).
4255
+ */
4256
+ async #collectMemoryBackendContext(preparation: {
4257
+ messagesToSummarize: AgentMessage[];
4258
+ turnPrefixMessages: AgentMessage[];
4259
+ }): Promise<string | undefined> {
4260
+ const backend = resolveMemoryBackend(this.settings);
4261
+ if (!backend.preCompactionContext) return undefined;
4262
+ const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
4263
+ try {
4264
+ return await backend.preCompactionContext(messages, this.settings);
4265
+ } catch (err) {
4266
+ logger.debug("Memory backend preCompactionContext failed", {
4267
+ backend: backend.id,
4268
+ error: String(err),
4269
+ });
4270
+ return undefined;
4271
+ }
4272
+ }
4273
+
4207
4274
  /**
4208
4275
  * Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff).
4209
4276
  */
@@ -5190,6 +5257,11 @@ export class AgentSession {
5190
5257
  preserveData = result?.preserveData;
5191
5258
  }
5192
5259
 
5260
+ const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
5261
+ if (memoryBackendContext) {
5262
+ hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
5263
+ }
5264
+
5193
5265
  let summary: string;
5194
5266
  let shortSummary: string | undefined;
5195
5267
  let firstKeptEntryId: string;
@@ -1,13 +1,7 @@
1
- import * as os from "node:os";
2
- import * as path from "node:path";
3
-
4
1
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
5
- import { getConfigDirName } from "@oh-my-pi/pi-utils";
6
- import { invalidate as invalidateFsCache } from "../capability/fs";
7
2
  import type { SettingPath, SettingValue } from "../config/settings";
8
3
  import { settings } from "../config/settings";
9
4
  import {
10
- clearClaudePluginRootsCache,
11
5
  clearPluginRootsAndCaches,
12
6
  resolveActiveProjectRegistryPath,
13
7
  resolveOrDefaultProjectRegistryPath,
@@ -942,14 +936,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
942
936
  name: "reload-plugins",
943
937
  description: "Reload all plugins (skills, commands, hooks, tools, agents, MCP)",
944
938
  handle: async (_command, runtime) => {
945
- // Invalidate the fs content cache for all registry files so
939
+ // Invalidate registry fs caches and the plugin roots cache so
946
940
  // listClaudePluginRoots re-reads from disk on next access.
947
- const home = os.homedir();
948
- invalidateFsCache(path.join(home, ".claude", "plugins", "installed_plugins.json"));
949
- invalidateFsCache(path.join(home, getConfigDirName(), "plugins", "installed_plugins.json"));
950
941
  const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd());
951
- if (projectPath) invalidateFsCache(projectPath);
952
- clearClaudePluginRootsCache();
942
+ clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
953
943
  await runtime.ctx.refreshSlashCommandState();
954
944
  runtime.ctx.showStatus("Plugins reloaded.");
955
945
  runtime.ctx.editor.setText("");
@@ -5,7 +5,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
- import { computeLineHash, HASHLINE_CONTENT_SEPARATOR } from "../edit/line-hash";
8
+ import { computeLineHash, HL_BODY_SEP } from "../edit/line-hash";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { Theme } from "../modes/theme/theme";
11
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
@@ -20,6 +20,7 @@ import {
20
20
  hasGlobPathChars,
21
21
  normalizePathLikeInput,
22
22
  parseSearchPath,
23
+ partitionExistingPaths,
23
24
  resolveExplicitSearchPaths,
24
25
  resolveToCwd,
25
26
  } from "./path-utils";
@@ -226,13 +227,21 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
226
227
  }
227
228
  resolvedPathInputs.push(resource.sourcePath);
228
229
  }
229
- if (resolvedPathInputs.length === 1) {
230
- const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
230
+ let effectivePathInputs = resolvedPathInputs;
231
+ if (resolvedPathInputs.length > 1) {
232
+ const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
233
+ if (partition.valid.length === 0) {
234
+ throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
235
+ }
236
+ effectivePathInputs = partition.valid;
237
+ }
238
+ if (effectivePathInputs.length === 1) {
239
+ const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
231
240
  searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
232
241
  globFilter = parsedPath.glob;
233
242
  scopePath = formatScopePath(searchPath);
234
243
  } else {
235
- const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
244
+ const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
236
245
  if (!multiSearchPath) {
237
246
  throw new ToolError("`paths` must contain at least one path or glob");
238
247
  }
@@ -321,7 +330,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
321
330
  const afterRef = useHashLines
322
331
  ? `${change.startLine}${computeLineHash(change.startLine, afterFirstLine)}`
323
332
  : `${change.startLine}:${change.startColumn}`;
324
- const lineSeparator = useHashLines ? HASHLINE_CONTENT_SEPARATOR : " ";
333
+ const lineSeparator = useHashLines ? HL_BODY_SEP : " ";
325
334
  modelOut.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
326
335
  modelOut.push(`+${afterRef}${lineSeparator}${afterLine}`);
327
336
  displayOut.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
@@ -20,6 +20,7 @@ import {
20
20
  hasGlobPathChars,
21
21
  normalizePathLikeInput,
22
22
  parseSearchPath,
23
+ partitionExistingPaths,
23
24
  resolveExplicitSearchPaths,
24
25
  resolveToCwd,
25
26
  } from "./path-utils";
@@ -171,13 +172,21 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
171
172
  }
172
173
  resolvedPathInputs.push(resource.sourcePath);
173
174
  }
174
- if (resolvedPathInputs.length === 1) {
175
- const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
175
+ let effectivePathInputs = resolvedPathInputs;
176
+ if (resolvedPathInputs.length > 1) {
177
+ const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
178
+ if (partition.valid.length === 0) {
179
+ throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
180
+ }
181
+ effectivePathInputs = partition.valid;
182
+ }
183
+ if (effectivePathInputs.length === 1) {
184
+ const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
176
185
  searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
177
186
  globFilter = parsedPath.glob;
178
187
  scopePath = formatScopePath(searchPath);
179
188
  } else {
180
- const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
189
+ const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
181
190
  if (!multiSearchPath) {
182
191
  throw new ToolError("`paths` must contain at least one path or glob");
183
192
  }
package/src/tools/find.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  formatPathRelativeToCwd,
28
28
  normalizePathLikeInput,
29
29
  parseFindPattern,
30
+ partitionExistingPaths,
30
31
  resolveExplicitFindPatterns,
31
32
  resolveToCwd,
32
33
  } from "./path-utils";
@@ -59,6 +60,10 @@ export interface FindToolDetails {
59
60
  files?: string[];
60
61
  truncated?: boolean;
61
62
  error?: string;
63
+ /** User-supplied paths whose base directory was missing on disk. The tool
64
+ * skipped these and continued with the surviving entries; surfaced as a
65
+ * non-fatal warning in the renderer and in the model-facing text. */
66
+ missingPaths?: string[];
62
67
  }
63
68
 
64
69
  /**
@@ -114,8 +119,23 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
114
119
  throw new ToolError("`paths` must contain non-empty globs or paths");
115
120
  }
116
121
 
117
- const multiPattern = await resolveExplicitFindPatterns(normalizedPatterns, this.session.cwd);
118
- const parsedPattern = multiPattern ? null : parseFindPattern(normalizedPatterns[0] ?? ".");
122
+ // Tolerate missing entries in a multi-path call: skip ones whose base
123
+ // directory is gone, and only error if every entry is missing. Single
124
+ // missing path keeps the original ENOENT semantics — the user explicitly
125
+ // asked about that one path, so silent empty results would be misleading.
126
+ let missingPaths: string[] = [];
127
+ let effectivePatterns = normalizedPatterns;
128
+ if (normalizedPatterns.length > 1 && !this.#customOps) {
129
+ const partition = await partitionExistingPaths(normalizedPatterns, this.session.cwd, parseFindPattern);
130
+ if (partition.valid.length === 0) {
131
+ throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
132
+ }
133
+ effectivePatterns = partition.valid;
134
+ missingPaths = partition.missing;
135
+ }
136
+
137
+ const multiPattern = await resolveExplicitFindPatterns(effectivePatterns, this.session.cwd);
138
+ const parsedPattern = multiPattern ? null : parseFindPattern(effectivePatterns[0] ?? ".");
119
139
  const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
120
140
  const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
121
141
  const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
@@ -124,7 +144,6 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
124
144
  if (searchPath === "/") {
125
145
  throw new ToolError("Searching from root directory '/' is not allowed");
126
146
  }
127
-
128
147
  const rawLimit = limit ?? DEFAULT_LIMIT;
129
148
  const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
130
149
  if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
@@ -141,16 +160,29 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
141
160
  });
142
161
  };
143
162
 
163
+ const missingPathsNote =
164
+ missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;
165
+
144
166
  const buildResult = (files: string[]): AgentToolResult<FindToolDetails> => {
145
167
  if (files.length === 0) {
146
- const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
147
- return toolResult(details).text("No files found matching pattern").done();
168
+ const details: FindToolDetails = {
169
+ scopePath,
170
+ fileCount: 0,
171
+ files: [],
172
+ truncated: false,
173
+ missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
174
+ };
175
+ const text = missingPathsNote
176
+ ? `No files found matching pattern\n${missingPathsNote}`
177
+ : "No files found matching pattern";
178
+ return toolResult(details).text(text).done();
148
179
  }
149
180
 
150
181
  const listLimit = applyListLimit(files, { limit: effectiveLimit });
151
182
  const limited = listLimit.items;
152
183
  const limitMeta = listLimit.meta;
153
- const rawOutput = limited.join("\n");
184
+ const baseOutput = limited.join("\n");
185
+ const rawOutput = missingPathsNote ? `${baseOutput}\n\n${missingPathsNote}` : baseOutput;
154
186
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
155
187
 
156
188
  const details: FindToolDetails = {
@@ -160,6 +192,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
160
192
  truncated: Boolean(limitMeta.resultLimit || truncation.truncated),
161
193
  resultLimitReached: limitMeta.resultLimit?.reached,
162
194
  truncation: truncation.truncated ? truncation : undefined,
195
+ missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
163
196
  };
164
197
 
165
198
  const resultBuilder = toolResult(details)
@@ -380,12 +413,18 @@ export const findToolRenderer = {
380
413
  const truncated = Boolean(details?.truncated || truncation || details?.resultLimitReached || limits?.resultLimit);
381
414
  const files = details?.files ?? [];
382
415
 
416
+ const missingPaths = details?.missingPaths ?? [];
417
+ const missingNote =
418
+ missingPaths.length > 0 ? uiTheme.fg("warning", `skipped missing: ${missingPaths.join(", ")}`) : undefined;
419
+
383
420
  if (fileCount === 0) {
384
421
  const header = renderStatusLine(
385
422
  { icon: "warning", title: "Find", description: args?.paths?.join(", "), meta: ["0 files"] },
386
423
  uiTheme,
387
424
  );
388
- return new Text([header, formatEmptyMessage("No files found", uiTheme)].join("\n"), 0, 0);
425
+ const lines = [header, formatEmptyMessage("No files found", uiTheme)];
426
+ if (missingNote) lines.push(missingNote);
427
+ return new Text(lines.join("\n"), 0, 0);
389
428
  }
390
429
  const meta: string[] = [formatCount("file", fileCount)];
391
430
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
@@ -406,6 +445,7 @@ export const findToolRenderer = {
406
445
  if (truncationReasons.length > 0) {
407
446
  extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`));
408
447
  }
448
+ if (missingNote) extraLines.push(missingNote);
409
449
 
410
450
  let cached: RenderCache | undefined;
411
451
  return {