@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/install.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { applyProposal, readProposal, revertApplied } from './apply.js'
|
|
6
|
+
import { readScan } from './auto.js'
|
|
7
|
+
import { discoverCheck, runCheck } from './check.js'
|
|
8
|
+
import { runReplay } from './replay.js'
|
|
9
|
+
import { acquireLock, appendInstall, readInstalls, readSettings } from './store.js'
|
|
10
|
+
import { loadHarness } from './tree.js'
|
|
11
|
+
import { validateProposal } from './tree.js'
|
|
12
|
+
import type { Install, InstallState, InstalledFile } from './types.js'
|
|
13
|
+
import type { InstallContext, OwnedFile } from './apply.js'
|
|
14
|
+
|
|
15
|
+
export const DAY_MS = 86_400_000
|
|
16
|
+
const CONFIRM_TIMEOUT_MS = 60_000
|
|
17
|
+
|
|
18
|
+
export interface InstallOutcome {
|
|
19
|
+
state: InstallState
|
|
20
|
+
message: string
|
|
21
|
+
retryable?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InstallOptions {
|
|
25
|
+
forced?: boolean
|
|
26
|
+
context?: InstallContext
|
|
27
|
+
lesson?: string
|
|
28
|
+
key?: string
|
|
29
|
+
command?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function liveSkills(cwd: string, agentDir: string): Map<string, string> {
|
|
33
|
+
const out = new Map<string, string>()
|
|
34
|
+
for (const skill of loadHarness(cwd, agentDir).skills) {
|
|
35
|
+
out.set(skill.name, skill.filePath)
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function installContext(cwd: string, agentDir: string): InstallContext {
|
|
41
|
+
return {
|
|
42
|
+
cwd,
|
|
43
|
+
agentDir,
|
|
44
|
+
homeSkills: join(homedir(), '.agents', 'skills'),
|
|
45
|
+
live: liveSkills(cwd, agentDir),
|
|
46
|
+
owned: ownedFiles(cwd)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What the part costs on every later request, which is the front of the prompt: the rule as it
|
|
52
|
+
* is appended, or the name and the description the model routes a skill or a profile on. The
|
|
53
|
+
* body of a skill is not in it, because a body is paid only when the model opens it.
|
|
54
|
+
*/
|
|
55
|
+
export function advertisedChars(files: InstalledFile[]): number {
|
|
56
|
+
let total = 0
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (file.mode === 'append') {
|
|
59
|
+
total += (file.block ?? '').length
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (!file.to.endsWith('.md')) continue
|
|
63
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(file.after ?? '')
|
|
64
|
+
if (match?.[1] === undefined) continue
|
|
65
|
+
for (const field of ['name', 'description']) {
|
|
66
|
+
total += new RegExp(`^${field}\\s*:\\s*(.*)$`, 'm').exec(match[1])?.[1]?.length ?? 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return total
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Characters the active installs add to the front of every later request. */
|
|
73
|
+
export function standingChars(installs: Install[]): number {
|
|
74
|
+
return installs
|
|
75
|
+
.filter((install) => install.ok && !install.reverted)
|
|
76
|
+
.reduce((total, install) => total + (install.cost ?? advertisedChars(install.files)), 0)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ruleBlock(dir: string): string {
|
|
80
|
+
for (const path of [join(dir, 'RULE.md'), join(dir, 'global', 'RULE.md')]) {
|
|
81
|
+
try {
|
|
82
|
+
const text = readFileSync(path, 'utf8').trim()
|
|
83
|
+
if (text !== '') return text
|
|
84
|
+
} catch {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function ownedFiles(cwd: string): Map<string, OwnedFile> {
|
|
92
|
+
const owned = new Map<string, OwnedFile>()
|
|
93
|
+
for (const install of readInstalls(cwd)) {
|
|
94
|
+
if (!install.ok || install.reverted) continue
|
|
95
|
+
for (const file of install.files) {
|
|
96
|
+
const entry = owned.get(file.to) ?? { blocks: [] }
|
|
97
|
+
if (file.after !== undefined) entry.after = file.after
|
|
98
|
+
if (file.mode === 'append') {
|
|
99
|
+
const block = file.block ?? ruleBlock(install.dir)
|
|
100
|
+
if (block !== '' && !entry.blocks.includes(block)) entry.blocks.push(block)
|
|
101
|
+
}
|
|
102
|
+
owned.set(file.to, entry)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return owned
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function installRoots(context: InstallContext): string[] {
|
|
109
|
+
return [
|
|
110
|
+
context.homeSkills,
|
|
111
|
+
join(context.cwd, '.agents', 'skills'),
|
|
112
|
+
join(context.cwd, '.pi', 'agents'),
|
|
113
|
+
context.agentDir
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function confirmOf(dir: string): string {
|
|
118
|
+
const path = join(dir, 'CONFIRM.md')
|
|
119
|
+
if (!existsSync(path)) return ''
|
|
120
|
+
const text = readFileSync(path, 'utf8')
|
|
121
|
+
const fenced = /```(?:sh|bash|shell)?\s*\n([\s\S]*?)```/.exec(text)
|
|
122
|
+
const body = (fenced?.[1] ?? text).trim()
|
|
123
|
+
return (
|
|
124
|
+
body
|
|
125
|
+
.split('\n')
|
|
126
|
+
.map((line) => line.trim())
|
|
127
|
+
.find((line) => line !== '' && !line.startsWith('#')) ?? ''
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function replayOf(cwd: string, finding: string, command?: string) {
|
|
132
|
+
const found = command ?? readScan(cwd)?.recurrences.find((recurrence) => recurrence.id === finding)?.command
|
|
133
|
+
if (found === undefined) return null
|
|
134
|
+
return runReplay(cwd, finding, found)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function finalize(
|
|
138
|
+
cwd: string,
|
|
139
|
+
dir: string,
|
|
140
|
+
install: Install,
|
|
141
|
+
context: InstallContext,
|
|
142
|
+
refusal: string,
|
|
143
|
+
command?: string
|
|
144
|
+
): Promise<InstallOutcome> {
|
|
145
|
+
const confirm = confirmOf(dir)
|
|
146
|
+
const confirmResult = confirm === '' ? null : await runCheck(cwd, confirm, { timeoutMs: CONFIRM_TIMEOUT_MS })
|
|
147
|
+
const base: Install = {
|
|
148
|
+
...install,
|
|
149
|
+
confirm: confirm === '' ? undefined : confirm,
|
|
150
|
+
confirmOk: confirmResult?.ok,
|
|
151
|
+
confirmOutput: confirmResult?.output.slice(0, 2000)
|
|
152
|
+
}
|
|
153
|
+
if (confirmResult !== null && !confirmResult.ok) {
|
|
154
|
+
revertApplied(install.files, installRoots(context), true)
|
|
155
|
+
appendInstall(cwd, { ...base, ok: false, reverted: true })
|
|
156
|
+
return {
|
|
157
|
+
state: 'rejected',
|
|
158
|
+
message: `reverted, the confirm command failed: ${confirmResult.output.slice(0, 300)}`
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const replay = await replayOf(cwd, install.finding, command)
|
|
162
|
+
const recorded: Install = {
|
|
163
|
+
...base,
|
|
164
|
+
replay: replay?.command,
|
|
165
|
+
replayOk: replay?.ok,
|
|
166
|
+
replayOutput: replay?.output
|
|
167
|
+
}
|
|
168
|
+
appendInstall(cwd, recorded)
|
|
169
|
+
return {
|
|
170
|
+
state: 'installed',
|
|
171
|
+
message: `installed ${install.files.length} file(s), ${
|
|
172
|
+
install.check === '' ? 'no check command' : `${install.check} found no new failure`
|
|
173
|
+
}${confirmResult === null ? ', no confirm command' : ', confirm passed'}${refusal}`
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function installProposal(
|
|
178
|
+
dir: string,
|
|
179
|
+
finding: string,
|
|
180
|
+
cwd: string,
|
|
181
|
+
options: InstallOptions = {}
|
|
182
|
+
): Promise<InstallOutcome> {
|
|
183
|
+
const release = acquireLock(cwd)
|
|
184
|
+
if (release === null) {
|
|
185
|
+
return { state: 'recorded', message: 'another install is running, so this one waits', retryable: true }
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return await installLocked(dir, finding, cwd, options)
|
|
189
|
+
} finally {
|
|
190
|
+
release()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function installLocked(
|
|
195
|
+
dir: string,
|
|
196
|
+
finding: string,
|
|
197
|
+
cwd: string,
|
|
198
|
+
options: InstallOptions
|
|
199
|
+
): Promise<InstallOutcome> {
|
|
200
|
+
const settings = readSettings(cwd)
|
|
201
|
+
if (!settings.autoInstall && options.forced !== true) {
|
|
202
|
+
return { state: 'recorded', message: `autoInstall is off, the proposal stays in ${dir}` }
|
|
203
|
+
}
|
|
204
|
+
const check = discoverCheck(cwd, settings.check)
|
|
205
|
+
const proposal = readProposal(dir)
|
|
206
|
+
const markdownOnly = proposal.rule !== null && proposal.skills.length === 0 && proposal.profiles.length === 0
|
|
207
|
+
if (check === '' && !markdownOnly) {
|
|
208
|
+
return {
|
|
209
|
+
state: 'recorded',
|
|
210
|
+
message: `no check command, and a skill or profile needs one, so the proposal stays in ${dir}`
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const spent = readInstalls(cwd).filter(
|
|
214
|
+
(install) => install.ok && !install.reverted && install.at > Date.now() - DAY_MS
|
|
215
|
+
)
|
|
216
|
+
if (options.forced !== true && spent.length >= settings.maxInstallsPerDay) {
|
|
217
|
+
return {
|
|
218
|
+
state: 'recorded',
|
|
219
|
+
message: `the install budget for today is spent (${spent.length})`,
|
|
220
|
+
retryable: true
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const context = options.context ?? {
|
|
224
|
+
cwd,
|
|
225
|
+
agentDir: '',
|
|
226
|
+
homeSkills: join(homedir(), '.agents', 'skills'),
|
|
227
|
+
live: new Map(),
|
|
228
|
+
owned: new Map()
|
|
229
|
+
}
|
|
230
|
+
const problems = validateProposal(dir, cwd)
|
|
231
|
+
if (problems.length > 0) {
|
|
232
|
+
return {
|
|
233
|
+
state: 'recorded',
|
|
234
|
+
message: `the proposal breaks ${problems.length} rules, so it stays on disk: ${problems[0]?.rule ?? ''} ${problems[0]?.detail ?? ''}`
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const { applied, refused } = applyProposal(dir, context)
|
|
238
|
+
if (applied.length === 0) {
|
|
239
|
+
return {
|
|
240
|
+
state: 'recorded',
|
|
241
|
+
message:
|
|
242
|
+
refused.length === 0
|
|
243
|
+
? 'the proposal holds no skill or profile to install'
|
|
244
|
+
: `the part stays on disk: ${refused[0]}`
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const added = advertisedChars(applied)
|
|
248
|
+
const standing = standingChars(readInstalls(cwd))
|
|
249
|
+
const capChars = settings.maxStandingTokens * 4
|
|
250
|
+
if (options.forced !== true && capChars > 0 && standing + added > capChars) {
|
|
251
|
+
revertApplied(applied, installRoots(context), true)
|
|
252
|
+
return {
|
|
253
|
+
state: 'recorded',
|
|
254
|
+
message: `the always-on block would reach ${Math.round((standing + added) / 4)} of ${settings.maxStandingTokens} tokens, so the part stays on disk; revert an install first`,
|
|
255
|
+
retryable: true
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const refusal = refused.length === 0 ? '' : `, ${refused.length} file(s) left alone: ${refused[0]}`
|
|
259
|
+
|
|
260
|
+
const install: Install = {
|
|
261
|
+
id: `${finding}-${Date.now()}`,
|
|
262
|
+
at: Date.now(),
|
|
263
|
+
finding,
|
|
264
|
+
dir,
|
|
265
|
+
files: applied,
|
|
266
|
+
check,
|
|
267
|
+
ok: true,
|
|
268
|
+
reverted: false,
|
|
269
|
+
lesson: options.lesson,
|
|
270
|
+
key: options.key,
|
|
271
|
+
cost: advertisedChars(applied)
|
|
272
|
+
}
|
|
273
|
+
if (check === '') return finalize(cwd, dir, install, context, `${refusal}, installed without a check`, options.command)
|
|
274
|
+
|
|
275
|
+
const result = await runCheck(cwd, check)
|
|
276
|
+
if (result.ok) return finalize(cwd, dir, install, context, refusal, options.command)
|
|
277
|
+
|
|
278
|
+
revertApplied(applied, installRoots(context), true)
|
|
279
|
+
const baseline = await runCheck(cwd, check)
|
|
280
|
+
const innocent =
|
|
281
|
+
!baseline.ok && baseline.failures !== null && result.failures !== null && result.failures <= baseline.failures
|
|
282
|
+
if (innocent) {
|
|
283
|
+
for (const file of applied) {
|
|
284
|
+
mkdirSync(dirname(file.to), { recursive: true })
|
|
285
|
+
writeFileSync(file.to, file.after, 'utf8')
|
|
286
|
+
}
|
|
287
|
+
return finalize(cwd, dir, install, context, refusal, options.command)
|
|
288
|
+
}
|
|
289
|
+
appendInstall(cwd, { ...install, ok: false, reverted: true })
|
|
290
|
+
return {
|
|
291
|
+
state: 'rejected',
|
|
292
|
+
message:
|
|
293
|
+
baseline.failures === null || result.failures === null
|
|
294
|
+
? `reverted, the check could not say whether the change broke anything: ${result.output.slice(0, 300)}`
|
|
295
|
+
: `reverted, ${check} went from ${baseline.failures} failures to ${result.failures}: ${result.output.slice(0, 300)}`
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } 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
|
+
export type SpendOutcome = 'installed' | 'reverted' | 'recorded' | 'rejected' | 'auto-reverted'
|
|
9
|
+
|
|
10
|
+
export interface LoopSpend {
|
|
11
|
+
at: number
|
|
12
|
+
target: string
|
|
13
|
+
turns: number
|
|
14
|
+
input: number
|
|
15
|
+
output: number
|
|
16
|
+
cost: number
|
|
17
|
+
outcome: SpendOutcome
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SpendTotal {
|
|
21
|
+
proposals: number
|
|
22
|
+
turns: number
|
|
23
|
+
input: number
|
|
24
|
+
output: number
|
|
25
|
+
cost: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SpendSchema = t.object({
|
|
29
|
+
at: t.number(),
|
|
30
|
+
target: t.string(),
|
|
31
|
+
turns: t.number(),
|
|
32
|
+
input: t.number(),
|
|
33
|
+
output: t.number(),
|
|
34
|
+
cost: t.number(),
|
|
35
|
+
outcome: t.union([
|
|
36
|
+
t.literal('installed'),
|
|
37
|
+
t.literal('reverted'),
|
|
38
|
+
t.literal('recorded'),
|
|
39
|
+
t.literal('rejected'),
|
|
40
|
+
t.literal('auto-reverted')
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
const isSpendSchema = new Validator(SpendSchema).isValidObject
|
|
44
|
+
|
|
45
|
+
export function spendFile(cwd: string): string {
|
|
46
|
+
return join(cwd, '.pi', 'harness', 'spend.jsonl')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readSpend(cwd: string): LoopSpend[] {
|
|
50
|
+
const path = spendFile(cwd)
|
|
51
|
+
if (!existsSync(path)) return []
|
|
52
|
+
const out: LoopSpend[] = []
|
|
53
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
54
|
+
if (line.trim() === '') continue
|
|
55
|
+
let parsed: unknown
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(line)
|
|
58
|
+
} catch {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (isSpendSchema(parsed)) out.push(parsed)
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function appendSpend(cwd: string, spend: LoopSpend): void {
|
|
67
|
+
const path = spendFile(cwd)
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
69
|
+
appendFileSync(path, `${JSON.stringify(spend)}\n`, 'utf8')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sumSpend(spends: LoopSpend[]): SpendTotal {
|
|
73
|
+
return spends.reduce<SpendTotal>(
|
|
74
|
+
(total, spend) => ({
|
|
75
|
+
proposals: total.proposals + 1,
|
|
76
|
+
turns: total.turns + spend.turns,
|
|
77
|
+
input: total.input + spend.input,
|
|
78
|
+
output: total.output + spend.output,
|
|
79
|
+
cost: total.cost + spend.cost
|
|
80
|
+
}),
|
|
81
|
+
{ proposals: 0, turns: 0, input: 0, output: 0, cost: 0 }
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DAY_MS = 86_400_000
|
|
86
|
+
|
|
87
|
+
export function spentToday(spends: LoopSpend[], now = Date.now()): SpendTotal {
|
|
88
|
+
return sumSpend(spends.filter((spend) => spend.at > now - DAY_MS))
|
|
89
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { t } from 'ata-validator/t'
|
|
2
|
+
|
|
3
|
+
import { matchLessons } from './match.js'
|
|
4
|
+
import { readLessons } from './store.js'
|
|
5
|
+
import type { PromptWords } from './corpus.js'
|
|
6
|
+
import type { Lesson } from './types.js'
|
|
7
|
+
|
|
8
|
+
export const LESSON_TOOL = 'lesson'
|
|
9
|
+
export const MAX_LESSONS_SHOWN = 3
|
|
10
|
+
|
|
11
|
+
export const LessonParameters = t.object({
|
|
12
|
+
action: t.union([t.literal('match'), t.literal('list'), t.literal('record'), t.literal('drop')]),
|
|
13
|
+
cue: t.optional(t.string()),
|
|
14
|
+
lesson: t.optional(t.string()),
|
|
15
|
+
confirm: t.optional(t.string()),
|
|
16
|
+
evidence: t.optional(t.string()),
|
|
17
|
+
confidence: t.optional(t.union([t.literal('high'), t.literal('medium'), t.literal('low')])),
|
|
18
|
+
id: t.optional(t.string())
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export function matchedLessons(cwd: string, prompt: string, rarity?: PromptWords): Lesson[] {
|
|
22
|
+
return matchLessons(readLessons(cwd), prompt, rarity)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatLesson(lesson: Lesson): string {
|
|
26
|
+
return [
|
|
27
|
+
`cue: ${lesson.cue}`,
|
|
28
|
+
`lesson: ${lesson.lesson}`,
|
|
29
|
+
...(lesson.confirm === undefined ? [] : [`confirm: ${lesson.confirm}`]),
|
|
30
|
+
`evidence: ${lesson.evidence}`,
|
|
31
|
+
`confidence: ${lesson.confidence}`,
|
|
32
|
+
`state: ${lesson.state}`
|
|
33
|
+
].join('\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatLessons(lessons: Lesson[]): string {
|
|
37
|
+
if (lessons.length === 0) return 'no lesson matches this request'
|
|
38
|
+
return lessons.slice(0, MAX_LESSONS_SHOWN).map(formatLesson).join('\n\n')
|
|
39
|
+
}
|
package/src/lint.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Validator, type Infer } from 'ata-validator'
|
|
5
|
+
import { t } from 'ata-validator/t'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export interface LintProblem {
|
|
9
|
+
rule: string
|
|
10
|
+
entry: string
|
|
11
|
+
detail: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const Manifest = t.object({
|
|
15
|
+
pi: t.object({ extensions: t.array(t.string()) })
|
|
16
|
+
})
|
|
17
|
+
const isManifest = new Validator(Manifest).isValidObject
|
|
18
|
+
|
|
19
|
+
const PackageJson = t.object({
|
|
20
|
+
name: t.optional(t.string()),
|
|
21
|
+
pi: t.optional(t.object({ extensions: t.array(t.string()) }))
|
|
22
|
+
})
|
|
23
|
+
const isPackageJson = new Validator(PackageJson).isValidObject
|
|
24
|
+
|
|
25
|
+
function readManifest(path: string): Infer<typeof Manifest> | null {
|
|
26
|
+
try {
|
|
27
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
28
|
+
return isManifest(parsed) ? parsed : null
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readPackage(path: string): Infer<typeof PackageJson> | null {
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
37
|
+
return isPackageJson(parsed) ? parsed : null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function lintManifest(root: string): LintProblem[] {
|
|
44
|
+
const problems: LintProblem[] = []
|
|
45
|
+
const manifest = readManifest(join(root, 'package.json'))
|
|
46
|
+
if (manifest === null) {
|
|
47
|
+
return [
|
|
48
|
+
{ rule: 'no-manifest', entry: 'package.json', detail: 'the root package.json has no pi.extensions list' }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
const registered = new Set(manifest.pi.extensions)
|
|
52
|
+
|
|
53
|
+
for (const entry of manifest.pi.extensions) {
|
|
54
|
+
if (existsSync(join(root, entry))) continue
|
|
55
|
+
problems.push({
|
|
56
|
+
rule: 'missing-entry-file',
|
|
57
|
+
entry,
|
|
58
|
+
detail: 'the manifest loads this file and it is not on disk'
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const extensionDir = join(root, 'extensions')
|
|
63
|
+
if (!existsSync(extensionDir)) return problems
|
|
64
|
+
for (const name of readdirSync(extensionDir).toSorted()) {
|
|
65
|
+
const packagePath = join(extensionDir, name, 'package.json')
|
|
66
|
+
if (!existsSync(packagePath)) continue
|
|
67
|
+
const pkg = readPackage(packagePath)
|
|
68
|
+
if (pkg === null) continue
|
|
69
|
+
for (const entry of pkg.pi?.extensions ?? []) {
|
|
70
|
+
const relative = `./extensions/${name}/${entry.replace(/^\.\//, '')}`
|
|
71
|
+
if (!existsSync(join(extensionDir, name, entry))) {
|
|
72
|
+
problems.push({
|
|
73
|
+
rule: 'missing-package-entry',
|
|
74
|
+
entry: relative,
|
|
75
|
+
detail: 'the package declares this entry and it is not on disk'
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
if (registered.has(relative)) continue
|
|
79
|
+
problems.push({
|
|
80
|
+
rule: 'unregistered-extension',
|
|
81
|
+
entry: relative,
|
|
82
|
+
detail: 'the package declares this entry and the root manifest never loads it'
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return problems
|
|
87
|
+
}
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Lesson } from './types.js'
|
|
2
|
+
import type { PromptWords } from './corpus.js'
|
|
3
|
+
|
|
4
|
+
/** A trigger that shows up in more than this share of the prompts of the project says nothing. */
|
|
5
|
+
const COMMON_RARITY = 0.02
|
|
6
|
+
|
|
7
|
+
function commonIn(word: string, rarity?: PromptWords): boolean {
|
|
8
|
+
if (rarity === undefined || rarity.count === 0) return false
|
|
9
|
+
return (rarity.words.get(word) ?? 0) / rarity.count > COMMON_RARITY
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STOP = new Set([
|
|
13
|
+
'about',
|
|
14
|
+
'above',
|
|
15
|
+
'after',
|
|
16
|
+
'again',
|
|
17
|
+
'against',
|
|
18
|
+
'all',
|
|
19
|
+
'also',
|
|
20
|
+
'and',
|
|
21
|
+
'another',
|
|
22
|
+
'any',
|
|
23
|
+
'anyway',
|
|
24
|
+
'are',
|
|
25
|
+
'around',
|
|
26
|
+
'ask',
|
|
27
|
+
'asks',
|
|
28
|
+
'away',
|
|
29
|
+
'back',
|
|
30
|
+
'because',
|
|
31
|
+
'been',
|
|
32
|
+
'before',
|
|
33
|
+
'being',
|
|
34
|
+
'below',
|
|
35
|
+
'both',
|
|
36
|
+
'but',
|
|
37
|
+
'can',
|
|
38
|
+
'calls',
|
|
39
|
+
'could',
|
|
40
|
+
'did',
|
|
41
|
+
'does',
|
|
42
|
+
'doing',
|
|
43
|
+
'down',
|
|
44
|
+
'during',
|
|
45
|
+
'each',
|
|
46
|
+
'every',
|
|
47
|
+
'from',
|
|
48
|
+
'further',
|
|
49
|
+
'get',
|
|
50
|
+
'gets',
|
|
51
|
+
'had',
|
|
52
|
+
'has',
|
|
53
|
+
'have',
|
|
54
|
+
'here',
|
|
55
|
+
'how',
|
|
56
|
+
'into',
|
|
57
|
+
'its',
|
|
58
|
+
'just',
|
|
59
|
+
'least',
|
|
60
|
+
'less',
|
|
61
|
+
'like',
|
|
62
|
+
'made',
|
|
63
|
+
'make',
|
|
64
|
+
'makes',
|
|
65
|
+
'many',
|
|
66
|
+
'more',
|
|
67
|
+
'most',
|
|
68
|
+
'much',
|
|
69
|
+
'must',
|
|
70
|
+
'not',
|
|
71
|
+
'now',
|
|
72
|
+
'only',
|
|
73
|
+
'onto',
|
|
74
|
+
'other',
|
|
75
|
+
'our',
|
|
76
|
+
'out',
|
|
77
|
+
'over',
|
|
78
|
+
'own',
|
|
79
|
+
'per',
|
|
80
|
+
'put',
|
|
81
|
+
'same',
|
|
82
|
+
'see',
|
|
83
|
+
'set',
|
|
84
|
+
'she',
|
|
85
|
+
'should',
|
|
86
|
+
'some',
|
|
87
|
+
'such',
|
|
88
|
+
'than',
|
|
89
|
+
'that',
|
|
90
|
+
'the',
|
|
91
|
+
'their',
|
|
92
|
+
'them',
|
|
93
|
+
'then',
|
|
94
|
+
'there',
|
|
95
|
+
'these',
|
|
96
|
+
'they',
|
|
97
|
+
'this',
|
|
98
|
+
'those',
|
|
99
|
+
'through',
|
|
100
|
+
'too',
|
|
101
|
+
'two',
|
|
102
|
+
'under',
|
|
103
|
+
'until',
|
|
104
|
+
'used',
|
|
105
|
+
'uses',
|
|
106
|
+
'using',
|
|
107
|
+
'very',
|
|
108
|
+
'want',
|
|
109
|
+
'was',
|
|
110
|
+
'way',
|
|
111
|
+
'were',
|
|
112
|
+
'what',
|
|
113
|
+
'when',
|
|
114
|
+
'where',
|
|
115
|
+
'which',
|
|
116
|
+
'while',
|
|
117
|
+
'who',
|
|
118
|
+
'whom',
|
|
119
|
+
'whose',
|
|
120
|
+
'will',
|
|
121
|
+
'with',
|
|
122
|
+
'would',
|
|
123
|
+
'yes',
|
|
124
|
+
'yet',
|
|
125
|
+
'you',
|
|
126
|
+
'your'
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
export function triggersOf(text: string, limit = 8): string[] {
|
|
130
|
+
const words = text.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []
|
|
131
|
+
const counts = new Map<string, number>()
|
|
132
|
+
for (const word of words) {
|
|
133
|
+
if (STOP.has(word)) continue
|
|
134
|
+
counts.set(word, (counts.get(word) ?? 0) + 1)
|
|
135
|
+
}
|
|
136
|
+
return [...counts.entries()]
|
|
137
|
+
.toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
138
|
+
.slice(0, limit)
|
|
139
|
+
.map(([word]) => word)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const MIN_TRIGGER_PROMPTS = 2
|
|
143
|
+
|
|
144
|
+
function plausible(word: string, count: number): boolean {
|
|
145
|
+
if (count >= MIN_TRIGGER_PROMPTS) return true
|
|
146
|
+
if (word.length < 8) return false
|
|
147
|
+
return !/[bcdfghjklmnpqrstvwxz]{4,}/.test(word)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The trigger words are the ones that say the situation, not the ones every
|
|
152
|
+
* request has. The corpus says which is which: a word the prompts never used
|
|
153
|
+
* cannot route to anything, so it is not a trigger, and a word most of them
|
|
154
|
+
* carry would put the tool in every request. A token seen once has to look like
|
|
155
|
+
* a word, because a typo is the rarest token in the corpus and routes nowhere.
|
|
156
|
+
*/
|
|
157
|
+
export function rareTriggersOf(cue: string, rarity: PromptWords, limit = 6): string[] {
|
|
158
|
+
const words = [
|
|
159
|
+
...new Set((cue.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? []).filter((word) => !STOP.has(word)))
|
|
160
|
+
]
|
|
161
|
+
const seen = words
|
|
162
|
+
.map((word) => ({ word, count: rarity.words.get(word) ?? 0 }))
|
|
163
|
+
.filter((entry) => entry.count > 0 && plausible(entry.word, entry.count))
|
|
164
|
+
.toSorted((a, b) => a.count - b.count || a.word.localeCompare(b.word))
|
|
165
|
+
const rare = seen.filter((entry) => entry.count <= Math.max(1, Math.floor(rarity.count * COMMON_RARITY)))
|
|
166
|
+
const kept = rare.length > 0 ? rare : seen.slice(0, 3)
|
|
167
|
+
return kept.slice(0, limit).map((entry) => entry.word)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rareEnough(word: string, rarity?: PromptWords): boolean {
|
|
171
|
+
if (rarity === undefined || rarity.count === 0) return word.length >= 6
|
|
172
|
+
return (rarity.words.get(word) ?? 0) / rarity.count <= 0.005
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One word is a coincidence: a request that says "key" is not a request about
|
|
177
|
+
* credentials. Two triggers, or one long rare one, is a situation.
|
|
178
|
+
*/
|
|
179
|
+
export function matchLessons(lessons: Lesson[], prompt: string, rarity?: PromptWords): Lesson[] {
|
|
180
|
+
const words = new Set(prompt.toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g) ?? [])
|
|
181
|
+
const scored = lessons
|
|
182
|
+
.filter((lesson) => lesson.state !== 'superseded' && lesson.state !== 'rejected' && lesson.state !== 'failed')
|
|
183
|
+
.map((lesson) => {
|
|
184
|
+
const triggers = lesson.triggers.length > 0 ? lesson.triggers : triggersOf(lesson.cue)
|
|
185
|
+
const matched = triggers.filter((word) => words.has(word) && !commonIn(word, rarity))
|
|
186
|
+
const strong = matched.some((word) => word.length >= 6 && rareEnough(word, rarity))
|
|
187
|
+
return { lesson, hits: matched.length, strong }
|
|
188
|
+
})
|
|
189
|
+
.filter((entry) => entry.hits >= 2 || (entry.hits === 1 && entry.strong))
|
|
190
|
+
.toSorted((a, b) => b.hits - a.hits || b.lesson.at - a.lesson.at)
|
|
191
|
+
return scored.map((entry) => entry.lesson)
|
|
192
|
+
}
|