@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
@@ -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 {
@@ -47,6 +47,9 @@ const OP_TITLES: Record<string, string> = {
47
47
  pr_push: "GitHub PR Push",
48
48
  search_issues: "GitHub Search Issues",
49
49
  search_prs: "GitHub Search PRs",
50
+ search_code: "GitHub Search Code",
51
+ search_commits: "GitHub Search Commits",
52
+ search_repos: "GitHub Search Repos",
50
53
  run_watch: "GitHub Run Watch",
51
54
  };
52
55
 
@@ -99,11 +102,17 @@ function buildOpMeta(args: GithubToolRenderArgs): string[] {
99
102
  break;
100
103
  }
101
104
  case "search_issues":
102
- case "search_prs": {
105
+ case "search_prs":
106
+ case "search_code":
107
+ case "search_commits": {
103
108
  if (args.query) meta.push(truncateVisualWidth(args.query, TRUNCATE_LENGTHS.CONTENT));
104
109
  if (args.repo) meta.push(args.repo);
105
110
  break;
106
111
  }
112
+ case "search_repos": {
113
+ if (args.query) meta.push(truncateVisualWidth(args.query, TRUNCATE_LENGTHS.CONTENT));
114
+ break;
115
+ }
107
116
  case "repo_view": {
108
117
  if (args.repo) meta.push(args.repo);
109
118
  if (args.branch) meta.push(args.branch);
package/src/tools/gh.ts CHANGED
@@ -113,6 +113,24 @@ const GH_SEARCH_FIELDS = [
113
113
  "updatedAt",
114
114
  "url",
115
115
  ];
116
+ const GH_SEARCH_CODE_FIELDS = ["path", "repository", "sha", "textMatches", "url"];
117
+ const GH_SEARCH_COMMITS_FIELDS = ["author", "commit", "committer", "id", "repository", "sha", "url"];
118
+ const GH_SEARCH_REPOS_FIELDS = [
119
+ "createdAt",
120
+ "description",
121
+ "forksCount",
122
+ "fullName",
123
+ "isArchived",
124
+ "isFork",
125
+ "isPrivate",
126
+ "language",
127
+ "openIssuesCount",
128
+ "owner",
129
+ "stargazersCount",
130
+ "updatedAt",
131
+ "url",
132
+ "visibility",
133
+ ];
116
134
  const SEARCH_LIMIT_DEFAULT = 10;
117
135
  const SEARCH_LIMIT_MAX = 50;
118
136
  const FILE_PREVIEW_LIMIT = 50;
@@ -139,6 +157,9 @@ const githubSchema = Type.Object({
139
157
  "pr_push",
140
158
  "search_issues",
141
159
  "search_prs",
160
+ "search_code",
161
+ "search_commits",
162
+ "search_repos",
142
163
  "run_watch",
143
164
  ],
144
165
  { description: "github operation" },
@@ -186,11 +207,16 @@ const githubSchema = Type.Object({
186
207
  forceWithLease: Type.Optional(Type.Boolean({ description: "force-with-lease push (pr_push)" })),
187
208
  query: Type.Optional(
188
209
  Type.String({
189
- description: "search query (search_issues, search_prs)",
210
+ description: "search query (search_issues, search_prs, search_code, search_commits, search_repos)",
190
211
  examples: ["is:open label:bug"],
191
212
  }),
192
213
  ),
193
- limit: Type.Optional(Type.Number({ description: "max results (search_issues, search_prs)", default: 10 })),
214
+ limit: Type.Optional(
215
+ Type.Number({
216
+ description: "max results (search_issues, search_prs, search_code, search_commits, search_repos)",
217
+ default: 10,
218
+ }),
219
+ ),
194
220
  run: Type.Optional(Type.String({ description: "actions run id or url (run_watch)", examples: ["123456"] })),
195
221
  tail: Type.Optional(Type.Number({ description: "log lines per failed job (run_watch)", default: 15 })),
196
222
  });
@@ -414,6 +440,58 @@ interface GhSearchResult {
414
440
  url?: string;
415
441
  }
416
442
 
443
+ interface GhSearchCodeTextMatch {
444
+ fragment?: string;
445
+ property?: string;
446
+ }
447
+
448
+ interface GhSearchCodeResult {
449
+ path?: string;
450
+ repository?: GhSearchRepository | null;
451
+ sha?: string;
452
+ textMatches?: GhSearchCodeTextMatch[];
453
+ url?: string;
454
+ }
455
+
456
+ interface GhSearchCommitGitActor {
457
+ name?: string;
458
+ email?: string;
459
+ date?: string;
460
+ }
461
+
462
+ interface GhSearchCommitDetail {
463
+ author?: GhSearchCommitGitActor | null;
464
+ committer?: GhSearchCommitGitActor | null;
465
+ message?: string;
466
+ }
467
+
468
+ interface GhSearchCommitResult {
469
+ author?: GhUser | null;
470
+ commit?: GhSearchCommitDetail | null;
471
+ committer?: GhUser | null;
472
+ id?: string;
473
+ repository?: GhSearchRepository | null;
474
+ sha?: string;
475
+ url?: string;
476
+ }
477
+
478
+ interface GhSearchRepoResult {
479
+ createdAt?: string;
480
+ description?: string | null;
481
+ forksCount?: number;
482
+ fullName?: string;
483
+ isArchived?: boolean;
484
+ isFork?: boolean;
485
+ isPrivate?: boolean;
486
+ language?: string | null;
487
+ openIssuesCount?: number;
488
+ owner?: GhUser | null;
489
+ stargazersCount?: number;
490
+ updatedAt?: string;
491
+ url?: string;
492
+ visibility?: string | null;
493
+ }
494
+
417
495
  interface GhRunReference {
418
496
  repo?: string;
419
497
  runId?: number;
@@ -551,14 +629,25 @@ function appendRepoFlag(args: string[], repo: string | undefined, identifier?: s
551
629
  args.push("--repo", repo);
552
630
  }
553
631
 
632
+ const SEARCH_FIELDS_BY_COMMAND: Record<"issues" | "prs" | "code" | "commits" | "repos", readonly string[]> = {
633
+ issues: GH_SEARCH_FIELDS,
634
+ prs: GH_SEARCH_FIELDS,
635
+ code: GH_SEARCH_CODE_FIELDS,
636
+ commits: GH_SEARCH_COMMITS_FIELDS,
637
+ repos: GH_SEARCH_REPOS_FIELDS,
638
+ };
639
+
554
640
  function buildGhSearchArgs(
555
- command: "issues" | "prs",
641
+ command: "issues" | "prs" | "code" | "commits" | "repos",
556
642
  query: string,
557
643
  limit: number,
558
644
  repo: string | undefined,
559
645
  ): string[] {
560
- const args = ["search", command, "--limit", String(limit), "--json", GH_SEARCH_FIELDS.join(",")];
561
- appendRepoFlag(args, repo);
646
+ const fields = SEARCH_FIELDS_BY_COMMAND[command];
647
+ const args = ["search", command, "--limit", String(limit), "--json", fields.join(",")];
648
+ if (command !== "repos") {
649
+ appendRepoFlag(args, repo);
650
+ }
562
651
  args.push("--", query);
563
652
  return args;
564
653
  }
@@ -1826,6 +1915,94 @@ function formatSearchResults(
1826
1915
  return lines.join("\n").trim();
1827
1916
  }
1828
1917
 
1918
+ function formatSearchCodeResults(query: string, repo: string | undefined, items: GhSearchCodeResult[]): string {
1919
+ const lines: string[] = [`# GitHub code search`, "", `Query: ${query}`];
1920
+ pushLine(lines, "Repository", repo);
1921
+ pushLine(lines, "Results", items.length);
1922
+
1923
+ if (items.length === 0) {
1924
+ lines.push("");
1925
+ lines.push("No code matches found.");
1926
+ return lines.join("\n").trim();
1927
+ }
1928
+
1929
+ for (const item of items) {
1930
+ lines.push("");
1931
+ lines.push(`- ${item.path ?? "(unknown path)"}`);
1932
+ pushLine(lines, " Repo", item.repository?.nameWithOwner);
1933
+ pushLine(lines, " Commit", formatShortSha(item.sha));
1934
+ pushLine(lines, " URL", item.url);
1935
+ const fragment = item.textMatches?.find(match => match.fragment)?.fragment;
1936
+ if (fragment) {
1937
+ pushLine(lines, " Match", normalizeText(fragment).split("\n", 1)[0]);
1938
+ }
1939
+ }
1940
+
1941
+ return lines.join("\n").trim();
1942
+ }
1943
+
1944
+ function formatSearchCommitMessage(message: string | undefined): string | undefined {
1945
+ if (!message) return undefined;
1946
+ const firstLine = normalizeText(message).split("\n", 1)[0];
1947
+ return firstLine || undefined;
1948
+ }
1949
+
1950
+ function formatSearchCommitsResults(query: string, repo: string | undefined, items: GhSearchCommitResult[]): string {
1951
+ const lines: string[] = [`# GitHub commits search`, "", `Query: ${query}`];
1952
+ pushLine(lines, "Repository", repo);
1953
+ pushLine(lines, "Results", items.length);
1954
+
1955
+ if (items.length === 0) {
1956
+ lines.push("");
1957
+ lines.push("No commits found.");
1958
+ return lines.join("\n").trim();
1959
+ }
1960
+
1961
+ for (const item of items) {
1962
+ lines.push("");
1963
+ const sha = formatShortSha(item.sha) ?? "(unknown sha)";
1964
+ const subject = formatSearchCommitMessage(item.commit?.message) ?? "(no commit message)";
1965
+ lines.push(`- ${sha} ${subject}`);
1966
+ pushLine(lines, " Repo", item.repository?.nameWithOwner);
1967
+ pushLine(lines, " Author", formatAuthor(item.author) ?? item.commit?.author?.name);
1968
+ pushLine(lines, " Date", item.commit?.author?.date ?? item.commit?.committer?.date);
1969
+ pushLine(lines, " URL", item.url);
1970
+ }
1971
+
1972
+ return lines.join("\n").trim();
1973
+ }
1974
+
1975
+ function formatSearchReposResults(query: string, items: GhSearchRepoResult[]): string {
1976
+ const lines: string[] = [`# GitHub repositories search`, "", `Query: ${query}`];
1977
+ pushLine(lines, "Results", items.length);
1978
+
1979
+ if (items.length === 0) {
1980
+ lines.push("");
1981
+ lines.push("No repositories found.");
1982
+ return lines.join("\n").trim();
1983
+ }
1984
+
1985
+ for (const item of items) {
1986
+ lines.push("");
1987
+ lines.push(`- ${item.fullName ?? "(unknown repository)"}`);
1988
+ const description = normalizeText(item.description).split("\n", 1)[0];
1989
+ if (description) {
1990
+ pushLine(lines, " Description", description);
1991
+ }
1992
+ pushLine(lines, " Language", item.language ?? undefined);
1993
+ pushLine(lines, " Stars", item.stargazersCount);
1994
+ pushLine(lines, " Forks", item.forksCount);
1995
+ pushLine(lines, " Open issues", item.openIssuesCount);
1996
+ pushLine(lines, " Visibility", item.visibility ?? undefined);
1997
+ pushLine(lines, " Archived", item.isArchived);
1998
+ pushLine(lines, " Fork", item.isFork);
1999
+ pushLine(lines, " Updated", item.updatedAt);
2000
+ pushLine(lines, " URL", item.url);
2001
+ }
2002
+
2003
+ return lines.join("\n").trim();
2004
+ }
2005
+
1829
2006
  async function saveArtifactText(session: ToolSession, toolType: string, text: string): Promise<string | undefined> {
1830
2007
  const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.(toolType)) ?? {};
1831
2008
  if (!artifactPath || !artifactId) {
@@ -1898,6 +2075,12 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
1898
2075
  return executeSearchIssues(this.session, params, signal);
1899
2076
  case "search_prs":
1900
2077
  return executeSearchPrs(this.session, params, signal);
2078
+ case "search_code":
2079
+ return executeSearchCode(this.session, params, signal);
2080
+ case "search_commits":
2081
+ return executeSearchCommits(this.session, params, signal);
2082
+ case "search_repos":
2083
+ return executeSearchRepos(this.session, params, signal);
1901
2084
  case "run_watch":
1902
2085
  return executeRunWatch(this.session, this.name, params, signal, onUpdate);
1903
2086
  }
@@ -2291,6 +2474,51 @@ async function executeSearchPrs(
2291
2474
  return buildTextResult(formatSearchResults("pull requests", query, repo, items));
2292
2475
  }
2293
2476
 
2477
+ async function executeSearchCode(
2478
+ session: ToolSession,
2479
+ params: GithubInput,
2480
+ signal: AbortSignal | undefined,
2481
+ ): Promise<AgentToolResult<GhToolDetails>> {
2482
+ const query = requireNonEmpty(params.query, "query");
2483
+ const repo = normalizeOptionalString(params.repo);
2484
+ const limit = resolveSearchLimit(params.limit);
2485
+ const args = buildGhSearchArgs("code", query, limit, repo);
2486
+
2487
+ const items = await git.github.json<GhSearchCodeResult[]>(session.cwd, args, signal, {
2488
+ repoProvided: Boolean(repo),
2489
+ });
2490
+ return buildTextResult(formatSearchCodeResults(query, repo, items));
2491
+ }
2492
+
2493
+ async function executeSearchCommits(
2494
+ session: ToolSession,
2495
+ params: GithubInput,
2496
+ signal: AbortSignal | undefined,
2497
+ ): Promise<AgentToolResult<GhToolDetails>> {
2498
+ const query = requireNonEmpty(params.query, "query");
2499
+ const repo = normalizeOptionalString(params.repo);
2500
+ const limit = resolveSearchLimit(params.limit);
2501
+ const args = buildGhSearchArgs("commits", query, limit, repo);
2502
+
2503
+ const items = await git.github.json<GhSearchCommitResult[]>(session.cwd, args, signal, {
2504
+ repoProvided: Boolean(repo),
2505
+ });
2506
+ return buildTextResult(formatSearchCommitsResults(query, repo, items));
2507
+ }
2508
+
2509
+ async function executeSearchRepos(
2510
+ session: ToolSession,
2511
+ params: GithubInput,
2512
+ signal: AbortSignal | undefined,
2513
+ ): Promise<AgentToolResult<GhToolDetails>> {
2514
+ const query = requireNonEmpty(params.query, "query");
2515
+ const limit = resolveSearchLimit(params.limit);
2516
+ const args = buildGhSearchArgs("repos", query, limit, undefined);
2517
+
2518
+ const items = await git.github.json<GhSearchRepoResult[]>(session.cwd, args, signal);
2519
+ return buildTextResult(formatSearchReposResults(query, items));
2520
+ }
2521
+
2294
2522
  async function executeRunWatch(
2295
2523
  session: ToolSession,
2296
2524
  toolName: string,
@@ -0,0 +1,70 @@
1
+ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
+ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
3
+ import { type Static, Type } from "@sinclair/typebox";
4
+ import { getHindsightSessionState } from "../hindsight/backend";
5
+ import { formatCurrentTime, formatMemories } from "../hindsight/content";
6
+ import recallDescription from "../prompts/tools/recall.md" with { type: "text" };
7
+ import type { ToolSession } from ".";
8
+
9
+ const hindsightRecallSchema = Type.Object({
10
+ query: Type.String({
11
+ description: "Natural language search query. Be specific about what you need to know.",
12
+ }),
13
+ });
14
+
15
+ export type HindsightRecallParams = Static<typeof hindsightRecallSchema>;
16
+
17
+ export class HindsightRecallTool implements AgentTool<typeof hindsightRecallSchema> {
18
+ readonly name = "recall";
19
+ readonly label = "Recall";
20
+ readonly description = recallDescription;
21
+ readonly parameters = hindsightRecallSchema;
22
+ readonly strict = true;
23
+
24
+ constructor(private readonly session: ToolSession) {}
25
+
26
+ static createIf(session: ToolSession): HindsightRecallTool | null {
27
+ if (session.settings.get("memory.backend") !== "hindsight") return null;
28
+ return new HindsightRecallTool(session);
29
+ }
30
+
31
+ async execute(_id: string, params: HindsightRecallParams, signal?: AbortSignal): Promise<AgentToolResult> {
32
+ return untilAborted(signal, async () => {
33
+ const sessionId = this.session.getSessionId?.();
34
+ const state = sessionId ? getHindsightSessionState(sessionId) : undefined;
35
+ if (!state) {
36
+ throw new Error("Hindsight backend is not initialised for this session.");
37
+ }
38
+
39
+ try {
40
+ const response = await state.client.recall(state.bankId, params.query, {
41
+ budget: state.config.recallBudget,
42
+ maxTokens: state.config.recallMaxTokens,
43
+ types: state.config.recallTypes.length > 0 ? state.config.recallTypes : undefined,
44
+ tags: state.recallTags,
45
+ tagsMatch: state.recallTagsMatch,
46
+ });
47
+ const results = response.results ?? [];
48
+ if (results.length === 0) {
49
+ return {
50
+ content: [{ type: "text", text: "No relevant memories found." }],
51
+ details: {},
52
+ };
53
+ }
54
+ const formatted = formatMemories(results);
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: `Found ${results.length} relevant memories (as of ${formatCurrentTime()} UTC):\n\n${formatted}`,
60
+ },
61
+ ],
62
+ details: {},
63
+ };
64
+ } catch (err) {
65
+ logger.warn("recall failed", { bankId: state.bankId, error: String(err) });
66
+ throw err instanceof Error ? err : new Error(String(err));
67
+ }
68
+ });
69
+ }
70
+ }
@@ -0,0 +1,57 @@
1
+ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
+ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
3
+ import { type Static, Type } from "@sinclair/typebox";
4
+ import { getHindsightSessionState } from "../hindsight/backend";
5
+ import { ensureBankMission } from "../hindsight/bank";
6
+ import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" };
7
+ import type { ToolSession } from ".";
8
+
9
+ const hindsightReflectSchema = Type.Object({
10
+ query: Type.String({ description: "The question to answer using long-term memory." }),
11
+ context: Type.Optional(Type.String({ description: "Optional additional context to guide the reflection." })),
12
+ });
13
+
14
+ export type HindsightReflectParams = Static<typeof hindsightReflectSchema>;
15
+
16
+ export class HindsightReflectTool implements AgentTool<typeof hindsightReflectSchema> {
17
+ readonly name = "reflect";
18
+ readonly label = "Reflect";
19
+ readonly description = reflectDescription;
20
+ readonly parameters = hindsightReflectSchema;
21
+ readonly strict = true;
22
+
23
+ constructor(private readonly session: ToolSession) {}
24
+
25
+ static createIf(session: ToolSession): HindsightReflectTool | null {
26
+ if (session.settings.get("memory.backend") !== "hindsight") return null;
27
+ return new HindsightReflectTool(session);
28
+ }
29
+
30
+ async execute(_id: string, params: HindsightReflectParams, signal?: AbortSignal): Promise<AgentToolResult> {
31
+ return untilAborted(signal, async () => {
32
+ const sessionId = this.session.getSessionId?.();
33
+ const state = sessionId ? getHindsightSessionState(sessionId) : undefined;
34
+ if (!state) {
35
+ throw new Error("Hindsight backend is not initialised for this session.");
36
+ }
37
+
38
+ try {
39
+ await ensureBankMission(state.client, state.bankId, state.config, state.missionsSet);
40
+ const response = await state.client.reflect(state.bankId, params.query, {
41
+ context: params.context,
42
+ budget: state.config.recallBudget,
43
+ tags: state.recallTags,
44
+ tagsMatch: state.recallTagsMatch,
45
+ });
46
+ const text = response.text?.trim() || "No relevant information found to reflect on.";
47
+ return {
48
+ content: [{ type: "text", text }],
49
+ details: {},
50
+ };
51
+ } catch (err) {
52
+ logger.warn("reflect failed", { bankId: state.bankId, error: String(err) });
53
+ throw err instanceof Error ? err : new Error(String(err));
54
+ }
55
+ });
56
+ }
57
+ }