@tduyng/pi-harness-loop 0.0.0-stage → 0.0.2

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.
package/src/replay.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { Validator } from 'ata-validator'
5
+ import { t } from 'ata-validator/t'
6
+
7
+
8
+ import { runCheck } from './check.js'
9
+ import type { Recurrence } from './corpus.js'
10
+ import { harnessDir } from './store.js'
11
+
12
+ export const REPLAY_TTL_MS = 24 * 60 * 60 * 1000
13
+ export const REPLAY_TIMEOUT_MS = 60_000
14
+
15
+ export interface Replay {
16
+ at: number
17
+ id: string
18
+ command: string
19
+ ok: boolean
20
+ exitCode: number | null
21
+ output: string
22
+ }
23
+
24
+ export type ReplayVerdict = 'stale' | 'live' | 'unknown'
25
+
26
+ const ReplaySchema = t.object({
27
+ at: t.number(),
28
+ id: t.string(),
29
+ command: t.string(),
30
+ ok: t.boolean(),
31
+ exitCode: t.union([t.number(), t.null()]),
32
+ output: t.string()
33
+ })
34
+ const isReplaySchema = new Validator(ReplaySchema).isValidObject
35
+
36
+ const READ_ONLY_BINS = new Set([
37
+ '[',
38
+ 'awk',
39
+ 'basename',
40
+ 'cat',
41
+ 'comm',
42
+ 'cut',
43
+ 'date',
44
+ 'diff',
45
+ 'dirname',
46
+ 'echo',
47
+ 'fd',
48
+ 'fdfind',
49
+ 'grep',
50
+ 'head',
51
+ 'id',
52
+ 'jq',
53
+ 'ls',
54
+ 'printenv',
55
+ 'printf',
56
+ 'pwd',
57
+ 'realpath',
58
+ 'rg',
59
+ 'sort',
60
+ 'stat',
61
+ 'tail',
62
+ 'test',
63
+ 'tr',
64
+ 'type',
65
+ 'uniq',
66
+ 'wc',
67
+ 'which'
68
+ ])
69
+
70
+ const GIT_READ = new Set([
71
+ 'blame',
72
+ 'branch',
73
+ 'describe',
74
+ 'diff',
75
+ 'grep',
76
+ 'log',
77
+ 'ls-files',
78
+ 'remote',
79
+ 'rev-parse',
80
+ 'show',
81
+ 'status'
82
+ ])
83
+
84
+ const SHELL_SYNTAX = /[<>&;`\n]|\$\(/
85
+
86
+ function segmentAllowed(segment: string): boolean {
87
+ const [bin, ...args] = segment.split(/\s+/)
88
+ if (bin === undefined) return false
89
+ if (args.some((arg) => arg.startsWith('--output'))) return false
90
+ if (bin === 'git') {
91
+ const sub = args[0] ?? ''
92
+ if (!GIT_READ.has(sub)) return false
93
+ if (sub === 'branch') {
94
+ return args.length === 1 || args.slice(1).every((arg) => /^(-a|-r|-v|--list|--all|--remotes)$/.test(arg))
95
+ }
96
+ if (sub === 'remote') return args.length === 1 || args[1] === '-v' || args[1] === 'show'
97
+ return true
98
+ }
99
+ if (bin === 'sed') return !args.some((arg) => arg === '-i' || arg.startsWith('--in-place'))
100
+ if (bin === 'npx') return args[0] === 'tsc' && args.includes('--noEmit')
101
+ if (bin === 'node') return args.length === 1 && args[0] === '--version'
102
+ if (bin === 'find') return !args.some((arg) => /^-(delete|exec|execdir|ok)$/.test(arg))
103
+ if (bin === 'awk') return !args.some((arg) => arg.includes('system('))
104
+ return READ_ONLY_BINS.has(bin)
105
+ }
106
+
107
+ export function canReplay(command: string): boolean {
108
+ const trimmed = command
109
+ .trim()
110
+ .replace(/\s*2>&1\s*/g, ' ')
111
+ .trim()
112
+ if (trimmed === '' || trimmed.length > 600 || SHELL_SYNTAX.test(trimmed)) return false
113
+ return trimmed.split('|').every((segment) => segmentAllowed(segment.trim()))
114
+ }
115
+
116
+ function replayFile(cwd: string): string {
117
+ return join(harnessDir(cwd), 'replay.jsonl')
118
+ }
119
+
120
+ export function readReplays(cwd: string): Replay[] {
121
+ const path = replayFile(cwd)
122
+ if (!existsSync(path)) return []
123
+ const out: Replay[] = []
124
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
125
+ if (line.trim() === '') continue
126
+ let parsed: unknown
127
+ try {
128
+ parsed = JSON.parse(line)
129
+ } catch {
130
+ continue
131
+ }
132
+ if (isReplaySchema(parsed)) out.push(parsed)
133
+ }
134
+ return out
135
+ }
136
+
137
+ export function appendReplay(cwd: string, replay: Replay): void {
138
+ mkdirSync(harnessDir(cwd), { recursive: true })
139
+ appendFileSync(replayFile(cwd), `${JSON.stringify(replay)}\n`, 'utf8')
140
+ }
141
+
142
+ export function replayVerdict(cwd: string, recurrence: Recurrence): ReplayVerdict {
143
+ if (recurrence.command === undefined) return 'unknown'
144
+ const latest = readReplays(cwd)
145
+ .filter((replay) => replay.id === recurrence.id && replay.command === recurrence.command)
146
+ .toSorted((a, b) => b.at - a.at)[0]
147
+ if (latest === undefined || Date.now() - latest.at > REPLAY_TTL_MS) return 'unknown'
148
+ return latest.ok ? 'stale' : 'live'
149
+ }
150
+
151
+ export async function runReplay(cwd: string, id: string, command: string): Promise<Replay | null> {
152
+ if (!canReplay(command)) return null
153
+ const result = await runCheck(cwd, command, { timeoutMs: REPLAY_TIMEOUT_MS })
154
+ const replay: Replay = {
155
+ at: Date.now(),
156
+ id,
157
+ command,
158
+ ok: result.ok,
159
+ exitCode: result.exitCode,
160
+ output: result.output.slice(0, 2000)
161
+ }
162
+ appendReplay(cwd, replay)
163
+ return replay
164
+ }
165
+
166
+ export async function probeReplay(cwd: string, recurrence: Recurrence): Promise<ReplayVerdict> {
167
+ const cached = replayVerdict(cwd, recurrence)
168
+ if (cached !== 'unknown') return cached
169
+ if (recurrence.command === undefined) return 'unknown'
170
+ const replay = await runReplay(cwd, recurrence.id, recurrence.command)
171
+ if (replay === null) return 'unknown'
172
+ return replay.ok ? 'stale' : 'live'
173
+ }
package/src/store.ts ADDED
@@ -0,0 +1,274 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { Validator } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ import { writeFileAtomic } from './atomic.js'
9
+ import type { HarnessSettings, Install, Lesson } from './types.js'
10
+
11
+ export function harnessDir(cwd: string): string {
12
+ return join(cwd, '.pi', 'harness')
13
+ }
14
+
15
+ export function lessonsFile(cwd: string): string {
16
+ return join(harnessDir(cwd), 'lessons.jsonl')
17
+ }
18
+
19
+ export function installsFile(cwd: string): string {
20
+ return join(harnessDir(cwd), 'installs.jsonl')
21
+ }
22
+
23
+ export function settingsFile(cwd: string): string {
24
+ return join(cwd, '.pi', 'harness.json')
25
+ }
26
+
27
+ const LessonSchema = t.object({
28
+ id: t.string(),
29
+ at: t.number(),
30
+ cue: t.string(),
31
+ lesson: t.string(),
32
+ evidence: t.string(),
33
+ confidence: t.union([t.literal('high'), t.literal('medium'), t.literal('low')]),
34
+ triggers: t.array(t.string()),
35
+ kind: t.union([
36
+ t.literal('repeat-error'),
37
+ t.literal('repeat-command'),
38
+ t.literal('repeat-read'),
39
+ t.literal('no-change'),
40
+ t.literal('abandoned-tool')
41
+ ]),
42
+ state: t.union([
43
+ t.literal('recorded'),
44
+ t.literal('installed'),
45
+ t.literal('failed'),
46
+ t.literal('rejected'),
47
+ t.literal('superseded')
48
+ ]),
49
+ confirm: t.optional(t.string()),
50
+ supersedes: t.optional(t.string()),
51
+ uses: t.optional(t.number()),
52
+ lastUsed: t.optional(t.number())
53
+ })
54
+ const isLessonSchema = new Validator(LessonSchema).isValidObject
55
+
56
+ const SettingsSchema = t.object({
57
+ check: t.optional(t.string()),
58
+ auto: t.optional(t.boolean()),
59
+ autoInstall: t.optional(t.boolean()),
60
+ global: t.optional(t.boolean()),
61
+ maxInstallsPerDay: t.optional(t.number()),
62
+ maxProposalsPerDay: t.optional(t.number()),
63
+ maxTurnsPerProposal: t.optional(t.number()),
64
+ maxStandingTokens: t.optional(t.number()),
65
+ writerModel: t.optional(t.string())
66
+ })
67
+ const isSettingsSchema = new Validator(SettingsSchema).isValidObject
68
+
69
+ const InstalledFile = t.object({
70
+ to: t.string(),
71
+ existed: t.boolean(),
72
+ before: t.string(),
73
+ after: t.optional(t.string()),
74
+ afterHash: t.optional(t.string()),
75
+ block: t.optional(t.string()),
76
+ mode: t.union([t.literal('replace'), t.literal('append')])
77
+ })
78
+
79
+ const InstallSchema = t.object({
80
+ id: t.string(),
81
+ at: t.number(),
82
+ finding: t.string(),
83
+ dir: t.string(),
84
+ files: t.array(InstalledFile),
85
+ check: t.string(),
86
+ ok: t.boolean(),
87
+ reverted: t.boolean(),
88
+ lesson: t.optional(t.string()),
89
+ key: t.optional(t.string()),
90
+ cost: t.optional(t.number()),
91
+ confirm: t.optional(t.string()),
92
+ confirmOk: t.optional(t.boolean()),
93
+ confirmOutput: t.optional(t.string()),
94
+ replay: t.optional(t.string()),
95
+ replayOk: t.optional(t.boolean()),
96
+ replayOutput: t.optional(t.string()),
97
+ note: t.optional(t.string())
98
+ })
99
+ const isInstallSchema = new Validator(InstallSchema).isValidObject
100
+
101
+ function normalizedCue(cue: string): string {
102
+ return cue.toLowerCase().replace(/\s+/g, ' ').trim()
103
+ }
104
+
105
+ function active(lesson: Lesson): boolean {
106
+ return lesson.state === 'recorded' || lesson.state === 'installed'
107
+ }
108
+
109
+ /**
110
+ * The file is append-only, so two processes can record the same cue as two active
111
+ * lines. The view keeps the newest active line of a cue, and every settled line,
112
+ * because a superseded record is how the report finds the rule still installed.
113
+ */
114
+ function withoutDuplicateActives(lessons: Lesson[]): Lesson[] {
115
+ const newest = new Map<string, Lesson>()
116
+ for (const lesson of lessons) {
117
+ if (!active(lesson)) continue
118
+ const key = normalizedCue(lesson.cue)
119
+ const seen = newest.get(key)
120
+ if (seen === undefined || seen.at <= lesson.at) newest.set(key, lesson)
121
+ }
122
+ return lessons.filter((lesson) => !active(lesson) || newest.get(normalizedCue(lesson.cue))?.id === lesson.id)
123
+ }
124
+
125
+ export function readLessons(cwd: string): Lesson[] {
126
+ const path = lessonsFile(cwd)
127
+ if (!existsSync(path)) return []
128
+ const lessons = new Map<string, Lesson>()
129
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
130
+ const trimmed = line.trim()
131
+ if (trimmed === '') continue
132
+ let parsed: unknown
133
+ try {
134
+ parsed = JSON.parse(trimmed)
135
+ } catch {
136
+ continue
137
+ }
138
+ if (isLessonSchema(parsed)) lessons.set(parsed.id, parsed)
139
+ }
140
+ return withoutDuplicateActives([...lessons.values()])
141
+ }
142
+
143
+ export function appendLesson(cwd: string, lesson: Lesson): void {
144
+ const path = lessonsFile(cwd)
145
+ mkdirSync(dirname(path), { recursive: true })
146
+ appendFileSync(path, `${JSON.stringify(lesson)}\n`, 'utf8')
147
+ }
148
+
149
+ function sameCue(left: string, right: string): boolean {
150
+ return normalizedCue(left) === normalizedCue(right)
151
+ }
152
+
153
+ /** A lesson whose part did not hold stops matching, and the record keeps why. */
154
+ export function failLesson(cwd: string, id: string): boolean {
155
+ const found = readLessons(cwd).find((lesson) => lesson.id === id)
156
+ if (found === undefined || found.state === 'failed') return false
157
+ appendLesson(cwd, { ...found, state: 'failed' })
158
+ return true
159
+ }
160
+
161
+ /**
162
+ * A lesson about a situation that another lesson already covers replaces it. The old one is
163
+ * kept, marked superseded, because it is the record of what the loop believed before, and it
164
+ * stops matching, because a later session has to read one rule for a situation, not two.
165
+ */
166
+ export function recordLesson(cwd: string, lesson: Lesson) {
167
+ const previous =
168
+ readLessons(cwd).find(
169
+ (entry) => entry.state !== 'rejected' && entry.state !== 'superseded' && sameCue(entry.cue, lesson.cue)
170
+ ) ?? null
171
+ if (previous !== null) appendLesson(cwd, { ...previous, state: 'superseded' })
172
+ const next = previous === null ? lesson : { ...lesson, supersedes: previous.id }
173
+ appendLesson(cwd, next)
174
+ return { lesson: next, superseded: previous }
175
+ }
176
+
177
+ export function writeLessons(cwd: string, lessons: Lesson[]): void {
178
+ const path = lessonsFile(cwd)
179
+ mkdirSync(dirname(path), { recursive: true })
180
+ writeFileAtomic(path, lessons.map((lesson) => `${JSON.stringify(lesson)}\n`).join(''))
181
+ }
182
+
183
+ const DEFAULT_SETTINGS: HarnessSettings = {
184
+ check: '',
185
+ auto: true,
186
+ autoInstall: true,
187
+ global: false,
188
+ maxInstallsPerDay: 3,
189
+ maxProposalsPerDay: 5,
190
+ maxTurnsPerProposal: 6,
191
+ maxStandingTokens: 1_500,
192
+ writerModel: ''
193
+ }
194
+
195
+ export function readSettings(cwd: string): HarnessSettings {
196
+ const path = settingsFile(cwd)
197
+ if (!existsSync(path)) return { ...DEFAULT_SETTINGS }
198
+ let parsed: unknown
199
+ try {
200
+ parsed = JSON.parse(readFileSync(path, 'utf8'))
201
+ } catch {
202
+ return { ...DEFAULT_SETTINGS }
203
+ }
204
+ if (!isSettingsSchema(parsed)) return { ...DEFAULT_SETTINGS }
205
+ return {
206
+ check: parsed.check ?? DEFAULT_SETTINGS.check,
207
+ auto: parsed.auto ?? DEFAULT_SETTINGS.auto,
208
+ autoInstall: parsed.autoInstall ?? DEFAULT_SETTINGS.autoInstall,
209
+ global: parsed.global ?? DEFAULT_SETTINGS.global,
210
+ maxInstallsPerDay: Math.max(0, parsed.maxInstallsPerDay ?? DEFAULT_SETTINGS.maxInstallsPerDay),
211
+ maxProposalsPerDay: Math.max(0, parsed.maxProposalsPerDay ?? DEFAULT_SETTINGS.maxProposalsPerDay),
212
+ maxTurnsPerProposal: Math.max(1, parsed.maxTurnsPerProposal ?? DEFAULT_SETTINGS.maxTurnsPerProposal),
213
+ maxStandingTokens: Math.max(0, parsed.maxStandingTokens ?? DEFAULT_SETTINGS.maxStandingTokens),
214
+ writerModel: parsed.writerModel ?? DEFAULT_SETTINGS.writerModel
215
+ }
216
+ }
217
+
218
+ export function readInstalls(cwd: string): Install[] {
219
+ const path = installsFile(cwd)
220
+ if (!existsSync(path)) return []
221
+ const installs = new Map<string, Install>()
222
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
223
+ if (line.trim() === '') continue
224
+ let parsed: unknown
225
+ try {
226
+ parsed = JSON.parse(line)
227
+ } catch {
228
+ continue
229
+ }
230
+ if (isInstallSchema(parsed)) installs.set(parsed.id, parsed)
231
+ }
232
+ return [...installs.values()].toSorted((a, b) => a.at - b.at)
233
+ }
234
+
235
+ export function appendInstall(cwd: string, install: Install): void {
236
+ const path = installsFile(cwd)
237
+ mkdirSync(dirname(path), { recursive: true })
238
+ appendFileSync(path, `${JSON.stringify(install)}\n`, 'utf8')
239
+ }
240
+
241
+ const LOCK_STALE_MS = 5 * 60_000
242
+
243
+ export function acquireLock(cwd: string): (() => void) | null {
244
+ const path = join(harnessDir(cwd), 'lock')
245
+ mkdirSync(dirname(path), { recursive: true })
246
+ const token = `${process.pid} ${Date.now()} ${randomUUID()}\n`
247
+ const take = (): boolean => {
248
+ try {
249
+ writeFileSync(path, token, { encoding: 'utf8', flag: 'wx' })
250
+ return true
251
+ } catch {
252
+ return false
253
+ }
254
+ }
255
+ if (!take()) {
256
+ try {
257
+ const existing = readFileSync(path, 'utf8')
258
+ const at = Number(existing.split(' ')[1])
259
+ if (Number.isFinite(at) && Date.now() - at > LOCK_STALE_MS) rmSync(path, { force: true })
260
+ } catch {}
261
+ if (!take()) return null
262
+ }
263
+ return () => {
264
+ try {
265
+ if (readFileSync(path, 'utf8') === token) rmSync(path, { force: true })
266
+ } catch {}
267
+ }
268
+ }
269
+
270
+ export function recordLessonUse(cwd: string, id: string): void {
271
+ const found = readLessons(cwd).find((lesson) => lesson.id === id)
272
+ if (found === undefined) return
273
+ appendLesson(cwd, { ...found, uses: (found.uses ?? 0) + 1, lastUsed: Date.now() })
274
+ }
package/src/target.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { Recurrence, SkillUse } from './corpus.js'
2
+ import type { Finding } from './findings.js'
3
+ import type { FindingKind } from './types.js'
4
+
5
+ export interface Target {
6
+ id: string
7
+ title: string
8
+ count: number
9
+ evidence: string[]
10
+ sample: string
11
+ command?: string
12
+ tool: string
13
+ scope: string
14
+ kind: FindingKind
15
+ cue: string
16
+ }
17
+
18
+ export function targetFromFinding(finding: Finding): Target {
19
+ const kind: FindingKind =
20
+ finding.kind === 'command'
21
+ ? 'repeat-command'
22
+ : finding.kind === 'read'
23
+ ? 'repeat-read'
24
+ : finding.kind === 'no-change'
25
+ ? 'no-change'
26
+ : finding.kind === 'unanswered'
27
+ ? 'abandoned-tool'
28
+ : 'repeat-error'
29
+ return {
30
+ id: finding.id,
31
+ title: finding.title,
32
+ count: finding.count,
33
+ evidence: finding.evidence,
34
+ sample: finding.evidence[0] ?? '',
35
+ tool: '',
36
+ scope: 'this session',
37
+ kind,
38
+ cue: finding.title
39
+ }
40
+ }
41
+
42
+ export function targetFromSkillUse(
43
+ use: SkillUse,
44
+ description: string,
45
+ path: string
46
+ ): Target {
47
+ const evidence = [`${path} :: ${description}`]
48
+ return {
49
+ id: use.id,
50
+ title: `the skill ${use.name} is advertised in every request and was opened in none of ${use.sessions} sessions`,
51
+ count: use.sessions,
52
+ evidence,
53
+ sample: '',
54
+ tool: '',
55
+ scope: `${use.sessions} sessions of this project`,
56
+ kind: 'dead-skill',
57
+ cue: `the skill ${use.name} is never opened`
58
+ }
59
+ }
60
+
61
+ export function targetFromRecurrence(recurrence: Recurrence): Target {
62
+ const kind: FindingKind =
63
+ recurrence.kind === 'command' ? 'repeat-command' : recurrence.kind === 'read' ? 'repeat-read' : 'repeat-error'
64
+ return {
65
+ id: recurrence.id,
66
+ title: recurrence.title,
67
+ count: recurrence.occurrences,
68
+ evidence: recurrence.evidence,
69
+ sample: recurrence.sample,
70
+ command: recurrence.command,
71
+ tool: recurrence.tool,
72
+ scope: `${recurrence.sessions} sessions of this project`,
73
+ kind,
74
+ cue: recurrence.title
75
+ }
76
+ }
@@ -0,0 +1,139 @@
1
+ import { Validator } from 'ata-validator'
2
+ import { t } from 'ata-validator/t'
3
+
4
+
5
+ import type { SessionEntry } from '@earendil-works/pi-coding-agent'
6
+ const isString = new Validator(t.string()).isValidObject
7
+
8
+ export interface ToolCall {
9
+ id: string
10
+ name: string
11
+ command: string
12
+ path: string
13
+ }
14
+
15
+ export interface ToolResult {
16
+ toolCallId: string
17
+ toolName: string
18
+ isError: boolean
19
+ body: string
20
+ }
21
+
22
+ export interface Transcript {
23
+ calls: ToolCall[]
24
+ results: ToolResult[]
25
+ }
26
+
27
+ const CommandArguments = t.object({ command: t.string() })
28
+ const isCommandArguments = new Validator(CommandArguments).isValidObject
29
+ const PathArguments = t.object({ path: t.string() })
30
+ const isPathArguments = new Validator(PathArguments).isValidObject
31
+
32
+ export function readTranscript(entries: SessionEntry[]): Transcript {
33
+ const calls: ToolCall[] = []
34
+ const results: ToolResult[] = []
35
+ for (const entry of entries) {
36
+ if (entry.type !== 'message') continue
37
+ const message = entry.message
38
+ if (message.role === 'assistant') {
39
+ for (const block of message.content) {
40
+ if (block.type !== 'toolCall') continue
41
+ calls.push({
42
+ id: block.id,
43
+ name: block.name,
44
+ command: isCommandArguments(block.arguments) ? block.arguments.command : '',
45
+ path: isPathArguments(block.arguments) ? block.arguments.path : ''
46
+ })
47
+ }
48
+ continue
49
+ }
50
+ if (message.role === 'toolResult') {
51
+ results.push({
52
+ toolCallId: message.toolCallId,
53
+ toolName: message.toolName,
54
+ isError: message.isError,
55
+ body: message.content
56
+ .filter((block) => block.type === 'text')
57
+ .map((block) => block.text)
58
+ .join('\n')
59
+ })
60
+ }
61
+ }
62
+ return { calls, results }
63
+ }
64
+
65
+ export interface Usage {
66
+ input: number
67
+ output: number
68
+ cost: number
69
+ }
70
+
71
+ export function sumUsage(entries: SessionEntry[]): Usage {
72
+ let input = 0
73
+ let output = 0
74
+ let cost = 0
75
+ for (const entry of entries) {
76
+ if (entry.type !== 'message' || entry.message.role !== 'assistant') continue
77
+ const usage = entry.message.usage
78
+ input += usage.input
79
+ output += usage.output
80
+ cost += usage.cost.total
81
+ }
82
+ return { input, output, cost }
83
+ }
84
+
85
+ export function countAssistantTurns(entries: SessionEntry[]): number {
86
+ return entries.filter((entry) => entry.type === 'message' && entry.message.role === 'assistant').length
87
+ }
88
+
89
+ export function lastAssistantText(entries: SessionEntry[]): string {
90
+ for (const entry of entries.toReversed()) {
91
+ if (entry.type !== 'message' || entry.message.role !== 'assistant') continue
92
+ return entry.message.content
93
+ .filter((block) => block.type === 'text')
94
+ .map((block) => block.text)
95
+ .join('\n')
96
+ }
97
+ return ''
98
+ }
99
+
100
+ export function lastUserText(entries: SessionEntry[]): string {
101
+ for (const entry of entries.toReversed()) {
102
+ if (entry.type !== 'message' || entry.message.role !== 'user') continue
103
+ const content = entry.message.content
104
+ if (isString(content)) return content
105
+ return content
106
+ .filter((block) => block.type === 'text')
107
+ .map((block) => block.text)
108
+ .join('\n')
109
+ }
110
+ return ''
111
+ }
112
+
113
+ export function recentText(entries: SessionEntry[], limit = 6): string {
114
+ const texts: string[] = []
115
+ for (const entry of entries.toReversed().slice(0, limit)) {
116
+ if (entry.type !== 'message') continue
117
+ const message = entry.message
118
+ if (message.role === 'assistant') {
119
+ texts.push(
120
+ message.content
121
+ .filter((block) => block.type === 'text')
122
+ .map((block) => block.text)
123
+ .join('\n')
124
+ )
125
+ continue
126
+ }
127
+ if (message.role !== 'user') continue
128
+ const content = message.content
129
+ texts.push(
130
+ isString(content)
131
+ ? content
132
+ : content
133
+ .filter((block) => block.type === 'text')
134
+ .map((block) => block.text)
135
+ .join('\n')
136
+ )
137
+ }
138
+ return texts.join('\n')
139
+ }