@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
@@ -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
+ }
@@ -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;
@@ -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
  }