@shanepadgett/tau-agent 0.1.0

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 (138) hide show
  1. package/README.md +30 -0
  2. package/docs/extending-tau-agent.md +119 -0
  3. package/docs/subagents.md +57 -0
  4. package/docs/tui.md +107 -0
  5. package/extensions/appshot/README.md +11 -0
  6. package/extensions/appshot/capture.swift +191 -0
  7. package/extensions/appshot/index.ts +245 -0
  8. package/extensions/appshot/native-helper.ts +102 -0
  9. package/extensions/attention/README.md +23 -0
  10. package/extensions/attention/index.ts +71 -0
  11. package/extensions/auto-name/README.md +26 -0
  12. package/extensions/auto-name/index.ts +163 -0
  13. package/extensions/branch/README.md +22 -0
  14. package/extensions/branch/index.ts +145 -0
  15. package/extensions/branch/panel.ts +148 -0
  16. package/extensions/clear-screen/README.md +29 -0
  17. package/extensions/clear-screen/index.ts +75 -0
  18. package/extensions/commit/README.md +15 -0
  19. package/extensions/commit/commit-plan.ts +328 -0
  20. package/extensions/commit/git-change-set.ts +268 -0
  21. package/extensions/commit/index.ts +138 -0
  22. package/extensions/commit/review-ui.ts +745 -0
  23. package/extensions/explore/README.md +7 -0
  24. package/extensions/explore/autoread.ts +140 -0
  25. package/extensions/explore/find.ts +147 -0
  26. package/extensions/explore/grep.ts +749 -0
  27. package/extensions/explore/index.ts +17 -0
  28. package/extensions/explore/limits.ts +22 -0
  29. package/extensions/explore/ls.ts +113 -0
  30. package/extensions/explore/path-display.ts +41 -0
  31. package/extensions/explore/path-tree.ts +150 -0
  32. package/extensions/explore/read.ts +160 -0
  33. package/extensions/explore/result.ts +29 -0
  34. package/extensions/explore/traverse.ts +226 -0
  35. package/extensions/footer/README.md +23 -0
  36. package/extensions/footer/index.ts +534 -0
  37. package/extensions/footer/settings.ts +13 -0
  38. package/extensions/ideas/README.md +10 -0
  39. package/extensions/ideas/browser.ts +66 -0
  40. package/extensions/ideas/index.ts +31 -0
  41. package/extensions/ideas/store.ts +32 -0
  42. package/extensions/image-gen/README.md +11 -0
  43. package/extensions/image-gen/client.ts +186 -0
  44. package/extensions/image-gen/index.ts +152 -0
  45. package/extensions/manage-sessions/README.md +12 -0
  46. package/extensions/manage-sessions/index.ts +58 -0
  47. package/extensions/manage-sessions/manager-ui.ts +323 -0
  48. package/extensions/manage-sessions/sessions.ts +126 -0
  49. package/extensions/patch/README.md +48 -0
  50. package/extensions/patch/executor.ts +355 -0
  51. package/extensions/patch/index.ts +170 -0
  52. package/extensions/patch/matcher.ts +236 -0
  53. package/extensions/patch/parser.ts +347 -0
  54. package/extensions/patch/render.ts +247 -0
  55. package/extensions/patch/summary.ts +35 -0
  56. package/extensions/publish/README.md +11 -0
  57. package/extensions/publish/index.ts +225 -0
  58. package/extensions/qna/README.md +45 -0
  59. package/extensions/qna/additional-context-body.ts +51 -0
  60. package/extensions/qna/body-render.ts +6 -0
  61. package/extensions/qna/choice-question-body.ts +322 -0
  62. package/extensions/qna/index.ts +245 -0
  63. package/extensions/qna/inline-editor-row.ts +56 -0
  64. package/extensions/qna/input-question-body.ts +89 -0
  65. package/extensions/qna/model.ts +317 -0
  66. package/extensions/qna/panel.ts +240 -0
  67. package/extensions/qna/ui.ts +16 -0
  68. package/extensions/reference/README.md +47 -0
  69. package/extensions/reference/index.ts +76 -0
  70. package/extensions/reference/panel.ts +874 -0
  71. package/extensions/reference/settings.ts +29 -0
  72. package/extensions/run-summary/README.md +5 -0
  73. package/extensions/run-summary/index.ts +98 -0
  74. package/extensions/silent-command-runner/README.md +25 -0
  75. package/extensions/silent-command-runner/index.ts +468 -0
  76. package/extensions/silent-command-runner/settings.ts +65 -0
  77. package/extensions/soul/README.md +19 -0
  78. package/extensions/soul/index.ts +20 -0
  79. package/extensions/soul/prompt.ts +254 -0
  80. package/extensions/soul/settings.ts +13 -0
  81. package/extensions/stash/README.md +48 -0
  82. package/extensions/stash/browser.ts +51 -0
  83. package/extensions/stash/index.ts +55 -0
  84. package/extensions/stash/store.ts +36 -0
  85. package/extensions/subagent/README.md +34 -0
  86. package/extensions/subagent/agents/scout.md +85 -0
  87. package/extensions/subagent/agents/web-research.md +95 -0
  88. package/extensions/subagent/agents.ts +196 -0
  89. package/extensions/subagent/index.ts +214 -0
  90. package/extensions/subagent/render.ts +89 -0
  91. package/extensions/subagent/run.ts +325 -0
  92. package/extensions/tau/README.md +17 -0
  93. package/extensions/tau/index.ts +122 -0
  94. package/extensions/tau-help/README.md +3 -0
  95. package/extensions/tau-help/help.md +125 -0
  96. package/extensions/tau-help/index.ts +68 -0
  97. package/extensions/turn-budget/README.md +14 -0
  98. package/extensions/turn-budget/index.ts +150 -0
  99. package/extensions/turn-budget/settings.ts +35 -0
  100. package/extensions/web/README.md +13 -0
  101. package/extensions/web/codesearch.ts +79 -0
  102. package/extensions/web/exa.ts +101 -0
  103. package/extensions/web/html.ts +67 -0
  104. package/extensions/web/index.ts +13 -0
  105. package/extensions/web/limits.ts +11 -0
  106. package/extensions/web/tool-output.ts +54 -0
  107. package/extensions/web/webfetch.ts +177 -0
  108. package/extensions/web/websearch.ts +100 -0
  109. package/package.json +57 -0
  110. package/prompts/.gitkeep +1 -0
  111. package/prompts/cavemanify.md +25 -0
  112. package/prompts/implement.md +25 -0
  113. package/prompts/interview.md +48 -0
  114. package/prompts/plan-feature.md +30 -0
  115. package/prompts/plan-implementation.md +50 -0
  116. package/schemas/tau.schema.json +205 -0
  117. package/shared/agent-blocked.ts +12 -0
  118. package/shared/description.ts +28 -0
  119. package/shared/events.ts +135 -0
  120. package/shared/git.ts +41 -0
  121. package/shared/injected-context.ts +69 -0
  122. package/shared/jsonl-store.ts +108 -0
  123. package/shared/model-fallback/index.ts +278 -0
  124. package/shared/model-fallback/settings.ts +18 -0
  125. package/shared/model-fallback/types.ts +8 -0
  126. package/shared/ranges.ts +21 -0
  127. package/shared/settings/define.ts +15 -0
  128. package/shared/settings/files.ts +20 -0
  129. package/shared/settings/json.ts +41 -0
  130. package/shared/settings/load.ts +59 -0
  131. package/shared/settings/merge.ts +21 -0
  132. package/shared/settings/paths.ts +41 -0
  133. package/shared/settings/schema.ts +25 -0
  134. package/shared/settings/specs.ts +23 -0
  135. package/shared/text.ts +11 -0
  136. package/shared/tool-row-state.ts +43 -0
  137. package/skills/writing-preferences/SKILL.md +136 -0
  138. package/themes/.gitkeep +1 -0
@@ -0,0 +1,205 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/shanepadgett/tau-agent/refs/heads/main/packages/agent/schemas/tau.schema.json",
4
+ "title": "Tau Configuration",
5
+ "description": "Configuration for Tau and Tau extensions.",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string",
10
+ "description": "Optional schema reference used by editors for validation and autocomplete."
11
+ },
12
+ "extensions": {
13
+ "type": "object",
14
+ "properties": {
15
+ "footer": {
16
+ "type": "object",
17
+ "properties": {
18
+ "enabled": {
19
+ "type": "boolean",
20
+ "default": true,
21
+ "description": "Enable Tau's compact footer."
22
+ }
23
+ },
24
+ "additionalProperties": false
25
+ },
26
+ "modelFallback": {
27
+ "type": "object",
28
+ "properties": {
29
+ "cooldowns": {
30
+ "type": "object",
31
+ "patternProperties": {
32
+ "^.*$": {
33
+ "type": "number",
34
+ "description": "Unix epoch milliseconds when the provider becomes available again."
35
+ }
36
+ }
37
+ }
38
+ },
39
+ "additionalProperties": false
40
+ },
41
+ "reference": {
42
+ "type": "object",
43
+ "properties": {
44
+ "editor": {
45
+ "anyOf": [
46
+ {
47
+ "type": "string",
48
+ "const": "default"
49
+ },
50
+ {
51
+ "type": "string",
52
+ "const": "code"
53
+ },
54
+ {
55
+ "type": "string",
56
+ "const": "zed"
57
+ }
58
+ ],
59
+ "default": "default",
60
+ "description": "Editor used by the reference picker open action. default uses $VISUAL, $EDITOR, then code."
61
+ },
62
+ "branchChoices": {
63
+ "type": "integer",
64
+ "default": 5,
65
+ "minimum": 1,
66
+ "maximum": 50,
67
+ "description": "Maximum branch choices shown when switching a reference branch."
68
+ }
69
+ },
70
+ "additionalProperties": false
71
+ },
72
+ "silentCommandRunner": {
73
+ "type": "object",
74
+ "properties": {
75
+ "enabled": {
76
+ "type": "boolean",
77
+ "default": true,
78
+ "description": "Enable silent command runner."
79
+ },
80
+ "maxOutputBytes": {
81
+ "type": "integer",
82
+ "default": 51200,
83
+ "minimum": 1,
84
+ "description": "Maximum raw stdout/stderr bytes sent to the agent."
85
+ },
86
+ "commands": {
87
+ "type": "array",
88
+ "items": {
89
+ "type": "object",
90
+ "required": [
91
+ "name",
92
+ "command"
93
+ ],
94
+ "properties": {
95
+ "name": {
96
+ "type": "string",
97
+ "description": "Short command name shown in notifications and failure messages."
98
+ },
99
+ "enabled": {
100
+ "type": "boolean",
101
+ "default": true,
102
+ "description": "Enable this command."
103
+ },
104
+ "command": {
105
+ "type": "string",
106
+ "description": "Shell command run with sh -lc after matching files change."
107
+ },
108
+ "cwd": {
109
+ "type": "string",
110
+ "default": ".",
111
+ "description": "Command working directory, relative to project root."
112
+ },
113
+ "env": {
114
+ "type": "object",
115
+ "patternProperties": {
116
+ "^.*$": {
117
+ "type": "string"
118
+ }
119
+ },
120
+ "description": "Extra environment values."
121
+ },
122
+ "details": {
123
+ "type": "string",
124
+ "description": "Free-text explanation of what this command checks or runs."
125
+ },
126
+ "includeGlobs": {
127
+ "type": "array",
128
+ "items": {
129
+ "type": "string"
130
+ },
131
+ "default": [
132
+ "**/*"
133
+ ],
134
+ "description": "Project-relative files that trigger this command."
135
+ },
136
+ "excludeGlobs": {
137
+ "type": "array",
138
+ "items": {
139
+ "type": "string"
140
+ },
141
+ "default": [],
142
+ "description": "Project-relative files ignored for this command."
143
+ },
144
+ "timeoutMs": {
145
+ "type": "integer",
146
+ "default": 120000,
147
+ "minimum": 1,
148
+ "description": "Command timeout in milliseconds."
149
+ }
150
+ },
151
+ "additionalProperties": false
152
+ },
153
+ "default": [],
154
+ "description": "Commands run after matched files change."
155
+ }
156
+ },
157
+ "additionalProperties": false
158
+ },
159
+ "soul": {
160
+ "type": "object",
161
+ "properties": {
162
+ "enabled": {
163
+ "type": "boolean",
164
+ "default": true,
165
+ "description": "Enable Tau's Rok system prompt."
166
+ }
167
+ },
168
+ "additionalProperties": false
169
+ },
170
+ "turnBudget": {
171
+ "type": "object",
172
+ "properties": {
173
+ "enabled": {
174
+ "type": "boolean",
175
+ "default": true,
176
+ "description": "Enable turn-budget hints."
177
+ },
178
+ "turnLimit": {
179
+ "type": "integer",
180
+ "default": 30,
181
+ "minimum": 1,
182
+ "description": "Initial soft cap for tool-using turns per user prompt."
183
+ },
184
+ "nudgeEveryTurns": {
185
+ "type": "integer",
186
+ "default": 5,
187
+ "minimum": 1,
188
+ "description": "Tool-using turn interval between turn-budget hints."
189
+ },
190
+ "softCapIncrement": {
191
+ "type": "integer",
192
+ "default": 10,
193
+ "minimum": 1,
194
+ "description": "Turns added when the soft cap is reached."
195
+ }
196
+ },
197
+ "additionalProperties": false
198
+ }
199
+ },
200
+ "additionalProperties": true,
201
+ "default": {}
202
+ }
203
+ },
204
+ "additionalProperties": false
205
+ }
@@ -0,0 +1,12 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { emitTauEvent } from "./events.ts";
3
+
4
+ export interface AgentBlockedNotice {
5
+ title?: string;
6
+ body?: string;
7
+ source?: string;
8
+ }
9
+
10
+ export function emitAgentBlocked(pi: Pick<ExtensionAPI, "events">, notice: AgentBlockedNotice = {}): void {
11
+ emitTauEvent(pi, "tau:agent.blocked", notice);
12
+ }
@@ -0,0 +1,28 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { browseIdeas } from "../extensions/ideas/browser.ts";
3
+ import { browseStash } from "../extensions/stash/browser.ts";
4
+
5
+ export interface DescriptionPromptResult {
6
+ text: string;
7
+ source: "manual" | "idea" | "stash";
8
+ }
9
+
10
+ export async function promptForDescription(ctx: ExtensionCommandContext): Promise<DescriptionPromptResult | null> {
11
+ while (true) {
12
+ const choice = await ctx.ui.select("Description source", [
13
+ "Write description",
14
+ "Pull from ideas",
15
+ "Pull from stash",
16
+ ]);
17
+ if (choice === undefined) return null;
18
+ if (choice === "Write description") return { text: "", source: "manual" };
19
+ if (choice === "Pull from ideas") {
20
+ const idea = await browseIdeas(ctx);
21
+ if (idea) return { text: idea.text, source: "idea" };
22
+ } else {
23
+ const stash = await browseStash(ctx);
24
+ if (stash) return { text: stash.text, source: "stash" };
25
+ }
26
+ // Browser cancelled without a pick; loop back to source select.
27
+ }
28
+ }
@@ -0,0 +1,135 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ToolRowVisualState } from "./tool-row-state.js";
3
+
4
+ export type TauAgentEvents = {
5
+ "tau:agent.blocked": {
6
+ title?: string;
7
+ body?: string;
8
+ source?: string;
9
+ };
10
+ "tau:file-mutation.applied": {
11
+ source: "patch";
12
+ toolCallId: string;
13
+ cwd: string;
14
+ status: "completed" | "partial" | "failed";
15
+ changes: Array<{
16
+ path: string;
17
+ kind: "add" | "replace" | "update" | "delete";
18
+ move?: { from: string; to: string };
19
+ linesAdded: number;
20
+ linesRemoved: number;
21
+ snapshotRanges?: Array<{ startLine: number; endLine: number }>;
22
+ }>;
23
+ };
24
+ "tau:autoread.requested": {
25
+ source: string;
26
+ title?: string;
27
+ cwd: string;
28
+ batchId: string;
29
+ files: Array<{ path: string }>;
30
+ };
31
+ "tau:footer-item": {
32
+ id: string;
33
+ text?: string;
34
+ priority?: number;
35
+ };
36
+ "tau:tool-row-state.set": {
37
+ rowId: string;
38
+ state?: ToolRowVisualState;
39
+ };
40
+ };
41
+
42
+ export interface TauFooterItem {
43
+ id: string;
44
+ text?: string;
45
+ priority?: number;
46
+ }
47
+
48
+ type EmitEventAPI = Pick<ExtensionAPI, "events">;
49
+
50
+ interface TauEventAPI extends EmitEventAPI {
51
+ on(event: "session_start", handler: () => void): void;
52
+ on(event: "session_shutdown", handler: () => void): void;
53
+ }
54
+
55
+ type TauEventHandler<Name extends keyof TauAgentEvents> = (data: TauAgentEvents[Name]) => void | Promise<void>;
56
+
57
+ interface TauEventSubscription {
58
+ stop(): void;
59
+ }
60
+
61
+ const tauEventSubscriptions = new WeakMap<
62
+ ExtensionAPI["events"],
63
+ Map<string, Map<keyof TauAgentEvents, TauEventSubscription>>
64
+ >();
65
+
66
+ export function emitTauEvent<Name extends keyof TauAgentEvents>(
67
+ pi: EmitEventAPI,
68
+ name: Name,
69
+ data: TauAgentEvents[Name],
70
+ ): void {
71
+ pi.events.emit(name, data);
72
+ }
73
+
74
+ export function onTauEvent<Name extends keyof TauAgentEvents>(
75
+ pi: TauEventAPI,
76
+ owner: string,
77
+ name: Name,
78
+ handler: TauEventHandler<Name>,
79
+ ): () => void {
80
+ if (owner.length === 0) throw new Error("Tau event owner is required.");
81
+
82
+ const subscriptions = getOwnerSubscriptions(pi.events, owner);
83
+ subscriptions.get(name)?.stop();
84
+
85
+ let unsubscribe: (() => void) | undefined;
86
+ let disposed = false;
87
+
88
+ function detach(): void {
89
+ unsubscribe?.();
90
+ unsubscribe = undefined;
91
+ }
92
+
93
+ const subscription: TauEventSubscription = {
94
+ stop() {
95
+ if (disposed) return;
96
+ disposed = true;
97
+ detach();
98
+ if (subscriptions.get(name) === subscription) subscriptions.delete(name);
99
+ },
100
+ };
101
+
102
+ function attach(): void {
103
+ if (disposed) return;
104
+ detach();
105
+ unsubscribe = pi.events.on(name, handler as (data: unknown) => void);
106
+ }
107
+
108
+ subscriptions.set(name, subscription);
109
+ pi.on("session_start", attach);
110
+ pi.on("session_shutdown", detach);
111
+ return subscription.stop;
112
+ }
113
+
114
+ export function setTauFooterItem(pi: EmitEventAPI, item: TauFooterItem): void {
115
+ emitTauEvent(pi, "tau:footer-item", item);
116
+ }
117
+
118
+ function getOwnerSubscriptions(
119
+ events: ExtensionAPI["events"],
120
+ owner: string,
121
+ ): Map<keyof TauAgentEvents, TauEventSubscription> {
122
+ let busSubscriptions = tauEventSubscriptions.get(events);
123
+ if (!busSubscriptions) {
124
+ busSubscriptions = new Map();
125
+ tauEventSubscriptions.set(events, busSubscriptions);
126
+ }
127
+
128
+ let ownerSubscriptions = busSubscriptions.get(owner);
129
+ if (!ownerSubscriptions) {
130
+ ownerSubscriptions = new Map();
131
+ busSubscriptions.set(owner, ownerSubscriptions);
132
+ }
133
+
134
+ return ownerSubscriptions;
135
+ }
package/shared/git.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ const DEFAULT_GIT_TIMEOUT_MS = 10_000;
4
+
5
+ export interface GitRunner {
6
+ cwd: string;
7
+ run(args: string[], options?: { cwd?: string; optional?: boolean; timeout?: number }): Promise<string>;
8
+ }
9
+
10
+ export function createGitRunner(pi: ExtensionAPI, ctx: ExtensionCommandContext): GitRunner {
11
+ return {
12
+ cwd: ctx.cwd,
13
+ async run(args, options = {}) {
14
+ const result = await pi.exec("git", args, {
15
+ cwd: options.cwd ?? this.cwd,
16
+ signal: ctx.signal,
17
+ timeout: options.timeout ?? DEFAULT_GIT_TIMEOUT_MS,
18
+ });
19
+ if (result.code === 0) return result.stdout.trim();
20
+ if (options.optional) return "";
21
+
22
+ const details = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
23
+ throw new Error(details || `git ${args.join(" ")} failed with exit code ${result.code}`);
24
+ },
25
+ };
26
+ }
27
+
28
+ export interface RepoStatus {
29
+ root: string;
30
+ fileCount: number;
31
+ }
32
+
33
+ // Resolves the repo root and counts dirty entries (staged, unstaged, and
34
+ // untracked). Returns null outside a git repo; returns fileCount 0 when the
35
+ // tree is clean. Callers surface each case.
36
+ export async function loadRepoStatus(git: GitRunner): Promise<RepoStatus | null> {
37
+ const root = await git.run(["rev-parse", "--show-toplevel"], { optional: true });
38
+ if (!root) return null;
39
+ const status = await git.run(["status", "--porcelain=v1", "--untracked-files=all"], { cwd: root });
40
+ return { root, fileCount: status ? status.split("\n").filter(Boolean).length : 0 };
41
+ }
@@ -0,0 +1,69 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const INJECTED_CONTEXT_TYPE = "tau.injected-context";
4
+
5
+ export interface InjectedContextDetails {
6
+ source: "review" | "tau-context";
7
+ title?: string;
8
+ }
9
+
10
+ export interface InjectedContext {
11
+ content: string;
12
+ details: InjectedContextDetails;
13
+ }
14
+
15
+ export interface InjectedContextMessage {
16
+ customType: typeof INJECTED_CONTEXT_TYPE;
17
+ content: string;
18
+ display: false;
19
+ details: InjectedContextDetails;
20
+ }
21
+
22
+ const pending: InjectedContextMessage[] = [];
23
+ const emittedThisTurn: InjectedContextMessage[] = [];
24
+
25
+ export function createInjectedContext(content: string, details: InjectedContextDetails): InjectedContextMessage {
26
+ return { customType: INJECTED_CONTEXT_TYPE, content, display: false, details };
27
+ }
28
+
29
+ export function queueInjectedContext(content: string, details: InjectedContextDetails): void {
30
+ pending.push(createInjectedContext(content, details));
31
+ }
32
+
33
+ export function shiftPendingInjectedContext(): InjectedContextMessage | undefined {
34
+ const message = pending.shift();
35
+ if (message) emittedThisTurn.push(message);
36
+ return message;
37
+ }
38
+
39
+ export function getPendingInjectedContexts(): InjectedContext[] {
40
+ return [...pending, ...emittedThisTurn].map(({ content, details }) => ({ content, details }));
41
+ }
42
+
43
+ export function clearEmittedInjectedContexts(): void {
44
+ emittedThisTurn.length = 0;
45
+ }
46
+
47
+ export function clearPendingInjectedContexts(): void {
48
+ pending.length = 0;
49
+ emittedThisTurn.length = 0;
50
+ }
51
+
52
+ export function getBranchInjectedContexts(entries: readonly SessionEntry[]): InjectedContext[] {
53
+ return entries.flatMap((entry) => {
54
+ if (entry.type !== "custom_message" || entry.customType !== INJECTED_CONTEXT_TYPE) return [];
55
+ if (typeof entry.content !== "string") return [];
56
+ const details = readDetails(entry.details);
57
+ return details ? [{ content: entry.content, details }] : [];
58
+ });
59
+ }
60
+
61
+ function readDetails(value: unknown): InjectedContextDetails | undefined {
62
+ if (!value || typeof value !== "object") return undefined;
63
+ const record = value as Record<string, unknown>;
64
+ if (record.source !== "review" && record.source !== "tau-context") return undefined;
65
+ return {
66
+ source: record.source,
67
+ ...(typeof record.title === "string" ? { title: record.title } : {}),
68
+ };
69
+ }
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { resolveProjectRoot } from "./settings/paths.ts";
5
+
6
+ // Shared storage for JSONL lists of `{ id, text, createdAt }` records.
7
+
8
+ export interface DatedRecord {
9
+ id: string;
10
+ text: string;
11
+ createdAt: number;
12
+ }
13
+
14
+ export async function recordFilePath(cwd: string, filename: string): Promise<string> {
15
+ const root = await resolveProjectRoot(cwd);
16
+ return join(root, ".pi", "tau", filename);
17
+ }
18
+
19
+ export async function loadRecords(cwd: string, filename: string, label: string): Promise<DatedRecord[]> {
20
+ const path = await recordFilePath(cwd, filename);
21
+ return loadRecordsAtPath(path, label);
22
+ }
23
+
24
+ export async function loadRecordsAtPath(path: string, label: string): Promise<DatedRecord[]> {
25
+ let raw: string;
26
+ try {
27
+ raw = await readFile(path, "utf8");
28
+ } catch (error) {
29
+ if (isErrorCode(error, "ENOENT")) return [];
30
+ throw error;
31
+ }
32
+
33
+ return sortByNewest(parseRecords(raw, path, label));
34
+ }
35
+
36
+ export async function appendRecord(cwd: string, filename: string, text: string): Promise<DatedRecord> {
37
+ const path = await recordFilePath(cwd, filename);
38
+ return appendRecordAtPath(path, text);
39
+ }
40
+
41
+ export async function appendRecordAtPath(path: string, text: string): Promise<DatedRecord> {
42
+ const record: DatedRecord = { id: randomUUID(), text: text.trim(), createdAt: Date.now() };
43
+ await mkdir(dirname(path), { recursive: true });
44
+ await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
45
+ return record;
46
+ }
47
+
48
+ // Atomic rewrite for edits/removals: temp file in the same dir, then rename
49
+ // over the target. Keeps on-disk order matching display (newest first).
50
+ export async function saveRecords(cwd: string, filename: string, records: readonly DatedRecord[]): Promise<void> {
51
+ const path = await recordFilePath(cwd, filename);
52
+ return saveRecordsAtPath(path, records);
53
+ }
54
+
55
+ export async function saveRecordsAtPath(path: string, records: readonly DatedRecord[]): Promise<void> {
56
+ await mkdir(dirname(path), { recursive: true });
57
+ const content = sortByNewest([...records])
58
+ .map((record) => JSON.stringify(record))
59
+ .join("\n");
60
+ const tmp = `${path}.${randomUUID()}.tmp`;
61
+ await writeFile(tmp, content ? `${content}\n` : "", "utf8");
62
+ await rename(tmp, path);
63
+ }
64
+
65
+ function sortByNewest(records: readonly DatedRecord[]): DatedRecord[] {
66
+ return [...records].sort((left, right) => right.createdAt - left.createdAt);
67
+ }
68
+
69
+ function parseRecords(raw: string, path: string, label: string): DatedRecord[] {
70
+ const records: DatedRecord[] = [];
71
+ let lineNumber = 0;
72
+ for (const line of raw.split("\n")) {
73
+ lineNumber++;
74
+ const trimmed = line.trim();
75
+ if (!trimmed) continue;
76
+ records.push(parseRecord(trimmed, path, lineNumber, label));
77
+ }
78
+ return records;
79
+ }
80
+
81
+ function parseRecord(line: string, path: string, lineNumber: number, label: string): DatedRecord {
82
+ let value: unknown;
83
+ try {
84
+ value = JSON.parse(line);
85
+ } catch (error) {
86
+ throw new Error(`Invalid ${label} JSONL at ${path}:${lineNumber}: ${errorText(error)}`);
87
+ }
88
+ if (!value || typeof value !== "object") throw invalidRecord(path, lineNumber, label);
89
+ const record = value as Record<string, unknown>;
90
+ const { id, text, createdAt } = record;
91
+ if (typeof id !== "string" || typeof text !== "string" || typeof createdAt !== "number") {
92
+ throw invalidRecord(path, lineNumber, label);
93
+ }
94
+ if (!Number.isFinite(createdAt)) throw invalidRecord(path, lineNumber, label);
95
+ return { id, text, createdAt };
96
+ }
97
+
98
+ function invalidRecord(path: string, lineNumber: number, label: string): Error {
99
+ return new Error(`Invalid ${label} record at ${path}:${lineNumber}`);
100
+ }
101
+
102
+ function isErrorCode(error: unknown, code: string): boolean {
103
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
104
+ }
105
+
106
+ function errorText(error: unknown): string {
107
+ return error instanceof Error ? error.message : String(error);
108
+ }