@oh-my-pi/pi-coding-agent 14.6.1 → 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 (63) hide show
  1. package/CHANGELOG.md +82 -1
  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 +595 -100
  8. package/src/config/settings.ts +46 -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 +4 -2
  32. package/src/modes/components/status-line/presets.ts +1 -1
  33. package/src/modes/components/status-line.ts +4 -1
  34. package/src/modes/controllers/command-controller.ts +6 -5
  35. package/src/modes/controllers/event-controller.ts +12 -0
  36. package/src/modes/controllers/mcp-command-controller.ts +23 -0
  37. package/src/modes/controllers/selector-controller.ts +10 -12
  38. package/src/modes/interactive-mode.ts +3 -2
  39. package/src/modes/theme/theme.ts +4 -0
  40. package/src/prompts/tools/github.md +3 -0
  41. package/src/prompts/tools/hashline.md +20 -16
  42. package/src/prompts/tools/read.md +10 -6
  43. package/src/prompts/tools/recall.md +5 -0
  44. package/src/prompts/tools/reflect.md +5 -0
  45. package/src/prompts/tools/retain.md +5 -0
  46. package/src/prompts/tools/search.md +1 -1
  47. package/src/sdk.ts +12 -9
  48. package/src/session/agent-session.ts +75 -3
  49. package/src/slash-commands/builtin-registry.ts +2 -12
  50. package/src/ssh/connection-manager.ts +1 -1
  51. package/src/tools/ast-edit.ts +14 -5
  52. package/src/tools/ast-grep.ts +12 -3
  53. package/src/tools/find.ts +47 -7
  54. package/src/tools/gh-renderer.ts +10 -1
  55. package/src/tools/gh.ts +233 -5
  56. package/src/tools/hindsight-recall.ts +70 -0
  57. package/src/tools/hindsight-reflect.ts +57 -0
  58. package/src/tools/hindsight-retain.ts +63 -0
  59. package/src/tools/index.ts +17 -0
  60. package/src/tools/output-meta.ts +1 -0
  61. package/src/tools/path-utils.ts +55 -0
  62. package/src/tools/read.ts +1 -1
  63. package/src/tools/search.ts +45 -8
@@ -0,0 +1,63 @@
1
+ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
+ import { type Static, Type } from "@sinclair/typebox";
3
+ import { getHindsightSessionState } from "../hindsight/backend";
4
+ import { enqueueRetain } from "../hindsight/retain-queue";
5
+ import retainDescription from "../prompts/tools/retain.md" with { type: "text" };
6
+ import type { ToolSession } from ".";
7
+
8
+ const hindsightRetainSchema = Type.Object({
9
+ items: Type.Array(
10
+ Type.Object({
11
+ content: Type.String({
12
+ description: "The information to remember. Be specific and self-contained — include who, what, when, why.",
13
+ }),
14
+ context: Type.Optional(
15
+ Type.String({ description: "Optional context describing where this information came from." }),
16
+ ),
17
+ }),
18
+ {
19
+ minItems: 1,
20
+ description:
21
+ "One or more memories to retain. Batch related facts in a single call rather than calling retain repeatedly — they are deduplicated and consolidated together.",
22
+ },
23
+ ),
24
+ });
25
+
26
+ export type HindsightRetainParams = Static<typeof hindsightRetainSchema>;
27
+ export class HindsightRetainTool implements AgentTool<typeof hindsightRetainSchema> {
28
+ readonly name = "retain";
29
+ readonly label = "Retain";
30
+ readonly description = retainDescription;
31
+ readonly parameters = hindsightRetainSchema;
32
+ readonly strict = true;
33
+
34
+ constructor(private readonly session: ToolSession) {}
35
+
36
+ static createIf(session: ToolSession): HindsightRetainTool | null {
37
+ if (session.settings.get("memory.backend") !== "hindsight") return null;
38
+ return new HindsightRetainTool(session);
39
+ }
40
+
41
+ async execute(_id: string, params: HindsightRetainParams): Promise<AgentToolResult> {
42
+ const sessionId = this.session.getSessionId?.();
43
+ const state = sessionId ? getHindsightSessionState(sessionId) : undefined;
44
+ if (!state || !sessionId) {
45
+ throw new Error("Hindsight backend is not initialised for this session.");
46
+ }
47
+
48
+ // Push every item onto the global queue and return immediately. The
49
+ // queue flushes either when it reaches its batch threshold or when its
50
+ // debounce timer fires. If the eventual batch fails, the queue
51
+ // surfaces a UI-only warning notice — the LLM is not informed.
52
+ for (const item of params.items) {
53
+ enqueueRetain(sessionId, item.content, item.context);
54
+ }
55
+
56
+ const count = params.items.length;
57
+ const noun = count === 1 ? "memory" : "memories";
58
+ return {
59
+ content: [{ type: "text", text: `${count} ${noun} queued.` }],
60
+ details: { count },
61
+ };
62
+ }
63
+ }
@@ -30,6 +30,9 @@ import { EvalTool } from "./eval";
30
30
  import { ExitPlanModeTool } from "./exit-plan-mode";
31
31
  import { FindTool } from "./find";
32
32
  import { GithubTool } from "./gh";
33
+ import { HindsightRecallTool } from "./hindsight-recall";
34
+ import { HindsightReflectTool } from "./hindsight-reflect";
35
+ import { HindsightRetainTool } from "./hindsight-retain";
33
36
  import { InspectImageTool } from "./inspect-image";
34
37
  import { IrcTool } from "./irc";
35
38
  import { JobTool } from "./job";
@@ -69,6 +72,9 @@ export * from "./eval";
69
72
  export * from "./exit-plan-mode";
70
73
  export * from "./find";
71
74
  export * from "./gh";
75
+ export * from "./hindsight-recall";
76
+ export * from "./hindsight-reflect";
77
+ export * from "./hindsight-retain";
72
78
  export * from "./image-gen";
73
79
  export * from "./inspect-image";
74
80
  export * from "./irc";
@@ -231,6 +237,9 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
231
237
  web_search: s => new WebSearchTool(s),
232
238
  search_tool_bm25: SearchToolBm25Tool.createIf,
233
239
  write: s => new WriteTool(s),
240
+ retain: HindsightRetainTool.createIf,
241
+ recall: HindsightRecallTool.createIf,
242
+ reflect: HindsightReflectTool.createIf,
234
243
  };
235
244
 
236
245
  export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
@@ -342,6 +351,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
342
351
  ) {
343
352
  requestedTools.push("recipe");
344
353
  }
354
+ if (session.settings.get("memory.backend") === "hindsight") {
355
+ for (const name of ["recall", "retain", "reflect"]) {
356
+ if (!requestedTools.includes(name)) requestedTools.push(name);
357
+ }
358
+ }
345
359
  }
346
360
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
347
361
  const isToolAllowed = (name: string) => {
@@ -365,6 +379,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
365
379
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
366
380
  if (name === "irc") return session.settings.get("irc.enabled");
367
381
  if (name === "recipe") return session.settings.get("recipe.enabled");
382
+ if (name === "retain" || name === "recall" || name === "reflect") {
383
+ return session.settings.get("memory.backend") === "hindsight";
384
+ }
368
385
  if (name === "task") {
369
386
  const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
370
387
  const currentDepth = session.taskDepth ?? 0;
@@ -465,6 +465,7 @@ async function spillLargeResultToArtifact(
465
465
  ): Promise<AgentToolResult> {
466
466
  const sessionManager = context?.sessionManager;
467
467
  if (!sessionManager) return result;
468
+ if (toolName === "read") return result;
468
469
  const { threshold, tailBytes, tailLines } = getSpillConfig(context?.settings);
469
470
 
470
471
  // Skip if tool already saved an artifact
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
+ import { isEnoent } from "@oh-my-pi/pi-utils";
5
6
 
6
7
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
7
8
  const NARROW_NO_BREAK_SPACE = "\u202F";
@@ -442,6 +443,60 @@ export async function resolveExplicitFindPatterns(
442
443
  return resolveFindPatternItems([...new Set(patternItems)], cwd);
443
444
  }
444
445
 
446
+ /**
447
+ * Result of partitioning a list of user-supplied paths/globs into entries whose
448
+ * base directory currently exists on disk versus those that do not.
449
+ *
450
+ * Used by multi-path tools (search, find, ast_grep, ast_edit) to tolerate one
451
+ * or more missing entries in a multi-path call: the surviving entries should
452
+ * still be searched, with the missing entries surfaced as a non-fatal warning.
453
+ */
454
+ export interface PartitionedPaths {
455
+ /** Raw input strings whose resolved base path exists. */
456
+ valid: string[];
457
+ /** Raw input strings whose resolved base path is missing (ENOENT). */
458
+ missing: string[];
459
+ }
460
+
461
+ /**
462
+ * Stat each input's base path concurrently; return entries split by existence.
463
+ *
464
+ * `splitter` is expected to be {@link parseFindPattern} or
465
+ * {@link parseSearchPath}: both return a `basePath` field that this helper
466
+ * resolves against `cwd` and stats. ENOENT is the only swallowed error — every
467
+ * other stat failure (permission, IO, etc.) propagates so callers do not silently
468
+ * skip paths that exist but are unreadable.
469
+ *
470
+ * Order of `valid` and `missing` follows the input order, so callers can rely
471
+ * on `valid[0]` matching the first surviving user-supplied entry.
472
+ */
473
+ export async function partitionExistingPaths(
474
+ items: string[],
475
+ cwd: string,
476
+ splitter: (item: string) => { basePath: string },
477
+ ): Promise<PartitionedPaths> {
478
+ const settled = await Promise.all(
479
+ items.map(async item => {
480
+ const { basePath } = splitter(item);
481
+ const absoluteBasePath = resolveToCwd(basePath, cwd);
482
+ try {
483
+ await fs.promises.stat(absoluteBasePath);
484
+ return { item, exists: true } as const;
485
+ } catch (err) {
486
+ if (isEnoent(err)) return { item, exists: false } as const;
487
+ throw err;
488
+ }
489
+ }),
490
+ );
491
+ const valid: string[] = [];
492
+ const missing: string[] = [];
493
+ for (const entry of settled) {
494
+ if (entry.exists) valid.push(entry.item);
495
+ else missing.push(entry.item);
496
+ }
497
+ return { valid, missing };
498
+ }
499
+
445
500
  export function resolveReadPath(filePath: string, cwd: string): string {
446
501
  const resolved = resolveToCwd(filePath, cwd);
447
502
  const shellEscapedVariant = tryShellEscapedPath(resolved);
package/src/tools/read.ts CHANGED
@@ -472,7 +472,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
472
472
  this.description = prompt.render(readDescription, {
473
473
  DEFAULT_LIMIT: String(this.#defaultLimit),
474
474
  DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
475
- IS_HASHLINE_MODE: displayMode.hashLines,
475
+ IS_HL_MODE: displayMode.hashLines,
476
476
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
477
477
  });
478
478
  }
@@ -22,6 +22,7 @@ import {
22
22
  hasGlobPathChars,
23
23
  normalizePathLikeInput,
24
24
  parseSearchPath,
25
+ partitionExistingPaths,
25
26
  resolveExplicitSearchPaths,
26
27
  resolveToCwd,
27
28
  } from "./path-utils";
@@ -68,6 +69,10 @@ export interface SearchToolDetails {
68
69
  * `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs space for
69
70
  * context). The TUI uses this directly so it never parses model-facing hashline anchors. */
70
71
  displayContent?: string;
72
+ /** User-supplied paths whose base directory was missing on disk. The tool
73
+ * skipped these and continued with the surviving entries; surfaced as a
74
+ * non-fatal warning in the renderer and in the model-facing text. */
75
+ missingPaths?: string[];
71
76
  }
72
77
 
73
78
  type SearchParams = Static<typeof searchSchema>;
@@ -82,7 +87,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
82
87
  constructor(private readonly session: ToolSession) {
83
88
  const displayMode = resolveFileDisplayMode(session);
84
89
  this.description = prompt.render(searchDescription, {
85
- IS_HASHLINE_MODE: displayMode.hashLines,
90
+ IS_HL_MODE: displayMode.hashLines,
86
91
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
87
92
  });
88
93
  }
@@ -140,13 +145,26 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
140
145
  }
141
146
  resolvedPathInputs.push(resource.sourcePath);
142
147
  }
143
- if (resolvedPathInputs.length === 1) {
144
- const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
148
+ // Tolerate missing entries in a multi-path call: skip ones whose base
149
+ // directory is gone, and only error if every entry is missing. Single
150
+ // missing path keeps the original ENOENT semantics.
151
+ let missingPaths: string[] = [];
152
+ let effectivePaths = resolvedPathInputs;
153
+ if (resolvedPathInputs.length > 1) {
154
+ const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
155
+ if (partition.valid.length === 0) {
156
+ throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
157
+ }
158
+ effectivePaths = partition.valid;
159
+ missingPaths = partition.missing;
160
+ }
161
+ if (effectivePaths.length === 1) {
162
+ const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
145
163
  searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
146
164
  globFilter = parsedPath.glob;
147
165
  scopePath = formatScopePath(searchPath);
148
166
  } else {
149
- const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
167
+ const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, this.session.cwd, globFilter);
150
168
  if (!multiSearchPath) {
151
169
  throw new ToolError("`paths` must contain at least one path or glob");
152
170
  }
@@ -285,6 +303,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
285
303
  const limitMessage = `Result limit reached; narrow paths or use skip=${nextSkip}.`;
286
304
  const { record: recordFile, list: fileList } = createFileRecorder();
287
305
  const fileMatchCounts = new Map<string, number>();
306
+ const missingPathsNote =
307
+ missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;
288
308
  if (selectedMatches.length === 0) {
289
309
  const details: SearchToolDetails = {
290
310
  scopePath,
@@ -292,8 +312,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
292
312
  fileCount: 0,
293
313
  files: [],
294
314
  truncated: false,
315
+ missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
295
316
  };
296
- return toolResult(details).text("No matches found").done();
317
+ const text = missingPathsNote ? `No matches found\n${missingPathsNote}` : "No matches found";
318
+ return toolResult(details).text(text).done();
297
319
  }
298
320
  const outputLines: string[] = [];
299
321
  let linesTruncated = false;
@@ -365,6 +387,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
365
387
  if (matchLimitReached || result.limitReached) {
366
388
  outputLines.push("", limitMessage);
367
389
  }
390
+ if (missingPathsNote) {
391
+ outputLines.push("", missingPathsNote);
392
+ }
368
393
  const rawOutput = outputLines.join("\n");
369
394
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
370
395
  const output = truncation.content;
@@ -382,6 +407,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
382
407
  matchLimitReached: matchLimitReached ? effectiveLimit : undefined,
383
408
  resultLimitReached: result.limitReached ? internalLimit : undefined,
384
409
  displayContent: displayLines.join("\n"),
410
+ missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
385
411
  };
386
412
  if (truncation.truncated) details.truncation = truncation;
387
413
  if (linesTruncated) details.linesTruncated = true;
@@ -487,12 +513,20 @@ export const searchToolRenderer = {
487
513
  details?.truncated || truncation || limits?.matchLimit || limits?.resultLimit || limits?.columnTruncated,
488
514
  );
489
515
 
516
+ const missingPathsList = details?.missingPaths ?? [];
517
+ const missingNote =
518
+ missingPathsList.length > 0
519
+ ? uiTheme.fg("warning", `skipped missing: ${missingPathsList.join(", ")}`)
520
+ : undefined;
521
+
490
522
  if (matchCount === 0) {
491
523
  const header = renderStatusLine(
492
524
  { icon: "warning", title: "Search", description: args?.pattern, meta: ["0 matches"] },
493
525
  uiTheme,
494
526
  );
495
- return new Text([header, formatEmptyMessage("No matches found", uiTheme)].join("\n"), 0, 0);
527
+ const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
528
+ if (missingNote) lines.push(missingNote);
529
+ return new Text(lines.join("\n"), 0, 0);
496
530
  }
497
531
 
498
532
  const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
@@ -538,8 +572,11 @@ export const searchToolRenderer = {
538
572
  if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
539
573
  if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.artifactId));
540
574
 
541
- const extraLines =
542
- truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : [];
575
+ const extraLines: string[] = [];
576
+ if (truncationReasons.length > 0) {
577
+ extraLines.push(uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`));
578
+ }
579
+ if (missingNote) extraLines.push(missingNote);
543
580
 
544
581
  let cached: RenderCache | undefined;
545
582
  return {