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