@tduyng/pi-harness-loop 0.0.0-stage → 0.0.1
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/LICENSE +178 -0
- package/README.md +380 -2
- package/package.json +41 -4
- package/src/apply.ts +257 -0
- package/src/atomic.ts +7 -0
- package/src/auto.ts +483 -0
- package/src/check.ts +73 -0
- package/src/coordination.ts +30 -0
- package/src/corpus.ts +641 -0
- package/src/diversity.ts +73 -0
- package/src/effect.ts +134 -0
- package/src/evaluate.ts +81 -0
- package/src/findings.ts +141 -0
- package/src/index.ts +761 -0
- package/src/install.ts +297 -0
- package/src/ledger.ts +89 -0
- package/src/lesson-tool.ts +39 -0
- package/src/lint.ts +87 -0
- package/src/match.ts +192 -0
- package/src/producer.ts +236 -0
- package/src/prompt.ts +162 -0
- package/src/replay.ts +173 -0
- package/src/store.ts +268 -0
- package/src/target.ts +76 -0
- package/src/transcript.ts +139 -0
- package/src/tree.ts +307 -0
- package/src/types.ts +77 -0
- package/src/writer.ts +134 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Validator } from 'ata-validator'
|
|
5
|
+
import { t } from 'ata-validator/t'
|
|
6
|
+
|
|
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 take = (): boolean => {
|
|
247
|
+
try {
|
|
248
|
+
writeFileSync(path, `${process.pid} ${Date.now()}\n`, { encoding: 'utf8', flag: 'wx' })
|
|
249
|
+
return true
|
|
250
|
+
} catch {
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!take()) {
|
|
255
|
+
try {
|
|
256
|
+
const at = Number(readFileSync(path, 'utf8').split(' ')[1])
|
|
257
|
+
if (Number.isFinite(at) && Date.now() - at > LOCK_STALE_MS) rmSync(path, { force: true })
|
|
258
|
+
} catch {}
|
|
259
|
+
if (!take()) return null
|
|
260
|
+
}
|
|
261
|
+
return () => rmSync(path, { force: true })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function recordLessonUse(cwd: string, id: string): void {
|
|
265
|
+
const found = readLessons(cwd).find((lesson) => lesson.id === id)
|
|
266
|
+
if (found === undefined) return
|
|
267
|
+
appendLesson(cwd, { ...found, uses: (found.uses ?? 0) + 1, lastUsed: Date.now() })
|
|
268
|
+
}
|
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
|
+
}
|