@tduyng/pi-timbre 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/memory.ts ADDED
@@ -0,0 +1,238 @@
1
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { createHash } from 'node:crypto'
5
+ import { Validator } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ import type { JsonValue } from '@earendil-works/pi-ai'
9
+ const isBoolean = new Validator(t.boolean()).isValidObject
10
+ const isStringArray = new Validator(t.array(t.string())).isValidObject
11
+ const isString = new Validator(t.string()).isValidObject
12
+
13
+ type JsonObject = { [key: string]: JsonValue }
14
+
15
+ export type Domain = 'prose'
16
+
17
+ export type Sample = {
18
+ at: number
19
+ prevented: boolean
20
+ domain: Domain
21
+ chars: number
22
+ hits: number
23
+ ids: string[]
24
+ cacheRead?: number
25
+ cacheWrite?: number
26
+ }
27
+
28
+ export type Settings = {
29
+ prevent?: boolean
30
+ }
31
+
32
+ export type Notice = {
33
+ at: number
34
+ session: string
35
+ path: string
36
+ }
37
+
38
+ export type Memory = {
39
+ version: 1
40
+ samples: Sample[]
41
+ notices?: Notice[]
42
+ settings?: Settings
43
+ }
44
+
45
+ const EMPTY: Memory = { version: 1, samples: [], notices: [] }
46
+ const MAX_SAMPLES = 500
47
+ const MAX_NOTICES = 500
48
+ const MAX_MEMORY_BYTES = 2 * 1024 * 1024
49
+
50
+ export function memoryPath(projectRoot: string): string {
51
+ const digest = createHash('sha256').update(projectRoot).digest('hex').slice(0, 16)
52
+ const base = process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state')
53
+ return join(base, 'timbre', `${digest}.json`)
54
+ }
55
+
56
+ export function load(path: string): Memory {
57
+ try {
58
+ if (statSync(path).size > MAX_MEMORY_BYTES) return { ...EMPTY }
59
+ const raw = readFileSync(path, 'utf-8')
60
+ return parseMemory(JSON.parse(raw) as JsonValue)
61
+ } catch {
62
+ return { ...EMPTY }
63
+ }
64
+ }
65
+
66
+ const UnknownRecordSchema = t.object({}, { additionalProperties: true })
67
+ const isUnknownRecordSchema = new Validator(UnknownRecordSchema).isValidObject
68
+ const FiniteNonnegativeSchema = t.number({ minimum: 0 })
69
+ const isFiniteNonnegativeSchema = new Validator(FiniteNonnegativeSchema).isValidObject
70
+ const DomainSchema = t.literal('prose')
71
+ const isDomainSchema = new Validator(DomainSchema).isValidObject
72
+
73
+ function isRecord(value: JsonValue | undefined): value is JsonObject {
74
+ return isUnknownRecordSchema(value)
75
+ }
76
+
77
+ function finiteNonnegative(value: JsonValue | undefined): value is number {
78
+ return isFiniteNonnegativeSchema(value) && Number.isFinite(value)
79
+ }
80
+
81
+ function parseSample(value: JsonValue): Sample | undefined {
82
+ if (!isRecord(value)) return undefined
83
+ const at = value['at']
84
+ const prevented = value['prevented']
85
+ const domain = value['domain'] ?? 'prose'
86
+ const chars = value['chars']
87
+ const hits = value['hits']
88
+ if (!finiteNonnegative(at) || !isBoolean(prevented) || !finiteNonnegative(chars) || !finiteNonnegative(hits)) {
89
+ return undefined
90
+ }
91
+ if (!isDomainSchema(domain)) return undefined
92
+ const ids = isStringArray(value['ids']) ? value['ids'].slice(0, 100) : []
93
+ const sample: Sample = { at, prevented, domain, chars, hits, ids }
94
+ const cacheRead = value['cacheRead']
95
+ const cacheWrite = value['cacheWrite']
96
+ if (finiteNonnegative(cacheRead)) sample.cacheRead = cacheRead
97
+ if (finiteNonnegative(cacheWrite)) sample.cacheWrite = cacheWrite
98
+ return sample
99
+ }
100
+
101
+ function parseNotice(value: JsonValue): Notice | undefined {
102
+ if (!isRecord(value)) return undefined
103
+ const at = value['at']
104
+ const session = value['session']
105
+ const path = value['path']
106
+ if (!finiteNonnegative(at)) return undefined
107
+ if (!isString(session) || !isString(path)) return undefined
108
+ return { at, session, path }
109
+ }
110
+
111
+ export function parseMemory(parsed: JsonValue | undefined): Memory {
112
+ if (!isRecord(parsed) || parsed['version'] !== 1) return { ...EMPTY }
113
+ const rows = Array.isArray(parsed['samples']) ? parsed['samples'].slice(-MAX_SAMPLES) : []
114
+ const noted = Array.isArray(parsed['notices']) ? parsed['notices'].slice(-MAX_NOTICES) : []
115
+ return {
116
+ version: 1,
117
+ samples: evict(rows.map(parseSample).filter((sample): sample is Sample => sample !== undefined)),
118
+ notices: noted.map(parseNotice).filter((notice): notice is Notice => notice !== undefined),
119
+ settings: sanitizeSettings(parsed['settings'])
120
+ }
121
+ }
122
+
123
+ function sanitizeSettings(raw: JsonValue | undefined): Settings | undefined {
124
+ if (!isRecord(raw)) return undefined
125
+ const settings: Settings = {}
126
+ const prevent = raw['prevent']
127
+ if (prevent === true || prevent === false) settings.prevent = prevent
128
+ return Object.keys(settings).length > 0 ? settings : undefined
129
+ }
130
+
131
+ export function save(path: string, memory: Memory): void {
132
+ try {
133
+ mkdirSync(dirname(path), { recursive: true })
134
+ const tmp = `${path}.${process.pid}.tmp`
135
+ writeFileSync(tmp, JSON.stringify(memory), 'utf-8')
136
+ renameSync(tmp, path)
137
+ } catch {}
138
+ }
139
+
140
+ export function merge(a: Memory, b: Memory): Memory {
141
+ const seen = new Set(a.samples.map(key))
142
+ const samples = [...a.samples, ...b.samples.filter((s) => !seen.has(key(s)))].toSorted((x, y) => x.at - y.at)
143
+ const mineNotices = a.notices ?? []
144
+ const theirsNotices = b.notices ?? []
145
+ const seenNotices = new Set(mineNotices.map(noticeKey))
146
+ const notices = [...mineNotices, ...theirsNotices.filter((x) => !seenNotices.has(noticeKey(x)))]
147
+ return {
148
+ version: 1,
149
+ samples: evict(samples),
150
+ notices: evictNotices(notices),
151
+ settings: b.settings ?? a.settings
152
+ }
153
+ }
154
+
155
+ function key(s: Sample): string {
156
+ return `${s.at}:${s.prevented}:${s.domain}:${s.chars}:${s.hits}`
157
+ }
158
+
159
+ export function record(memory: Memory, ids: string[], sample: Omit<Sample, 'ids'>): Memory {
160
+ return { ...memory, samples: evict([...memory.samples, { ...sample, ids }]) }
161
+ }
162
+
163
+ function noticeKey(n: Notice): string {
164
+ return `${n.session}:${n.path}`
165
+ }
166
+
167
+ export function appendNotice(memory: Memory, notice: Notice): Memory {
168
+ return { ...memory, notices: evictNotices([...(memory.notices ?? []), notice]) }
169
+ }
170
+
171
+ export function noticed(memory: Memory, session: string, path: string): boolean {
172
+ return (memory.notices ?? []).some((n) => n.session === session && n.path === path)
173
+ }
174
+
175
+ function evictNotices(notices: Notice[]): Notice[] {
176
+ return notices.length <= MAX_NOTICES ? notices : notices.slice(-MAX_NOTICES)
177
+ }
178
+
179
+ function evict(samples: Sample[]): Sample[] {
180
+ if (samples.length <= MAX_SAMPLES) return samples
181
+ const on = samples.filter((s) => s.prevented)
182
+ const off = samples.filter((s) => !s.prevented)
183
+ const [small, large] = on.length <= off.length ? [on, off] : [off, on]
184
+
185
+ const smallKeep = Math.min(small.length, Math.floor(MAX_SAMPLES / 2))
186
+ const largeKeep = MAX_SAMPLES - smallKeep
187
+ return [...small.slice(-smallKeep), ...large.slice(-largeKeep)].toSorted((a, b) => a.at - b.at)
188
+ }
189
+
190
+ export function tallyIds(samples: Sample[]): Map<string, number> {
191
+ const counts = new Map<string, number>()
192
+ for (const s of samples) for (const id of s.ids) counts.set(id, (counts.get(id) ?? 0) + 1)
193
+ return counts
194
+ }
195
+
196
+ export type Effect = {
197
+ withPrevention: { samples: number; density: number }
198
+ withoutPrevention: { samples: number; density: number }
199
+ delta: number | null
200
+ }
201
+
202
+ export function effect(memory: Memory, domain: Domain = 'prose'): Effect {
203
+ const rows = memory.samples.filter((s) => s.domain === domain)
204
+ const on = rows.filter((s) => s.prevented)
205
+ const off = rows.filter((s) => !s.prevented)
206
+ const onD = density(on)
207
+ const offD = density(off)
208
+ return {
209
+ withPrevention: { samples: on.length, density: onD },
210
+ withoutPrevention: { samples: off.length, density: offD },
211
+ delta:
212
+ off.length === 0 || on.length === 0
213
+ ? null
214
+ : offD === 0
215
+ ? onD === 0
216
+ ? 0
217
+ : -100
218
+ : ((offD - onD) / offD) * 100
219
+ }
220
+ }
221
+
222
+ function density(xs: Sample[]): number {
223
+ const chars = xs.reduce((a, s) => a + s.chars, 0)
224
+ const hits = xs.reduce((a, s) => a + s.hits, 0)
225
+ return chars === 0 ? 0 : (hits / chars) * 1000
226
+ }
227
+
228
+ export type CacheSummary = { turns: number; avgCacheRead: number; avgCacheWrite: number }
229
+
230
+ export function cacheSummary(memory: Memory, prevented: boolean): CacheSummary | null {
231
+ const turns = memory.samples.filter(
232
+ (s) => s.domain === 'prose' && s.prevented === prevented && s.cacheRead !== undefined
233
+ )
234
+ if (turns.length === 0) return null
235
+ const reads = turns.reduce((a, s) => a + (s.cacheRead ?? 0), 0)
236
+ const writes = turns.reduce((a, s) => a + (s.cacheWrite ?? 0), 0)
237
+ return { turns: turns.length, avgCacheRead: reads / turns.length, avgCacheWrite: writes / turns.length }
238
+ }
@@ -0,0 +1,255 @@
1
+ export type Tier = 'forensic' | 1 | 2
2
+ export type Scope = 'prose' | 'url'
3
+
4
+ export type Pattern = {
5
+ id: string
6
+ tier: Tier
7
+ re: RegExp
8
+ hint: string
9
+ scope?: Scope
10
+ }
11
+
12
+ export const PATTERNS: Pattern[] = [
13
+ {
14
+ id: 'tool-leakage',
15
+ tier: 'forensic',
16
+ scope: 'url',
17
+ re: /citeturn\d|contentReference\[oaicite:|oai_citation|attributableIndex|utm_source=(chatgpt\.com|openai|copilot\.com|perplexity)|referrer=grok\.com/i,
18
+ hint: 'Strip the citation token or tracking parameter.'
19
+ },
20
+ {
21
+ id: 'invisible-unicode',
22
+ tier: 'forensic',
23
+ scope: 'url',
24
+ re: /\u200B|\u200C|\u200D|\u2060|\uFEFF|\u00AD/,
25
+ hint: 'Delete the zero-width or soft-hyphen character.'
26
+ },
27
+ {
28
+ id: 'placeholder',
29
+ tier: 'forensic',
30
+ re: /\[(your name|insert [a-z ]+|company name|todo)\]|\b20\d\d-XX-XX\b/i,
31
+ hint: 'Fill the placeholder or cut the line.'
32
+ },
33
+ {
34
+ id: 'reasoning-artifact',
35
+ tier: 'forensic',
36
+ re: /^\s*(first, i'?ll\b|let me think\b|breaking this down\b)/i,
37
+ hint: 'Delete the scaffolding, keep the conclusion.'
38
+ },
39
+
40
+ {
41
+ id: 'em-dash',
42
+ tier: 1,
43
+ re: /—|–/,
44
+ hint: 'Comma, period, colon, or parentheses.'
45
+ },
46
+ {
47
+ id: 'semicolon',
48
+ tier: 1,
49
+ re: /(?<!&#\d{1,6}|&#x[0-9a-f]{1,5}|&[a-z][a-z0-9]{1,30});/i,
50
+ hint: 'Split into two sentences.'
51
+ },
52
+ {
53
+ id: 'curly-quote',
54
+ tier: 1,
55
+ re: /[‘’“”…]/,
56
+ hint: 'Straight quotes, three periods.'
57
+ },
58
+ {
59
+ id: 'emoji',
60
+ tier: 1,
61
+ re: /[\u{1F300}-\u{1FAFF}\u{1F900}-\u{1F9FF}]/u,
62
+ hint: 'Remove the decoration.'
63
+ },
64
+ {
65
+ id: 'bold-label-bullet',
66
+ tier: 1,
67
+ re: /^\s*[-*+]\s+\*\*[^*]{1,40}:\*\*/,
68
+ hint: 'Write it as prose, or drop the bold label.'
69
+ },
70
+ {
71
+ id: 'list-label-period',
72
+ tier: 1,
73
+ re: /^\s*[-*+]\s+\*\*[^*]{1,40}\.\*\*\s+\S/,
74
+ hint: 'Use a colon, not a period, on a list label. Or write it as prose.'
75
+ },
76
+
77
+ {
78
+ id: 'ai-vocabulary',
79
+ tier: 1,
80
+ re: /\b(tapestry|nestled|boasts|bustling|myriad|leverage|leveraging|delve|delving|garner|garnering|holistic|multifaceted|synergy|treasure trove|utilize|utilizing|facilitate|facilitating|elucidate|endeavor|encompass|encompassing|paradigm|catalyze|catalyzing|juxtapose|realm|plethora|galvanize|galvanizing|epitomize|epitomizing|unravel|unraveling|catapult|catapulting|conceptualize|supercharge|supercharging|spearhead|spearheading|commence|commencing|top[- ]notch)\b/i,
81
+ hint: 'Use the plain word. "utilize" is always "use".'
82
+ },
83
+
84
+ {
85
+ id: 'negative-parallelism',
86
+ tier: 1,
87
+ re: /\bit(?:'?s| is) not (just|merely)\b|\bnot only\b.{0,60}\bbut also\b|\b(?:isn'?t|aren'?t|wasn'?t|weren'?t)\b[^.\n]{2,60},\s*(?:it|that)'s\b/i,
88
+ hint: 'State the positive claim alone.'
89
+ },
90
+ {
91
+ id: 'throat-clearing',
92
+ tier: 1,
93
+ re: /\bhere(?:'?s| is) the thing\b|\bin today'?s\b|\bat its core\b|\bit(?:'?s| is) worth noting\b|\bit(?:'?s| is) important to (note|remember|highlight|mention)\b|\bthe real question is\b|\bwhen it comes to\b|\bat the end of the day\b|\bneedless to say\b|\bit turns out\b|\bthe truth is\b|\blet me be clear\b|\bin a nutshell\b|\brest assured\b|\bit goes without saying\b/i,
94
+ hint: 'Delete it and state the point.'
95
+ },
96
+ {
97
+ id: 'signposting',
98
+ tier: 1,
99
+ re: /\blet'?s (dive|explore|break (this|it) down)|\bdelv(e|ing) into\b|\bwithout further ado\b/i,
100
+ hint: 'Delete the announcement, keep the content.'
101
+ },
102
+ {
103
+ id: 'mannered-flourish',
104
+ tier: 1,
105
+ re: /\bearn(?:s|ed)? (?:its|their) keep\b|\bpull(?:s|ed)? (?:its|their) (?:own )?weight\b|\b(?:does|doing|did) the heavy lifting\b|\bmoves? the needle\b/i,
106
+ hint: 'Say the literal thing: what it does, or why it matters.'
107
+ },
108
+ {
109
+ id: 'chat-artifact',
110
+ tier: 1,
111
+ re: /\bI hope this helps\b|\bwould you like me to\b|\bgreat question\b|\byou'?re absolutely right\b|\bfeel free to\b|\bdon'?t hesitate to\b|\bI love that you asked\b|\bhappy to help\b|\bi'?d be happy to\b|\blet me know if\b|\bcertainly!\b|\bof course!\b|\bthat'?s a great point\b|\byou'?re asking about\b|\bto answer your question\b/i,
112
+ hint: 'Delete the assistant chatter, answer directly.'
113
+ },
114
+
115
+ {
116
+ id: 'claudism',
117
+ tier: 1,
118
+ re: /\bworth sitting with\b|\bkeep arriving at\b|\bthe question (that kept|that keeps|i keep) coming (up|back to)\b|\bhere'?s where it gets interesting\b|\bdouble-click on\b|\bthroughline\b|\bthrough-line\b|\bthe whole (game|ballgame)\b|\bthat'?s the entire point\b|\bthe only thing that (matters|mattered|happened|changed)\b|\bworth (talking about|drawing|making|examining|considering|exploring)\b|\b(which |that )?brings (me|us) back to\b|\bwe'?ve seen this movie before\b|\bthe thing that (got me|hooked me|got to me)\b|\bdispatches from\b|\bi can'?t stop thinking about\b|\bthat'?s the tell\b|\bthe tell is\b|\bshed light on\b|\bpave(s|d)? the way\b|\bit cannot be overstated\b|\bthis is where\b.{0,40}\bcomes in\b/i,
119
+ hint: 'Pseudo-reflective framing. State the thing directly.'
120
+ },
121
+
122
+ {
123
+ id: 'false-agency',
124
+ tier: 1,
125
+ re: /\b(?:numbers|results|data|figures) speak for (?:themselves|itself)\b|\btells? a (?:clear )?story\b|\bpaints? a (?:clear |vivid )?picture\b|\bspeaks volumes\b/i,
126
+ hint: 'State the finding yourself. Data does not narrate.'
127
+ },
128
+ {
129
+ id: 'colon-reveal',
130
+ tier: 1,
131
+ re: /\b(?:the )?(?:answer|secret|trick|truth|reality|lesson) (?:is|was|remains)\s*:|\b(?:the )?(?:bottom line|key takeaway|takeaway)\s*:/i,
132
+ hint: 'State the point. The setup colon manufactures a reveal.'
133
+ },
134
+
135
+ {
136
+ id: 'future-narrative',
137
+ tier: 1,
138
+ re: /\bthe (?:next|coming|upcoming)\s+(?:era|decade|chapter|frontier|market cycle|paradigm|iteration|wave|phase|epoch)\b|\bis (?:set|destined|poised)\s+to\s+(?:be|become|define|reshape|redefine|transform)\b/i,
139
+ hint: 'Pick the falsifiable version, or cut it. The template shape is not a prediction.'
140
+ },
141
+ {
142
+ id: 'confidence-calibration',
143
+ tier: 1,
144
+ re: /\b(?:interestingly|surprisingly|notably|fascinatingly|remarkably|unsurprisingly|tellingly)\b/i,
145
+ hint: 'Let the fact speak for itself. Cut the calibration word.'
146
+ },
147
+ {
148
+ id: 'listicle-trench',
149
+ tier: 1,
150
+ re: /\bthe (?:first|second|third) (?:wall|step|pillar|phase|wave|stage|reason|lesson|takeaway) is\b/i,
151
+ hint: 'A hidden numbered list. Make it a list, or weave the points without numbering.'
152
+ },
153
+
154
+ {
155
+ id: 'fronted-participle',
156
+ tier: 2,
157
+ re: /(?:^|(?<=[.!?])\s+)(?:(?!(?:According|Regarding|Concerning|Depending|Seeing|Nothing|Something|Anything|Everything|Morning|Evening|Building|String|During|Ceiling|Meeting|Spring|Ring|Wing|King)\b)[A-Z][a-z]*ing\s+(?:the|a|an|its|their|his|her|our|your|this|that|these|those|on|in|into|with|for|from|at|by|to)|Having\s+[a-z]+(?:ed|ing|en)|By\s+[a-z]+ing)\b[^,.;:!?\n]{0,60}?,\s+(?:the|a|an|this|that|these|those|its|their|our|your|his|her|we|you|they|it|he|she|i)\b/,
158
+ hint: 'Name the actor first. "The team ran the suite" beats "Running the suite, the team".'
159
+ },
160
+ {
161
+ id: 'participial-padding',
162
+ tier: 2,
163
+ re: /,\s+(?:highlighting|underscoring|showcasing|fostering|reflecting|demonstrating|signaling|signalling|ensuring|emphasizing|emphasising|paving the way)\b/i,
164
+ hint: 'The -ing tail adds significance instead of information. Cut it, or make it a sentence.'
165
+ }
166
+ ]
167
+
168
+ export const TIER_2_WORDS = [
169
+ 'robust',
170
+ 'comprehensive',
171
+ 'innovative',
172
+ 'streamline',
173
+ 'empower',
174
+ 'enhance',
175
+ 'optimize',
176
+ 'scalable',
177
+ 'profound',
178
+ 'cultivate',
179
+ 'bolster',
180
+ 'cornerstone',
181
+ 'groundbreaking',
182
+ 'transformative',
183
+ 'unprecedented',
184
+ 'compelling',
185
+ 'versatile',
186
+ 'vibrant',
187
+ 'hone',
188
+ 'tailor',
189
+ 'revolutionize',
190
+ 'amplify',
191
+ 'discern',
192
+ 'cutting-edge',
193
+ 'pivotal',
194
+ 'seamless',
195
+ 'harness',
196
+ 'unlock',
197
+ 'unleash',
198
+ 'elevate',
199
+ 'foster',
200
+ 'meticulous',
201
+ 'invaluable',
202
+ 'unwavering',
203
+ 'bespoke',
204
+ 'interplay',
205
+ 'intricate',
206
+ 'resonate',
207
+ 'illuminate',
208
+ 'captivate',
209
+ 'nuanced',
210
+ 'paramount',
211
+ 'burgeoning',
212
+ 'ubiquitous',
213
+ 'aforementioned',
214
+ 'pertaining to',
215
+ 'noteworthy',
216
+ 'delineate',
217
+ 'underpin',
218
+ 'unveil',
219
+ 'double-edged sword',
220
+ 'sheds light',
221
+ 'strikes a balance',
222
+ 'substrate',
223
+ 'flywheel',
224
+ 'north star',
225
+ 'nexus',
226
+ 'endgame',
227
+ 'gold-plating',
228
+ 'vantage point',
229
+ 'locus',
230
+ 'quietly',
231
+ 'genuinely',
232
+ 'inherently'
233
+ ] as const
234
+
235
+ export const TIER_3_PHRASES = [
236
+ 'the integration of',
237
+ 'decentralized compute',
238
+ 'community-driven',
239
+ 'long-term sustainability',
240
+ 'seamless integration',
241
+ 'cutting-edge solution',
242
+ 'robust solution',
243
+ 'future-proof',
244
+ 'best-in-class',
245
+ 'world-class',
246
+ 'turn-key',
247
+ 'scalable solution',
248
+ 'streamlined workflow',
249
+ 'powerful tool',
250
+ 'blazing fast',
251
+ 'state-of-the-art',
252
+ 'next-generation'
253
+ ] as const
254
+
255
+ export const byId = new Map(PATTERNS.map((p) => [p.id, p]))
@@ -0,0 +1,35 @@
1
+ export type Register = 'reference' | 'changelog' | 'narrative' | 'chat' | 'unknown'
2
+
3
+ export type Profile = {
4
+ register: Register
5
+ terseIsCorrect: boolean
6
+ voiceAllowed: boolean
7
+ }
8
+
9
+ const PROFILES = {
10
+ reference: { register: 'reference', terseIsCorrect: true, voiceAllowed: false },
11
+ changelog: { register: 'changelog', terseIsCorrect: true, voiceAllowed: false },
12
+ narrative: { register: 'narrative', terseIsCorrect: false, voiceAllowed: true },
13
+ chat: { register: 'chat', terseIsCorrect: true, voiceAllowed: true },
14
+ unknown: { register: 'unknown', terseIsCorrect: false, voiceAllowed: false }
15
+ } satisfies Record<Register, Profile>
16
+
17
+ const SEG = String.raw`(?:^|/)`
18
+
19
+ const RULES: Array<[RegExp, Register]> = [
20
+ [new RegExp(`${SEG}(blog|posts?|essays?|articles?|drafts?)/`, 'i'), 'narrative'],
21
+ [new RegExp(`${SEG}(CHANGELOG|HISTORY|RELEASE[-_]?NOTES)`, 'i'), 'changelog'],
22
+ [/COMMIT_EDITMSG|\.git\//, 'changelog'],
23
+ [new RegExp(`${SEG}(adr|rfc|spec|specs|reference|api)/`, 'i'), 'reference'],
24
+ [/(README|CONTRIBUTING|AGENTS|CLAUDE)\.mdx?$/i, 'reference'],
25
+ [new RegExp(`${SEG}docs?/`, 'i'), 'reference']
26
+ ]
27
+
28
+ export function classify(path: string): Profile {
29
+ for (const [re, register] of RULES) {
30
+ if (re.test(path)) return PROFILES[register]
31
+ }
32
+ return PROFILES.unknown
33
+ }
34
+
35
+ export const chatProfile = PROFILES.chat