@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/LICENSE +178 -0
- package/README.md +156 -2
- package/package.json +45 -4
- package/src/README.md +385 -0
- package/src/code.ts +154 -0
- package/src/config.ts +21 -0
- package/src/guidance/catalog.md +17 -0
- package/src/guidance/review.md +438 -0
- package/src/guide.ts +26 -0
- package/src/index.ts +691 -0
- package/src/memory.ts +238 -0
- package/src/patterns.ts +255 -0
- package/src/register.ts +35 -0
- package/src/report.ts +205 -0
- package/src/rules.ts +37 -0
- package/src/scan.ts +328 -0
- package/src/segments.ts +81 -0
- package/src/stats.ts +112 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { closeSync, existsSync, globSync, openSync, readFileSync, readSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
|
|
4
|
+
import {
|
|
5
|
+
BeforeAgentStartEvent,
|
|
6
|
+
BeforeAgentStartEventResult,
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionContext,
|
|
9
|
+
isToolCallEventType,
|
|
10
|
+
MessageEndEvent,
|
|
11
|
+
ToolResultEvent
|
|
12
|
+
} from '@earendil-works/pi-coding-agent'
|
|
13
|
+
import { Validator } from 'ata-validator'
|
|
14
|
+
import { t } from 'ata-validator/t'
|
|
15
|
+
|
|
16
|
+
import { loadConfig, type Config } from './config.js'
|
|
17
|
+
import {
|
|
18
|
+
type Domain,
|
|
19
|
+
type Effect,
|
|
20
|
+
appendNotice,
|
|
21
|
+
effect,
|
|
22
|
+
load,
|
|
23
|
+
memoryPath,
|
|
24
|
+
merge,
|
|
25
|
+
noticed,
|
|
26
|
+
record,
|
|
27
|
+
save,
|
|
28
|
+
type Memory,
|
|
29
|
+
type Settings
|
|
30
|
+
} from './memory.js'
|
|
31
|
+
|
|
32
|
+
import { GUIDE_TOOL_NAME, registerGuide } from './guide.js'
|
|
33
|
+
import { classify, type Profile } from './register.js'
|
|
34
|
+
import { codeCheckProblem, codePath, scanCodeBatch, type CodeFinding } from './code.js'
|
|
35
|
+
import {
|
|
36
|
+
formatCodeFindings,
|
|
37
|
+
formatFindRequest,
|
|
38
|
+
formatFixRequest,
|
|
39
|
+
formatHits,
|
|
40
|
+
formatStats,
|
|
41
|
+
type FixTarget
|
|
42
|
+
} from './report.js'
|
|
43
|
+
import { catalogAvailable, generationRules } from './rules.js'
|
|
44
|
+
import { aggregateSpans } from './segments.js'
|
|
45
|
+
import { type Hit, isIgnored, proseLength, proseLineChars, scanProse } from './scan.js'
|
|
46
|
+
import {
|
|
47
|
+
burstiness,
|
|
48
|
+
hasClauseStack,
|
|
49
|
+
hasLongSentenceRun,
|
|
50
|
+
hasStaccatoRun,
|
|
51
|
+
isMonotone,
|
|
52
|
+
repeatedOpening,
|
|
53
|
+
wordCount
|
|
54
|
+
} from './stats.js'
|
|
55
|
+
|
|
56
|
+
type AssistantContent = AssistantMessage['content']
|
|
57
|
+
|
|
58
|
+
type ToolResultPatch = { content: ToolResultEvent['content'] }
|
|
59
|
+
|
|
60
|
+
const PROSE_FILE = /\.(md|mdx|markdown|txt)$/i
|
|
61
|
+
const PATH_TOKEN = /^@|[/\\]|\.[A-Za-z0-9]{1,8}$/
|
|
62
|
+
const GLOB_TOKEN = /[*?[]/
|
|
63
|
+
const GLOB_EXCLUDE = /(^|\/)(node_modules|\.git|dist|build|out|coverage|target|vendor|\.venv|\.next)(\/|$)/
|
|
64
|
+
const MAX_FIX_FILES = 10
|
|
65
|
+
const MAX_FIX_FILE_BYTES = 2 * 1024 * 1024
|
|
66
|
+
const SUBCOMMANDS = ['on', 'off', 'fix', 'reset', 'stats', 'status']
|
|
67
|
+
|
|
68
|
+
function setGuideTool(pi: ExtensionAPI, enabled: boolean): void {
|
|
69
|
+
const active = pi.getActiveTools()
|
|
70
|
+
if (active.length === 0) return
|
|
71
|
+
if (active.includes(GUIDE_TOOL_NAME) === enabled) return
|
|
72
|
+
pi.setActiveTools(enabled ? [...active, GUIDE_TOOL_NAME] : active.filter((name) => name !== GUIDE_TOOL_NAME))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const MIN_CHAT_PROSE = 40
|
|
76
|
+
|
|
77
|
+
const DEFAULT_NOTE = ' (project default until changed again; next turn re-primes the cache)'
|
|
78
|
+
|
|
79
|
+
export default function timbre(pi: ExtensionAPI): void {
|
|
80
|
+
const config = loadConfig()
|
|
81
|
+
if (!config.enabled) return
|
|
82
|
+
|
|
83
|
+
registerGuide(pi)
|
|
84
|
+
|
|
85
|
+
pi.on('session_start', (_event, ctx) => {
|
|
86
|
+
reportedCode.clear()
|
|
87
|
+
try {
|
|
88
|
+
const over = carriedOver(forProject(ctx.cwd), config)
|
|
89
|
+
if (over.length === 0) return
|
|
90
|
+
ctx.ui.notify(
|
|
91
|
+
`timbre: overrides from an earlier /timbre are still active — ${over.join(', ')}. Run /timbre to review, or /timbre reset to clear.`,
|
|
92
|
+
'info'
|
|
93
|
+
)
|
|
94
|
+
} catch {}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
let preventing = config.prevent
|
|
98
|
+
|
|
99
|
+
let settingsReady = false
|
|
100
|
+
|
|
101
|
+
let turnPrevented = config.prevent
|
|
102
|
+
const preventedNow = (): boolean => turnPrevented
|
|
103
|
+
|
|
104
|
+
let pendingWarning: string | undefined
|
|
105
|
+
const touchedCode = new Set<string>()
|
|
106
|
+
const reportedCode = new Map<string, string>()
|
|
107
|
+
let noticesHeld = 0
|
|
108
|
+
let block: { path: string; text: string } | undefined
|
|
109
|
+
|
|
110
|
+
let cached: { path: string; memory: Memory; mtimeMs: number; size: number } | undefined
|
|
111
|
+
const stamp = (path: string) => {
|
|
112
|
+
const stat = statSync(path, { throwIfNoEntry: false })
|
|
113
|
+
return { mtimeMs: stat?.mtimeMs ?? 0, size: stat?.size ?? 0 }
|
|
114
|
+
}
|
|
115
|
+
const remember = (path: string, memory: Memory): void => {
|
|
116
|
+
cached = { path, memory, ...stamp(path) }
|
|
117
|
+
}
|
|
118
|
+
const forProject = (cwd: string): Memory => {
|
|
119
|
+
const path = memoryPath(cwd)
|
|
120
|
+
const now = stamp(path)
|
|
121
|
+
if (cached?.path === path && cached.mtimeMs === now.mtimeMs && cached.size === now.size) {
|
|
122
|
+
return cached.memory
|
|
123
|
+
}
|
|
124
|
+
const memory = load(path)
|
|
125
|
+
remember(path, memory)
|
|
126
|
+
return memory
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const commit = (cwd: string, next: Memory): void => {
|
|
130
|
+
const path = memoryPath(cwd)
|
|
131
|
+
const merged = merge(forProject(cwd), next)
|
|
132
|
+
save(path, merged)
|
|
133
|
+
remember(path, merged)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ensureSettings = (cwd: string): void => {
|
|
137
|
+
if (settingsReady) return
|
|
138
|
+
settingsReady = true
|
|
139
|
+
const mem = forProject(cwd)
|
|
140
|
+
if (mem.settings?.prevent !== undefined) preventing = mem.settings.prevent
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const persistSettings = (cwd: string, settings: Settings): void => {
|
|
144
|
+
const path = memoryPath(cwd)
|
|
145
|
+
const mem = forProject(cwd)
|
|
146
|
+
const next: Memory = { ...mem, settings: { ...mem.settings, ...settings } }
|
|
147
|
+
save(path, next)
|
|
148
|
+
remember(path, next)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const healthWarnings = (): string[] => moduleHealthWarnings(config)
|
|
152
|
+
|
|
153
|
+
const statusText = (): string => (preventing ? 'on' : 'off')
|
|
154
|
+
const syncStatus = (ctx: ExtensionContext | undefined): void => {
|
|
155
|
+
try {
|
|
156
|
+
ctx?.ui?.setStatus?.('timbre', `timbre: ${statusText()}`)
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
pi.on('session_before_compact', () => {})
|
|
161
|
+
|
|
162
|
+
pi.on(
|
|
163
|
+
'before_agent_start',
|
|
164
|
+
(event: BeforeAgentStartEvent, ctx: ExtensionContext): BeforeAgentStartEventResult | undefined => {
|
|
165
|
+
try {
|
|
166
|
+
ensureSettings(ctx.cwd)
|
|
167
|
+
} catch {}
|
|
168
|
+
turnPrevented = preventing
|
|
169
|
+
syncStatus(ctx)
|
|
170
|
+
// Armed for the whole session: the tool list is the front of every request.
|
|
171
|
+
setGuideTool(pi, true)
|
|
172
|
+
if (!preventing) return undefined
|
|
173
|
+
try {
|
|
174
|
+
const path = memoryPath(ctx.cwd)
|
|
175
|
+
if (block?.path !== path) block = { path, text: generationRules() }
|
|
176
|
+
if (block.text === '') return undefined
|
|
177
|
+
const current = event.systemPromptOptions.appendSystemPrompt ?? ''
|
|
178
|
+
if (current.includes(block.text)) return undefined
|
|
179
|
+
event.systemPromptOptions.appendSystemPrompt = current ? `${current}\n\n${block.text}` : block.text
|
|
180
|
+
return undefined
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
pi.on('message_end', (event: MessageEndEvent, ctx: ExtensionContext): undefined => {
|
|
188
|
+
if (!config.watchChat) return undefined
|
|
189
|
+
try {
|
|
190
|
+
if (event.message.role !== 'assistant') return undefined
|
|
191
|
+
const text = textOf(event.message.content)
|
|
192
|
+
if (proseLength(text) < MIN_CHAT_PROSE) return undefined
|
|
193
|
+
|
|
194
|
+
const { hits, chars } = scanProse(text)
|
|
195
|
+
const memory = forProject(ctx.cwd)
|
|
196
|
+
commit(
|
|
197
|
+
ctx.cwd,
|
|
198
|
+
record(
|
|
199
|
+
memory,
|
|
200
|
+
hits.map((h) => h.id),
|
|
201
|
+
{
|
|
202
|
+
at: Date.now(),
|
|
203
|
+
prevented: preventedNow(),
|
|
204
|
+
domain: 'prose',
|
|
205
|
+
chars,
|
|
206
|
+
hits: hits.length,
|
|
207
|
+
cacheRead: event.message.usage.cacheRead,
|
|
208
|
+
cacheWrite: event.message.usage.cacheWrite
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
} catch {}
|
|
213
|
+
return undefined
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
pi.on('tool_call', (event, ctx) => {
|
|
217
|
+
try {
|
|
218
|
+
const code = codePath(event, ctx.cwd)
|
|
219
|
+
if (code !== null) touchedCode.add(code)
|
|
220
|
+
|
|
221
|
+
let path: string
|
|
222
|
+
let text: string
|
|
223
|
+
if (isToolCallEventType('write', event)) {
|
|
224
|
+
path = event.input.path
|
|
225
|
+
text = event.input.content
|
|
226
|
+
} else if (isToolCallEventType('edit', event)) {
|
|
227
|
+
path = event.input.path
|
|
228
|
+
text = event.input.edits.map((e) => e.newText).join('\n\n')
|
|
229
|
+
} else {
|
|
230
|
+
return undefined
|
|
231
|
+
}
|
|
232
|
+
if (!PROSE_FILE.test(path) || text === '') return undefined
|
|
233
|
+
|
|
234
|
+
const head = readHeader(isAbsolute(path) ? path : resolve(ctx.cwd, path))
|
|
235
|
+
if (head !== null && isIgnored(head)) return undefined
|
|
236
|
+
|
|
237
|
+
// One notice per file per session. 86% of prose writes land on ten files, so
|
|
238
|
+
// most writes to a path have already been noticed in this session.
|
|
239
|
+
if (!preventing) return undefined
|
|
240
|
+
const session = ctx.sessionManager.getSessionId()
|
|
241
|
+
const memory = forProject(ctx.cwd)
|
|
242
|
+
if (noticed(memory, session, path)) {
|
|
243
|
+
noticesHeld += 1
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
commit(ctx.cwd, appendNotice(memory, { at: Date.now(), session, path }))
|
|
247
|
+
return undefined
|
|
248
|
+
} catch {
|
|
249
|
+
return undefined
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// One oxlint spawn per turn. The note prefers the model's next write or edit result, which is
|
|
254
|
+
// where it is unambiguous and free. A turn whose last act is a write would otherwise lose it,
|
|
255
|
+
// so what is left goes into the transcript as a custom_message entry: a `continue: true` would
|
|
256
|
+
// ask for a model request, and a follow-up message is the stale report this replaced.
|
|
257
|
+
pi.on('turn_end', async (_event, _ctx: ExtensionContext) => {
|
|
258
|
+
let codeNote: string | undefined
|
|
259
|
+
const paths = [...touchedCode]
|
|
260
|
+
touchedCode.clear()
|
|
261
|
+
if (paths.length > 0 && config.enabled) {
|
|
262
|
+
const findings = await scanCodeBatch(paths)
|
|
263
|
+
if (findings !== null) {
|
|
264
|
+
const fresh = freshFindings(findings, reportedCode)
|
|
265
|
+
// Overwrites whatever an earlier turn left undelivered, so a note can never
|
|
266
|
+
// outlive the turn whose code it describes.
|
|
267
|
+
if (fresh.length > 0) codeNote = formatCodeFindings(fresh)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The note goes into the session rather than out as a message: `continue: true` would
|
|
272
|
+
* ask for a model request, and a follow-up message is the stale report this replaced.
|
|
273
|
+
* An entry costs neither and keeps its own provenance.
|
|
274
|
+
*/
|
|
275
|
+
if (codeNote === undefined) return undefined
|
|
276
|
+
return {
|
|
277
|
+
entries: [
|
|
278
|
+
{ type: 'custom_message' as const, customType: 'timbre-code-note', content: codeNote, display: false }
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
pi.on(
|
|
284
|
+
'tool_result',
|
|
285
|
+
async (event: ToolResultEvent, ctx: ExtensionContext): Promise<ToolResultPatch | undefined> => {
|
|
286
|
+
try {
|
|
287
|
+
const found = inspectProse(event, ctx.cwd)
|
|
288
|
+
if (found === null) return undefined
|
|
289
|
+
|
|
290
|
+
const { hits, chars, note: scannedNote, domain } = found
|
|
291
|
+
|
|
292
|
+
commit(
|
|
293
|
+
ctx.cwd,
|
|
294
|
+
record(
|
|
295
|
+
forProject(ctx.cwd),
|
|
296
|
+
hits.map((h) => h.id),
|
|
297
|
+
{ at: Date.now(), prevented: preventedNow(), domain, chars, hits: hits.length }
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
const parts = [pendingWarning, scannedNote === '' ? undefined : scannedNote].filter(
|
|
302
|
+
(part): part is string => part !== undefined
|
|
303
|
+
)
|
|
304
|
+
pendingWarning = undefined
|
|
305
|
+
if (parts.length === 0) return undefined
|
|
306
|
+
return { content: [...event.content, { type: 'text' as const, text: parts.join('\n\n') }] }
|
|
307
|
+
} catch {
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
const setPrevention = async (ctx: ExtensionContext, next: boolean): Promise<void> => {
|
|
314
|
+
preventing = next
|
|
315
|
+
persistSettings(ctx.cwd, { prevent: next })
|
|
316
|
+
syncStatus(ctx)
|
|
317
|
+
ctx.ui.notify(`timbre: prevention ${next ? 'on' : 'off'}${DEFAULT_NOTE}`, 'info')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
pi.registerShortcut('ctrl+alt+t', {
|
|
321
|
+
description: 'Toggle timbre prevention',
|
|
322
|
+
handler: async (ctx) => {
|
|
323
|
+
try {
|
|
324
|
+
ensureSettings(ctx.cwd)
|
|
325
|
+
} catch {}
|
|
326
|
+
await setPrevention(ctx, !preventing)
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
pi.registerCommand('timbre', {
|
|
331
|
+
description: 'Prose prevention status, effect measurement, and on/off',
|
|
332
|
+
handler: async (args, ctx) => {
|
|
333
|
+
const raw = args.trim()
|
|
334
|
+
const arg = raw.toLowerCase()
|
|
335
|
+
|
|
336
|
+
if (arg === 'on' || arg === 'off') {
|
|
337
|
+
await setPrevention(ctx, arg === 'on')
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
const fixArgs = /^fix\b([\s\S]*)$/.exec(raw)
|
|
341
|
+
if (fixArgs) {
|
|
342
|
+
requestFix(pi, ctx, fixArgs[1]!.trim())
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
if (arg === 'reset') {
|
|
346
|
+
const ok = await ctx.ui.confirm('timbre', 'Discard all recorded samples for this project?')
|
|
347
|
+
if (ok) {
|
|
348
|
+
const path = memoryPath(ctx.cwd)
|
|
349
|
+
const cleared: Memory = { version: 1, samples: [] }
|
|
350
|
+
save(path, cleared)
|
|
351
|
+
remember(path, cleared)
|
|
352
|
+
block = undefined
|
|
353
|
+
preventing = config.prevent
|
|
354
|
+
settingsReady = false
|
|
355
|
+
syncStatus(ctx)
|
|
356
|
+
ctx.ui.notify('timbre: memory cleared, live overrides reset to the env defaults', 'info')
|
|
357
|
+
}
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
if (arg !== '' && arg !== 'stats' && arg !== 'status') {
|
|
361
|
+
ctx.ui.notify(`timbre: no subcommand "${raw}". Try: ${SUBCOMMANDS.join(', ')}`, 'warning')
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const memory = forProject(ctx.cwd)
|
|
366
|
+
const effects: Array<[Domain, Effect]> = [['prose', effect(memory, 'prose')]]
|
|
367
|
+
const stats = formatStats(memory, effects, preventing)
|
|
368
|
+
const provenance = configProvenance(memory, config)
|
|
369
|
+
const warnings = healthWarnings()
|
|
370
|
+
const body = [...provenance, '', stats].join('\n')
|
|
371
|
+
if (warnings.length === 0) {
|
|
372
|
+
ctx.ui.notify(body, 'info')
|
|
373
|
+
} else {
|
|
374
|
+
ctx.ui.notify(`${warnings.map((w) => `timbre: ${w}`).join('\n')}\n\n${body}`, 'warning')
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
getArgumentCompletions: (prefix) =>
|
|
378
|
+
SUBCOMMANDS.filter((c) => c.startsWith(prefix)).map((c) => ({ value: c, label: c }))
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The oxlint paths are parameters so a test can force the fault, the same way `scanCodeBatch`
|
|
384
|
+
* takes them. A spawn whose config is missing returns null, which reads exactly like a clean
|
|
385
|
+
* file, so without this a code check that can never run is indistinguishable from one that does.
|
|
386
|
+
*/
|
|
387
|
+
export function moduleHealthWarnings(config: Config, bin?: string, rules?: string): string[] {
|
|
388
|
+
const warnings: string[] = []
|
|
389
|
+
if (!catalogAvailable())
|
|
390
|
+
warnings.push('rule catalog failed to load from disk; layer 0 is injecting nothing this session')
|
|
391
|
+
const codeProblem = codeCheckProblem(bin, rules)
|
|
392
|
+
if (codeProblem !== undefined) warnings.push(codeProblem)
|
|
393
|
+
return warnings
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
type Inspection = { path: string; hits: Hit[]; chars: number; note: string; domain: Domain }
|
|
397
|
+
|
|
398
|
+
function inspectProse(event: ToolResultEvent, cwd: string): Inspection | null {
|
|
399
|
+
const path = prosePath(event, cwd)
|
|
400
|
+
if (path === null) return null
|
|
401
|
+
const text = writtenText(event)
|
|
402
|
+
if (text === null) return null
|
|
403
|
+
|
|
404
|
+
const head = readHeader(path)
|
|
405
|
+
if (head !== null && isIgnored(head)) return null
|
|
406
|
+
|
|
407
|
+
const profile = classify(path)
|
|
408
|
+
const { hits: scanned, chars } = scanProse(text)
|
|
409
|
+
const hits = [
|
|
410
|
+
...(event.toolName === 'edit' ? scanned.filter((h) => h.id !== 'unclosed-fence') : scanned),
|
|
411
|
+
...rhythmHits(text, profile)
|
|
412
|
+
]
|
|
413
|
+
const note = formatHits(path, hits)
|
|
414
|
+
return { path, hits, chars, note: registerNote(note, profile.register), domain: 'prose' }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function scanDisk(path: string): FixTarget | { error: string } {
|
|
418
|
+
if (!PROSE_FILE.test(path)) return { error: `${path} is not a prose file (.md, .mdx, .markdown, .txt)` }
|
|
419
|
+
|
|
420
|
+
let text: string
|
|
421
|
+
try {
|
|
422
|
+
if (statSync(path).size > MAX_FIX_FILE_BYTES) return { error: `${path} is too large to scan safely` }
|
|
423
|
+
text = readFileSync(path, 'utf8')
|
|
424
|
+
} catch {
|
|
425
|
+
return { error: `${path} could not be read` }
|
|
426
|
+
}
|
|
427
|
+
if (isIgnored(text)) return { error: `${path} opts out with a lint-disable marker` }
|
|
428
|
+
|
|
429
|
+
const profile = classify(path)
|
|
430
|
+
const { hits } = scanProse(text)
|
|
431
|
+
const allHits = [...hits, ...rhythmHits(text, profile)]
|
|
432
|
+
const segments = aggregateSpans(proseLineChars(text), allHits)
|
|
433
|
+
return { path, register: profile.register, hits: allHits, segments }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function namesFile(token: string, cwd: string): boolean {
|
|
437
|
+
if (token.startsWith('@') || GLOB_TOKEN.test(token) || PROSE_FILE.test(token)) return true
|
|
438
|
+
if (!PATH_TOKEN.test(token)) return false
|
|
439
|
+
return existsSync(isAbsolute(token) ? token : resolve(cwd, token))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function expandGlob(pattern: string, cwd: string): string[] {
|
|
443
|
+
try {
|
|
444
|
+
return globSync(pattern.includes('/') ? pattern : `**/${pattern}`, {
|
|
445
|
+
cwd,
|
|
446
|
+
exclude: (match) => GLOB_EXCLUDE.test(match)
|
|
447
|
+
})
|
|
448
|
+
.filter((match) => PROSE_FILE.test(match))
|
|
449
|
+
.toSorted()
|
|
450
|
+
.map((match) => resolve(cwd, match))
|
|
451
|
+
} catch {
|
|
452
|
+
return []
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function requestFix(pi: ExtensionAPI, ctx: ExtensionContext, raw: string): void {
|
|
457
|
+
const tokens = raw.split(/\s+/).filter((token) => token !== '')
|
|
458
|
+
const names = tokens.filter((token) => namesFile(token, ctx.cwd))
|
|
459
|
+
let rest = raw
|
|
460
|
+
for (;;) {
|
|
461
|
+
const token = /^(\S+)\s*/.exec(rest)
|
|
462
|
+
if (token === null || !namesFile(token[1]!, ctx.cwd)) break
|
|
463
|
+
rest = rest.slice(token[0].length)
|
|
464
|
+
}
|
|
465
|
+
const instruction = rest.trim()
|
|
466
|
+
|
|
467
|
+
const problems: string[] = []
|
|
468
|
+
const paths: string[] = []
|
|
469
|
+
for (const name of names) {
|
|
470
|
+
const cleaned = name.replace(/^@/, '')
|
|
471
|
+
if (GLOB_TOKEN.test(cleaned)) {
|
|
472
|
+
const matched = expandGlob(cleaned, ctx.cwd)
|
|
473
|
+
if (matched.length === 0) problems.push(`${cleaned} matches no prose file`)
|
|
474
|
+
paths.push(...matched)
|
|
475
|
+
} else paths.push(isAbsolute(cleaned) ? cleaned : resolve(ctx.cwd, cleaned))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const deliver = ctx.isIdle() ? undefined : ({ deliverAs: 'followUp' } as const)
|
|
479
|
+
const fullRules = generationRules()
|
|
480
|
+
|
|
481
|
+
if (paths.length === 0 && problems.length === 0) {
|
|
482
|
+
if (instruction === '') {
|
|
483
|
+
const usage = 'Usage: /timbre fix <path|glob> ... [instructions], as in /timbre fix *.md keep it short'
|
|
484
|
+
ctx.ui.notify(usage, 'warning')
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
pi.sendUserMessage(formatFindRequest(instruction, fullRules), deliver)
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let targets: FixTarget[] = []
|
|
492
|
+
const clean: FixTarget[] = []
|
|
493
|
+
for (const path of new Set(paths)) {
|
|
494
|
+
const found = scanDisk(path)
|
|
495
|
+
if ('error' in found) problems.push(found.error)
|
|
496
|
+
else if (found.hits.length === 0) clean.push(found)
|
|
497
|
+
else targets.push(found)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (targets.length === 0) {
|
|
501
|
+
if (clean.length > 0 && instruction !== '') {
|
|
502
|
+
if (problems.length > 0) ctx.ui.notify(`timbre: skipped\n ${problems.join('\n ')}`, 'warning')
|
|
503
|
+
pi.sendUserMessage(formatFixRequest(clean.slice(0, MAX_FIX_FILES), instruction, fullRules), deliver)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
const cleanNote =
|
|
507
|
+
clean.length > 3
|
|
508
|
+
? [`${clean.length} files scan clean, nothing to rewrite`]
|
|
509
|
+
: clean.map((file) => `${file.path} scans clean, nothing to rewrite`)
|
|
510
|
+
ctx.ui.notify(`timbre: ${[...problems, ...cleanNote].join('\n')}`, problems.length > 0 ? 'warning' : 'info')
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
if (targets.length > MAX_FIX_FILES) {
|
|
514
|
+
problems.push(
|
|
515
|
+
`${targets.length} files carry tells, rewriting the first ${MAX_FIX_FILES}. Run it again for the rest.`
|
|
516
|
+
)
|
|
517
|
+
targets = targets.slice(0, MAX_FIX_FILES)
|
|
518
|
+
}
|
|
519
|
+
if (problems.length > 0) ctx.ui.notify(`timbre: skipped\n ${problems.join('\n ')}`, 'warning')
|
|
520
|
+
|
|
521
|
+
pi.sendUserMessage(formatFixRequest(targets, instruction, fullRules), deliver)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const WriteArgs = t.object({ content: t.string() })
|
|
525
|
+
const isWriteArgs = new Validator(WriteArgs).isValidObject
|
|
526
|
+
const EditArgs = t.object({ edits: t.array(t.object({ newText: t.string() })) })
|
|
527
|
+
const isEditArgs = new Validator(EditArgs).isValidObject
|
|
528
|
+
|
|
529
|
+
function writtenText(event: Pick<ToolResultEvent, 'toolName' | 'input'>): string | null {
|
|
530
|
+
if (event.toolName === 'write' && isWriteArgs(event.input)) {
|
|
531
|
+
return event.input.content
|
|
532
|
+
}
|
|
533
|
+
if (event.toolName === 'edit' && isEditArgs(event.input)) {
|
|
534
|
+
return event.input.edits.map((e) => e.newText).join('\n\n')
|
|
535
|
+
}
|
|
536
|
+
return null
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function rhythmHits(text: string, profile: Profile): Hit[] {
|
|
540
|
+
if (profile.terseIsCorrect) return []
|
|
541
|
+
const hits: Hit[] = []
|
|
542
|
+
|
|
543
|
+
const b = burstiness(text)
|
|
544
|
+
if (wordCount(text) >= 50 && isMonotone(b)) {
|
|
545
|
+
hits.push({
|
|
546
|
+
line: 0,
|
|
547
|
+
id: 'monotone-rhythm',
|
|
548
|
+
tier: 2,
|
|
549
|
+
hint: `Sentence lengths barely vary (${b.count} sentences, mean ${b.mean.toFixed(0)} words). Mix short and long.`,
|
|
550
|
+
text: ''
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
if (hasStaccatoRun(text)) {
|
|
554
|
+
hits.push({
|
|
555
|
+
line: 0,
|
|
556
|
+
id: 'staccato-run',
|
|
557
|
+
tier: 2,
|
|
558
|
+
hint: 'Four or more short sentences in a row. One is emphasis, a run is engineering.',
|
|
559
|
+
text: ''
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
const opening = repeatedOpening(text)
|
|
563
|
+
if (opening !== null) {
|
|
564
|
+
hits.push({
|
|
565
|
+
line: 0,
|
|
566
|
+
id: 'repeated-opening',
|
|
567
|
+
tier: 2,
|
|
568
|
+
hint: 'Three or more consecutive sentences open the same way. Merge sentences or vary the opening.',
|
|
569
|
+
text: opening
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
if (hasLongSentenceRun(text)) {
|
|
573
|
+
hits.push({
|
|
574
|
+
line: 0,
|
|
575
|
+
id: 'long-sentence-run',
|
|
576
|
+
tier: 2,
|
|
577
|
+
hint: 'Three or more long sentences in a row. Split one; one idea per sentence.',
|
|
578
|
+
text: ''
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
if (hasClauseStack(text)) {
|
|
582
|
+
hits.push({
|
|
583
|
+
line: 0,
|
|
584
|
+
id: 'clause-stack',
|
|
585
|
+
tier: 2,
|
|
586
|
+
hint: 'Several sentences stack three or more subordinate clauses. Split one; one clause, one idea.',
|
|
587
|
+
text: ''
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
if (text.split(/\s+/).filter((w) => w !== '').length >= 120 && !text.includes('\n\n')) {
|
|
591
|
+
hits.push({
|
|
592
|
+
line: 0,
|
|
593
|
+
id: 'wall-paragraph',
|
|
594
|
+
tier: 2,
|
|
595
|
+
hint: 'One paragraph carries the whole argument. Split it; short paragraphs are easier to read.',
|
|
596
|
+
text: ''
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
return hits
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Both sides key on `hit.path`: oxlint can answer with a relative path where the write recorded an
|
|
603
|
+
// absolute one, and comparing those two bases made every lookup miss, so the check reported nothing.
|
|
604
|
+
export function freshFindings(findings: CodeFinding[], reported: Map<string, string>): CodeFinding[] {
|
|
605
|
+
const signatures = new Map<string, string[]>()
|
|
606
|
+
for (const hit of findings) {
|
|
607
|
+
const rows = signatures.get(hit.path)
|
|
608
|
+
if (rows === undefined) signatures.set(hit.path, [`${hit.id}:${hit.line}`])
|
|
609
|
+
else rows.push(`${hit.id}:${hit.line}`)
|
|
610
|
+
}
|
|
611
|
+
const fresh: CodeFinding[] = []
|
|
612
|
+
for (const [path, rows] of signatures) {
|
|
613
|
+
const signature = rows.toSorted().join(',')
|
|
614
|
+
if (signature === reported.get(path)) continue
|
|
615
|
+
reported.set(path, signature)
|
|
616
|
+
fresh.push(...findings.filter((hit) => hit.path === path))
|
|
617
|
+
}
|
|
618
|
+
return fresh
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function prosePath(event: Pick<ToolResultEvent, 'toolName' | 'isError' | 'input'>, cwd: string): string | null {
|
|
622
|
+
if (event.isError) return null
|
|
623
|
+
if (event.toolName !== 'write' && event.toolName !== 'edit') return null
|
|
624
|
+
const path = String(event.input['path'] ?? '')
|
|
625
|
+
if (!PROSE_FILE.test(path)) return null
|
|
626
|
+
return isAbsolute(path) ? path : resolve(cwd, path)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function textOf(content: AssistantContent): string {
|
|
630
|
+
return content
|
|
631
|
+
.filter((block): block is TextContent => block.type === 'text')
|
|
632
|
+
.map((block) => block.text)
|
|
633
|
+
.join('\n\n')
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function registerNote(note: string, register: string): string {
|
|
637
|
+
if (register === 'reference' || register === 'changelog') {
|
|
638
|
+
return `${note}\nRegister is ${register}: terse and plain is correct here, do not add voice.`
|
|
639
|
+
}
|
|
640
|
+
return note
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function readHeader(path: string, bytes = 1600): string | null {
|
|
644
|
+
let fd: number | undefined
|
|
645
|
+
try {
|
|
646
|
+
fd = openSync(path, 'r')
|
|
647
|
+
const buf = Buffer.alloc(bytes)
|
|
648
|
+
const n = readSync(fd, buf, 0, bytes, 0)
|
|
649
|
+
return buf.toString('utf8', 0, n)
|
|
650
|
+
} catch {
|
|
651
|
+
return null
|
|
652
|
+
} finally {
|
|
653
|
+
if (fd !== undefined) closeSync(fd)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
type FieldInfo = {
|
|
658
|
+
name: string
|
|
659
|
+
envName: string
|
|
660
|
+
envValue: boolean
|
|
661
|
+
file?: boolean
|
|
662
|
+
active: boolean
|
|
663
|
+
carried: boolean
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function settingFields(memory: Memory, config: Config): FieldInfo[] {
|
|
667
|
+
const set = memory.settings ?? {}
|
|
668
|
+
const mk = (name: string, envName: string, envValue: boolean, file: boolean | undefined): FieldInfo => {
|
|
669
|
+
const active = file !== undefined ? file : envValue
|
|
670
|
+
return { name, envName, envValue, file, active, carried: file !== undefined && file !== envValue }
|
|
671
|
+
}
|
|
672
|
+
return [mk('prevent', 'TIMBRE_PREVENT', config.prevent, set.prevent)]
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function activeLabel(f: FieldInfo): string {
|
|
676
|
+
return f.active ? 'on' : 'off'
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function configProvenance(memory: Memory, config: Config): string[] {
|
|
680
|
+
return settingFields(memory, config).map((f) => {
|
|
681
|
+
const source = `· ${f.file !== undefined ? 'state file' : `env ${f.envName}`}`.padEnd(34)
|
|
682
|
+
const mark = f.carried ? '⚠ carried over' : ''
|
|
683
|
+
return ` ${f.name.padEnd(9)}${activeLabel(f).padEnd(11)}${source}${mark}`
|
|
684
|
+
})
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function carriedOver(memory: Memory, config: Config): string[] {
|
|
688
|
+
return settingFields(memory, config)
|
|
689
|
+
.filter((f) => f.carried)
|
|
690
|
+
.map((f) => `${f.name} ${activeLabel(f)}`)
|
|
691
|
+
}
|