dsh-error-atlas 0.11.2 → 0.11.26
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/CHANGELOG.md +148 -0
- package/README.md +61 -10
- package/bin/atlas-digest.mjs +14 -5
- package/bin/atlas-retro.mjs +5 -3
- package/client.js +959 -90
- package/docs/icon-180.png +0 -0
- package/index.js +356 -20
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +9 -3
- package/test/run.mjs +336 -10
|
Binary file
|
package/index.js
CHANGED
|
@@ -33,9 +33,9 @@ import { dirname, join } from 'node:path'
|
|
|
33
33
|
export const name = 'error-atlas'
|
|
34
34
|
|
|
35
35
|
const DEFAULTS = {
|
|
36
|
-
maxRecords:
|
|
37
|
-
maxLog:
|
|
38
|
-
maxNodes:
|
|
36
|
+
maxRecords: 4000,
|
|
37
|
+
maxLog: 2000,
|
|
38
|
+
maxNodes: 2400,
|
|
39
39
|
maxRetros: 200,
|
|
40
40
|
messageChars: 260,
|
|
41
41
|
retroChars: 420,
|
|
@@ -52,7 +52,7 @@ const CODING_TOOLS = new Set(['write', 'edit', 'pwsh', 'bash', 'run_code', 'plug
|
|
|
52
52
|
* with one of these prefixes never becomes a node. `cd`/`pwd` noise is the reason this
|
|
53
53
|
* exists — it would otherwise eat the node budget and pollute the relapse count.
|
|
54
54
|
*/
|
|
55
|
-
const PREF_DEFAULTS = { minCount: 1, ignore: [], prompt: true, inject: true }
|
|
55
|
+
const PREF_DEFAULTS = { minCount: 1, ignore: [], prompt: true, inject: true, showNoise: false }
|
|
56
56
|
|
|
57
57
|
function normalizeIgnore(value, limit = 24) {
|
|
58
58
|
if (!Array.isArray(value)) return []
|
|
@@ -125,6 +125,76 @@ function normalizeMessage(value, limit) {
|
|
|
125
125
|
return text.length > limit ? text.slice(0, limit - 1) + '…' : text
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The identity of a failing command, not its whole command line.
|
|
130
|
+
*
|
|
131
|
+
* The shell hands us compound lines ("cd X; & node script.js"), and using that as the
|
|
132
|
+
* record message split one recurring failure into dozens of near-duplicate signatures.
|
|
133
|
+
* This keeps the executable, its meaningful sub command, and the script it ran.
|
|
134
|
+
*/
|
|
135
|
+
function commandFingerprint(command) {
|
|
136
|
+
const KNOWN = /^(node|npm|pnpm|yarn|git|gh|rg|python|python3|pwsh|powershell|cmd|deno|bun|cargo|go|dotnet|tsc|vite|eslint|jest|vitest)$/
|
|
137
|
+
const EXE = /\.(exe|cmd|bat|ps1)$/i
|
|
138
|
+
const flat = String(command || '').replace(/\s+/g, ' ').trim()
|
|
139
|
+
if (!flat) return ''
|
|
140
|
+
const segments = flat
|
|
141
|
+
.split(/;|&&|\|\||\n/)
|
|
142
|
+
.map((part) => part.trim())
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
const exeOf = (segment) => {
|
|
145
|
+
const tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) || []
|
|
146
|
+
const clean = tokens.map((token) => token.replace(/^["']|["']$/g, ''))
|
|
147
|
+
const head = clean[0]
|
|
148
|
+
if (!head) return null
|
|
149
|
+
const bare = head.split(/[\\/]/).pop()
|
|
150
|
+
const name = bare.replace(EXE, '').toLowerCase()
|
|
151
|
+
if (!KNOWN.test(name)) return null
|
|
152
|
+
return { name, rest: clean.slice(1) }
|
|
153
|
+
}
|
|
154
|
+
// The failing command is the last one in the pipeline; heredoc bodies and script
|
|
155
|
+
// fragments are not commands, so walk backwards to the last real executable.
|
|
156
|
+
let found = null
|
|
157
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
158
|
+
const candidate = segments[i].replace(/^&\s*/, '').replace(/^@'\s*/, '')
|
|
159
|
+
const parsed = exeOf(candidate)
|
|
160
|
+
if (parsed) {
|
|
161
|
+
found = parsed
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!found) return 'shell'
|
|
166
|
+
const inline = found.rest[0] || ''
|
|
167
|
+
if (/^-(e|c|m)$/i.test(inline)) return found.name + ' ' + inline
|
|
168
|
+
// `npm run <script>` is the useful half of a package-manager failure.
|
|
169
|
+
if (/^(npm|pnpm|yarn|bun)$/.test(found.name) && found.rest[0] === 'run' && found.rest[1]) {
|
|
170
|
+
return found.name + ' run ' + found.rest[1]
|
|
171
|
+
}
|
|
172
|
+
const qualifier = found.rest.find((token) => token && !token.startsWith('-'))
|
|
173
|
+
if (!qualifier) return found.name
|
|
174
|
+
const file = qualifier.split(/[\\/]/).pop()
|
|
175
|
+
return found.name + ' ' + (file || qualifier)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The first useful line of stderr: the part of a failure that is not the command. */
|
|
179
|
+
function exitDetailOf(result) {
|
|
180
|
+
const blocks = Array.isArray(result && result.content) ? result.content : []
|
|
181
|
+
const lines = []
|
|
182
|
+
for (const block of blocks) {
|
|
183
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') continue
|
|
184
|
+
for (const raw of block.text.split(/\r?\n/)) {
|
|
185
|
+
const line = raw.trim()
|
|
186
|
+
if (!line) continue
|
|
187
|
+
if (/^\[exit code: -?\d+\]$/.test(line)) continue
|
|
188
|
+
if (/^\[\d+ms\]$/.test(line)) continue
|
|
189
|
+
lines.push(line)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// stderr trails stdout, so the last meaningful line is the error itself.
|
|
193
|
+
const last = lines.length > 0 ? lines[lines.length - 1] : ''
|
|
194
|
+
if (!last) return ''
|
|
195
|
+
return clip(last.replace(/^\s*(error|fatal|Error|FATAL):\s*/, ''), 90)
|
|
196
|
+
}
|
|
197
|
+
|
|
128
198
|
function summarizeArguments(args) {
|
|
129
199
|
if (!args || typeof args !== 'object') return ''
|
|
130
200
|
const keys = ['command', 'cmd', 'script', 'file_path', 'path', 'query', 'url', 'name']
|
|
@@ -316,6 +386,7 @@ function createStore(config) {
|
|
|
316
386
|
cause: clip(entry.cause, config.retroChars),
|
|
317
387
|
fix: clip(entry.fix, config.retroChars),
|
|
318
388
|
lesson: clip(entry.lesson, config.retroChars),
|
|
389
|
+
essence: clip(entry.essence, 160) || undefined,
|
|
319
390
|
tags: Array.isArray(entry.tags) ? entry.tags.filter((t) => typeof t === 'string').slice(0, 8) : [],
|
|
320
391
|
tools: Array.isArray(entry.tools) ? entry.tools.filter((t) => typeof t === 'string').slice(0, 12) : [],
|
|
321
392
|
files: Array.isArray(entry.files) ? entry.files.filter((t) => typeof t === 'string').slice(0, 12) : [],
|
|
@@ -340,9 +411,68 @@ function createStore(config) {
|
|
|
340
411
|
state.prefs.minCount = Math.max(1, Math.min(50, Math.floor(parsed.prefs.minCount)))
|
|
341
412
|
}
|
|
342
413
|
if (Array.isArray(parsed.prefs.ignore)) state.prefs.ignore = normalizeIgnore(parsed.prefs.ignore)
|
|
343
|
-
|
|
344
|
-
|
|
414
|
+
if (typeof parsed.prefs.showNoise === 'boolean') state.prefs.showNoise = parsed.prefs.showNoise
|
|
415
|
+
if (typeof parsed.prefs.prompt === 'boolean') state.prefs.prompt = parsed.prefs.prompt
|
|
416
|
+
if (typeof parsed.prefs.inject === 'boolean') state.prefs.inject = parsed.prefs.inject
|
|
345
417
|
}
|
|
418
|
+
let migrated = false
|
|
419
|
+
// ── store v3: exit messages become command fingerprints ────────────────
|
|
420
|
+
// Before this, an exit record carried the whole command line, so one recurring
|
|
421
|
+
// failure arrived as dozens of near-identical signatures. Fold them once, and
|
|
422
|
+
// remap every retrospective that pointed at a merged-away id.
|
|
423
|
+
if (Number.isFinite(parsed.version) && parsed.version < 3) {
|
|
424
|
+
const remap = new Map()
|
|
425
|
+
const canonical = new Map()
|
|
426
|
+
for (const entry of [...state.records.values()]) {
|
|
427
|
+
if (entry.kind !== 'exit') continue
|
|
428
|
+
const parts = String(entry.message || '').split(' · ')
|
|
429
|
+
const code = parts[0] || 'exit code 1'
|
|
430
|
+
const command = parts.slice(1).join(' · ')
|
|
431
|
+
const fingerprint = commandFingerprint(command) || command
|
|
432
|
+
const detail = parts.length > 2 ? parts[parts.length - 1] : ''
|
|
433
|
+
const message = code + (fingerprint ? ' · ' + fingerprint : '') + (detail ? ' · ' + detail : '')
|
|
434
|
+
const key = 'exit\u0000' + entry.name + '\u0000' + message
|
|
435
|
+
const target = canonical.get(key)
|
|
436
|
+
if (!target) {
|
|
437
|
+
const merged = Object.assign({}, entry, { message, key })
|
|
438
|
+
canonical.set(key, merged)
|
|
439
|
+
if (merged !== entry) state.records.delete(entry.key)
|
|
440
|
+
state.records.set(key, merged)
|
|
441
|
+
remap.set(entry.id, merged.id)
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
target.count += entry.count
|
|
445
|
+
target.first = Math.min(target.first, entry.first)
|
|
446
|
+
target.last = Math.max(target.last, entry.last)
|
|
447
|
+
for (const session of entry.sessions || []) {
|
|
448
|
+
if (target.sessions.length < 12 && !target.sessions.includes(session)) target.sessions.push(session)
|
|
449
|
+
}
|
|
450
|
+
state.records.delete(entry.key)
|
|
451
|
+
remap.set(entry.id, target.id)
|
|
452
|
+
}
|
|
453
|
+
const alive = new Set([...state.records.values()].map((entry) => entry.id))
|
|
454
|
+
for (const retro of state.retros) {
|
|
455
|
+
if (!Array.isArray(retro.errorIds) || retro.errorIds.length === 0) continue
|
|
456
|
+
const next = []
|
|
457
|
+
for (const id of retro.errorIds) {
|
|
458
|
+
const mapped = remap.get(id) || id
|
|
459
|
+
if (alive.has(mapped) && !next.includes(mapped)) next.push(mapped)
|
|
460
|
+
}
|
|
461
|
+
retro.errorIds = next.slice(0, 12)
|
|
462
|
+
}
|
|
463
|
+
migrated = true
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const retro of state.retros) {
|
|
467
|
+
if (!retro.essence) {
|
|
468
|
+
const derived = deriveEssence(retro)
|
|
469
|
+
if (derived) {
|
|
470
|
+
retro.essence = clip(derived, 160)
|
|
471
|
+
migrated = true
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (migrated) save()
|
|
346
476
|
// A store written before lesson links existed carries only failure text on its
|
|
347
477
|
// retrospectives; give those pipelines back once.
|
|
348
478
|
if (state.retros.length > 0 && state.records.size > 0) {
|
|
@@ -361,7 +491,7 @@ function createStore(config) {
|
|
|
361
491
|
try {
|
|
362
492
|
mkdirSync(dir, { recursive: true })
|
|
363
493
|
const payload = JSON.stringify({
|
|
364
|
-
version:
|
|
494
|
+
version: 3,
|
|
365
495
|
updatedAt: state.updatedAt,
|
|
366
496
|
records: [...state.records.values()],
|
|
367
497
|
log: state.log,
|
|
@@ -491,6 +621,17 @@ function createStore(config) {
|
|
|
491
621
|
if (state.retros.length > config.maxRetros) state.retros.length = config.maxRetros
|
|
492
622
|
}
|
|
493
623
|
|
|
624
|
+
/** A lesson is only worth injecting if it says what to do next, in one line. */
|
|
625
|
+
function deriveEssence(retro) {
|
|
626
|
+
const raw = String((retro && (retro.essence || retro.lesson)) || '').trim()
|
|
627
|
+
if (!raw) return ''
|
|
628
|
+
const flat = raw.replace(/\s+/g, ' ')
|
|
629
|
+
const trimmed = flat.length > 96 ? flat.slice(0, 95) + '…' : flat
|
|
630
|
+
return /→/.test(trimmed) || /^(如果|当|别|不要|先|记得|用|把|每次|任何|永远|写|改|加|删|查|量|先量)/.test(trimmed)
|
|
631
|
+
? trimmed
|
|
632
|
+
: '规则:' + trimmed
|
|
633
|
+
}
|
|
634
|
+
|
|
494
635
|
function addRetro(input) {
|
|
495
636
|
const at = Date.now()
|
|
496
637
|
state.retroSeq += 1
|
|
@@ -498,6 +639,7 @@ function createStore(config) {
|
|
|
498
639
|
id: 'r' + state.retroSeq.toString(36),
|
|
499
640
|
at,
|
|
500
641
|
auto: input.auto === true,
|
|
642
|
+
essence: clip(input.essence || '', 160) || undefined,
|
|
501
643
|
title: clip(input.title, 120) || '未命名任务',
|
|
502
644
|
done: clip(input.done, config.retroChars),
|
|
503
645
|
cause: clip(input.cause, config.retroChars),
|
|
@@ -544,6 +686,7 @@ function createStore(config) {
|
|
|
544
686
|
existing.cause = pick(input.cause, existing.cause, config.retroChars)
|
|
545
687
|
existing.fix = pick(input.fix, existing.fix, config.retroChars)
|
|
546
688
|
existing.lesson = pick(input.lesson, existing.lesson, config.retroChars)
|
|
689
|
+
existing.essence = pick(input.essence, existing.essence, 160)
|
|
547
690
|
existing.tags = list(input.tags, existing.tags, 8)
|
|
548
691
|
existing.tools = list(input.tools, existing.tools, 12)
|
|
549
692
|
existing.files = list(input.files, existing.files, 12)
|
|
@@ -574,6 +717,7 @@ function createStore(config) {
|
|
|
574
717
|
ignore: [...state.prefs.ignore],
|
|
575
718
|
prompt: state.prefs.prompt,
|
|
576
719
|
inject: state.prefs.inject,
|
|
720
|
+
showNoise: state.prefs.showNoise,
|
|
577
721
|
}
|
|
578
722
|
}
|
|
579
723
|
|
|
@@ -585,11 +729,24 @@ function createStore(config) {
|
|
|
585
729
|
if (Array.isArray(patch.ignore)) state.prefs.ignore = normalizeIgnore(patch.ignore)
|
|
586
730
|
if (typeof patch.prompt === 'boolean') state.prefs.prompt = patch.prompt
|
|
587
731
|
if (typeof patch.inject === 'boolean') state.prefs.inject = patch.inject
|
|
732
|
+
if (typeof patch.showNoise === 'boolean') state.prefs.showNoise = patch.showNoise
|
|
588
733
|
state.updatedAt = Date.now()
|
|
589
734
|
save()
|
|
590
735
|
return prefs()
|
|
591
736
|
}
|
|
592
737
|
|
|
738
|
+
/**
|
|
739
|
+
* A failure whose only evidence is the exit code, repeated enough times to be churn.
|
|
740
|
+
* Real failures bring stderr with them; bare repeats are the "same command failed
|
|
741
|
+
* again" noise that used to drown the graph and poison lesson staleness.
|
|
742
|
+
*/
|
|
743
|
+
function isBareExit(entry) {
|
|
744
|
+
if (!entry || entry.kind !== 'exit') return false
|
|
745
|
+
if (entry.count < 5) return false
|
|
746
|
+
const segments = String(entry.message || '').split(' · ')
|
|
747
|
+
return segments.length < 3
|
|
748
|
+
}
|
|
749
|
+
|
|
593
750
|
function clear() {
|
|
594
751
|
state.records.clear()
|
|
595
752
|
state.log = []
|
|
@@ -597,20 +754,40 @@ function createStore(config) {
|
|
|
597
754
|
save()
|
|
598
755
|
}
|
|
599
756
|
|
|
757
|
+
/**
|
|
758
|
+
* A signature that many lessons point at is ambient environment noise — a quirk of the
|
|
759
|
+
* shell, not the mistake any single lesson was about. Its recurrence must not invalidate
|
|
760
|
+
* every lesson that happened to run in the same turn.
|
|
761
|
+
*/
|
|
762
|
+
const AMBIENT_REFS = 4
|
|
763
|
+
|
|
764
|
+
function ambientIds() {
|
|
765
|
+
const counts = new Map()
|
|
766
|
+
for (const retro of state.retros) {
|
|
767
|
+
for (const id of retro.errorIds || []) counts.set(id, (counts.get(id) || 0) + 1)
|
|
768
|
+
}
|
|
769
|
+
const ambient = new Set()
|
|
770
|
+
for (const [id, count] of counts) {
|
|
771
|
+
if (count >= AMBIENT_REFS) ambient.add(id)
|
|
772
|
+
}
|
|
773
|
+
return ambient
|
|
774
|
+
}
|
|
775
|
+
|
|
600
776
|
// A lesson can fail. When the very error a retrospective learned from shows up again
|
|
601
777
|
// after the lesson was written, the lesson did not hold. The failure that produced the
|
|
602
778
|
// retrospective is excluded by a grace window so a lesson never counts as its own relapse.
|
|
603
779
|
const RETRO_GRACE_MS = 90 * 1000
|
|
604
780
|
|
|
605
|
-
function recurrenceOf(retro, recordById) {
|
|
781
|
+
function recurrenceOf(retro, recordById, ambient) {
|
|
606
782
|
const ids = Array.isArray(retro.errorIds) ? retro.errorIds : []
|
|
607
783
|
if (ids.length === 0) return null
|
|
608
784
|
const cutoff = (Number(retro.at) || 0) + RETRO_GRACE_MS
|
|
609
785
|
const keys = new Set()
|
|
610
786
|
let lastAt = 0
|
|
611
787
|
for (const id of ids) {
|
|
788
|
+
if (ambient && ambient.has(id)) continue
|
|
612
789
|
const entry = recordById.get(id)
|
|
613
|
-
if (!entry) continue
|
|
790
|
+
if (!entry || isBareExit(entry)) continue
|
|
614
791
|
keys.add(entry.key)
|
|
615
792
|
if (entry.last > cutoff) lastAt = Math.max(lastAt, entry.last)
|
|
616
793
|
}
|
|
@@ -672,9 +849,10 @@ function createStore(config) {
|
|
|
672
849
|
const needle = typeof query === 'string' ? query.trim().toLowerCase() : ''
|
|
673
850
|
const recordById = new Map()
|
|
674
851
|
for (const entry of state.records.values()) recordById.set(entry.id, entry)
|
|
852
|
+
const ambient = ambientIds()
|
|
675
853
|
const lessons = []
|
|
676
854
|
for (const retro of state.retros) {
|
|
677
|
-
const relapse = recurrenceOf(retro, recordById)
|
|
855
|
+
const relapse = recurrenceOf(retro, recordById, ambient)
|
|
678
856
|
const programs = []
|
|
679
857
|
for (const file of retro.files || []) {
|
|
680
858
|
const project = projectOf(file, retro.cwd)
|
|
@@ -698,6 +876,7 @@ function createStore(config) {
|
|
|
698
876
|
at: retro.at,
|
|
699
877
|
auto: !!retro.auto,
|
|
700
878
|
title: retro.title,
|
|
879
|
+
essence: retro.essence || deriveEssence(retro),
|
|
701
880
|
lesson: retro.lesson || '',
|
|
702
881
|
cause: retro.cause || '',
|
|
703
882
|
fix: retro.fix || '',
|
|
@@ -721,17 +900,22 @@ function createStore(config) {
|
|
|
721
900
|
// Switched off in the panel: an empty string makes the context section vanish, so
|
|
722
901
|
// a user who does not want injected lessons pays nothing per request.
|
|
723
902
|
if (state.prefs.inject === false) return ''
|
|
724
|
-
|
|
903
|
+
// Auto skeletons are bookkeeping, not lessons learned: they never make it in.
|
|
904
|
+
const lessons = lessonList().filter((item) => !item.auto)
|
|
725
905
|
if (lessons.length === 0) return ''
|
|
726
906
|
// Two traps plus two fresh rules is about the most a model will actually use;
|
|
727
907
|
// the full text stays one tool call away.
|
|
728
|
-
|
|
908
|
+
// Only a lesson that points at a real error can be disproved; a skeleton without
|
|
909
|
+
// errorIds can never come back, so it can never be a trap either.
|
|
910
|
+
const stale = lessons.filter((item) => item.stale > 0 && item.errorIds.length > 0).slice(0, 1)
|
|
729
911
|
const recent = lessons
|
|
730
912
|
.filter((item) => item.stale === 0)
|
|
731
913
|
.slice(0, Math.max(1, limit - stale.length))
|
|
732
914
|
const line = (item, prefix) => {
|
|
733
|
-
const where = item.programs.length > 0 ? '[' + item.programs
|
|
734
|
-
|
|
915
|
+
const where = item.programs.length > 0 ? '[' + item.programs[0] + '] ' : ''
|
|
916
|
+
// Essence, not the essay: one actionable line per lesson, so three of them fit
|
|
917
|
+
// in the same budget the old five-paragraph version used.
|
|
918
|
+
const text = clip(item.essence || item.lesson || item.title, 110)
|
|
735
919
|
return '- ' + prefix + where + text
|
|
736
920
|
}
|
|
737
921
|
const lines = ['## Error atlas: recorded lessons']
|
|
@@ -747,13 +931,71 @@ function createStore(config) {
|
|
|
747
931
|
return lines.join('\n')
|
|
748
932
|
}
|
|
749
933
|
|
|
934
|
+
// What the lessons bought, in numbers that are honest about being proxies: a lesson
|
|
935
|
+
// counts as "judged" only when it names failures that are still in the store, and only
|
|
936
|
+
// then can its signatures be watched afterwards. Everything else is bookkeeping.
|
|
937
|
+
function impactOf(days = 30) {
|
|
938
|
+
const span = Math.max(1, Math.min(365, Math.floor(days || 30)))
|
|
939
|
+
const windowMs = span * 24 * 60 * 60 * 1000
|
|
940
|
+
const since = Date.now() - windowMs
|
|
941
|
+
const recordById = new Map()
|
|
942
|
+
for (const entry of state.records.values()) recordById.set(entry.id, entry)
|
|
943
|
+
const ambient = ambientIds()
|
|
944
|
+
const linked = new Set()
|
|
945
|
+
let model = 0
|
|
946
|
+
let auto = 0
|
|
947
|
+
let judgeable = 0
|
|
948
|
+
let failed = 0
|
|
949
|
+
let firstLessonAt = Infinity
|
|
950
|
+
for (const retro of state.retros) {
|
|
951
|
+
if (retro.auto) auto += 1
|
|
952
|
+
else model += 1
|
|
953
|
+
if (!retro.auto && Number.isFinite(retro.at) && retro.at < firstLessonAt) firstLessonAt = retro.at
|
|
954
|
+
const ids = (retro.errorIds || []).filter((id) => recordById.has(id))
|
|
955
|
+
for (const id of ids) linked.add(id)
|
|
956
|
+
if (retro.auto || ids.length === 0) continue
|
|
957
|
+
judgeable += 1
|
|
958
|
+
const relapse = recurrenceOf(retro, recordById, ambient)
|
|
959
|
+
if (relapse && relapse.times > 0) failed += 1
|
|
960
|
+
}
|
|
961
|
+
let hits = 0
|
|
962
|
+
let hitsInWindow = 0
|
|
963
|
+
let afterFirstLesson = 0
|
|
964
|
+
for (const event of state.log) {
|
|
965
|
+
hits += 1
|
|
966
|
+
if (event.at >= since) hitsInWindow += 1
|
|
967
|
+
if (Number.isFinite(firstLessonAt) && event.at > firstLessonAt) afterFirstLesson += 1
|
|
968
|
+
}
|
|
969
|
+
const distinct = state.records.size
|
|
970
|
+
return {
|
|
971
|
+
days: span,
|
|
972
|
+
lessons: model + auto,
|
|
973
|
+
model,
|
|
974
|
+
auto,
|
|
975
|
+
judgeable,
|
|
976
|
+
failed,
|
|
977
|
+
held: judgeable - failed,
|
|
978
|
+
rate: judgeable > 0 ? Math.round((failed / judgeable) * 100) : 0,
|
|
979
|
+
distinct,
|
|
980
|
+
withLesson: linked.size,
|
|
981
|
+
coverage: distinct > 0 ? Math.round((linked.size / distinct) * 100) : 0,
|
|
982
|
+
hits,
|
|
983
|
+
hitsInWindow,
|
|
984
|
+
afterFirstLesson,
|
|
985
|
+
firstLessonAt: Number.isFinite(firstLessonAt) ? firstLessonAt : 0,
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
750
989
|
function snapshot() {
|
|
751
990
|
const all = [...state.records.values()].sort((a, b) => b.count - a.count || b.last - a.last)
|
|
752
991
|
const recordById = new Map()
|
|
753
992
|
for (const entry of all) recordById.set(entry.id, entry)
|
|
993
|
+
const ambient = ambientIds()
|
|
754
994
|
// Muted signatures still feed relapse detection (a lesson that failed on a noisy
|
|
755
995
|
// command is still a failed lesson), they just do not become nodes.
|
|
756
|
-
const
|
|
996
|
+
const noisy = all.filter((entry) => isBareExit(entry))
|
|
997
|
+
const quiet = state.prefs.showNoise ? all : all.filter((entry) => !isBareExit(entry))
|
|
998
|
+
const visible = quiet.filter(
|
|
757
999
|
(entry) => entry.count >= state.prefs.minCount && !isNoise(entry, state.prefs.ignore),
|
|
758
1000
|
)
|
|
759
1001
|
const muted = all.length - visible.length
|
|
@@ -764,6 +1006,11 @@ function createStore(config) {
|
|
|
764
1006
|
byKind[entry.kind] = (byKind[entry.kind] || 0) + entry.count
|
|
765
1007
|
}
|
|
766
1008
|
|
|
1009
|
+
// Recurrence is judged against what the graph actually shows, so muting a noisy
|
|
1010
|
+
// signature also stops it from flagging healthy lessons as failed.
|
|
1011
|
+
recordById.clear()
|
|
1012
|
+
for (const entry of visible) recordById.set(entry.id, entry)
|
|
1013
|
+
|
|
767
1014
|
const picked = visible.slice(0, config.maxNodes)
|
|
768
1015
|
// A retrospective must never point at a node the cap dropped: its lesson edge would
|
|
769
1016
|
// vanish and the atlas would quietly claim that every lesson held. Reserve the space.
|
|
@@ -885,7 +1132,7 @@ function createStore(config) {
|
|
|
885
1132
|
// anchored to the DSH core like every other leaf.
|
|
886
1133
|
const retroNodes = []
|
|
887
1134
|
for (const retro of state.retros.slice(0, config.maxRetros)) {
|
|
888
|
-
const relapse = recurrenceOf(retro, recordById)
|
|
1135
|
+
const relapse = recurrenceOf(retro, recordById, ambient)
|
|
889
1136
|
retroNodes.push({
|
|
890
1137
|
id: 'rt:' + retro.id,
|
|
891
1138
|
kind: 'retro',
|
|
@@ -940,6 +1187,7 @@ function createStore(config) {
|
|
|
940
1187
|
function lessonBoard() {
|
|
941
1188
|
const recordById = new Map()
|
|
942
1189
|
for (const entry of state.records.values()) recordById.set(entry.id, entry)
|
|
1190
|
+
const ambient = ambientIds()
|
|
943
1191
|
const tags = new Map()
|
|
944
1192
|
const programs = new Map()
|
|
945
1193
|
const failedRows = []
|
|
@@ -951,7 +1199,7 @@ function createStore(config) {
|
|
|
951
1199
|
bucket.set(name, cell)
|
|
952
1200
|
}
|
|
953
1201
|
for (const retro of state.retros) {
|
|
954
|
-
const relapse = recurrenceOf(retro, recordById)
|
|
1202
|
+
const relapse = recurrenceOf(retro, recordById, ambient)
|
|
955
1203
|
const stale = relapse ? relapse.times : 0
|
|
956
1204
|
total += 1
|
|
957
1205
|
const retroPrograms = []
|
|
@@ -1005,6 +1253,9 @@ function createStore(config) {
|
|
|
1005
1253
|
matched: visible.length,
|
|
1006
1254
|
truncated,
|
|
1007
1255
|
muted,
|
|
1256
|
+
// Bare repeats the graph hides by default; a non-zero count explains the gap
|
|
1257
|
+
// between distinct and matched instead of leaking it silently.
|
|
1258
|
+
noise: noisy.length,
|
|
1008
1259
|
sessions: sessionTotals.size,
|
|
1009
1260
|
tools: toolTotals.size,
|
|
1010
1261
|
projects: [...projectErrors.values()].filter((set) => set.size > 0).length,
|
|
@@ -1016,6 +1267,7 @@ function createStore(config) {
|
|
|
1016
1267
|
prefs: prefs(),
|
|
1017
1268
|
trend: trendOf(Date.now()),
|
|
1018
1269
|
lessons: lessonBoard(),
|
|
1270
|
+
impact: impactOf(30),
|
|
1019
1271
|
nodes,
|
|
1020
1272
|
links,
|
|
1021
1273
|
retros: state.retros.slice(0, config.maxRetros),
|
|
@@ -1037,6 +1289,7 @@ function createStore(config) {
|
|
|
1037
1289
|
applyPrefs,
|
|
1038
1290
|
lessonList,
|
|
1039
1291
|
lessonBriefing,
|
|
1292
|
+
impactOf,
|
|
1040
1293
|
}
|
|
1041
1294
|
}
|
|
1042
1295
|
|
|
@@ -1264,11 +1517,16 @@ export function apply(ctx, config = {}) {
|
|
|
1264
1517
|
} else {
|
|
1265
1518
|
const code = exitCodeOf(result)
|
|
1266
1519
|
if (code === undefined) return
|
|
1267
|
-
const
|
|
1520
|
+
const fingerprint = commandFingerprint(exec.arguments && exec.arguments.command)
|
|
1521
|
+
const detail = exitDetailOf(result)
|
|
1268
1522
|
entry = store.record({
|
|
1269
1523
|
kind: 'exit',
|
|
1270
1524
|
name: exec.name || 'tool',
|
|
1271
|
-
message:
|
|
1525
|
+
message:
|
|
1526
|
+
'exit code ' +
|
|
1527
|
+
code +
|
|
1528
|
+
(fingerprint ? ' · ' + fingerprint : '') +
|
|
1529
|
+
(detail ? ' · ' + detail : ''),
|
|
1272
1530
|
sessionId,
|
|
1273
1531
|
})
|
|
1274
1532
|
}
|
|
@@ -1474,6 +1732,11 @@ export function apply(ctx, config = {}) {
|
|
|
1474
1732
|
cause: { type: 'string', description: 'Root cause of the failure or the decision that mattered.' },
|
|
1475
1733
|
fix: { type: 'string', description: 'What solved it.' },
|
|
1476
1734
|
lesson: { type: 'string', description: 'The reusable rule that prevents this next time.' },
|
|
1735
|
+
essence: {
|
|
1736
|
+
type: 'string',
|
|
1737
|
+
description:
|
|
1738
|
+
'One line, at most ~70 characters: "<if this situation> -> <root cause class> -> <what to do next time>". This is the only line other sessions will see, so make it a rule, not a summary.',
|
|
1739
|
+
},
|
|
1477
1740
|
tags: { type: 'array', items: { type: 'string' }, description: 'Short labels, e.g. render, plugin, build.' },
|
|
1478
1741
|
errorIds: {
|
|
1479
1742
|
type: 'array',
|
|
@@ -1514,6 +1777,7 @@ export function apply(ctx, config = {}) {
|
|
|
1514
1777
|
cause: args.cause,
|
|
1515
1778
|
fix: args.fix,
|
|
1516
1779
|
lesson: args.lesson,
|
|
1780
|
+
essence: args.essence,
|
|
1517
1781
|
tags: args.tags,
|
|
1518
1782
|
tools: buffer ? buffer.tools.slice(0, 12) : [],
|
|
1519
1783
|
files: buffer ? buffer.files.slice(0, 12) : [],
|
|
@@ -1609,6 +1873,78 @@ export function apply(ctx, config = {}) {
|
|
|
1609
1873
|
}),
|
|
1610
1874
|
'error-atlas: lesson tool',
|
|
1611
1875
|
)
|
|
1876
|
+
|
|
1877
|
+
// Whether the mechanism pays for itself: how many lessons exist, how many can be
|
|
1878
|
+
// judged at all, and how many of those had their signatures come back anyway.
|
|
1879
|
+
scope.effect(
|
|
1880
|
+
() =>
|
|
1881
|
+
scope.tools.register({
|
|
1882
|
+
name: 'atlas_impact',
|
|
1883
|
+
description:
|
|
1884
|
+
'Report what the recorded lessons bought: how many exist, how many can be judged (they name failures that are still in the store), how many of those were followed by the same failure returning, and how much of the store the lessons cover. These are counts from the local store, not a controlled experiment.',
|
|
1885
|
+
parameters: {
|
|
1886
|
+
type: 'object',
|
|
1887
|
+
properties: {
|
|
1888
|
+
days: { type: 'number', description: 'Window for the failure counts, 1 to 365; defaults to 30.' },
|
|
1889
|
+
},
|
|
1890
|
+
additionalProperties: false,
|
|
1891
|
+
},
|
|
1892
|
+
output: {
|
|
1893
|
+
schema: {
|
|
1894
|
+
type: 'object',
|
|
1895
|
+
properties: {
|
|
1896
|
+
days: { type: 'number' },
|
|
1897
|
+
lessons: { type: 'number' },
|
|
1898
|
+
model: { type: 'number' },
|
|
1899
|
+
auto: { type: 'number' },
|
|
1900
|
+
judgeable: { type: 'number' },
|
|
1901
|
+
failed: { type: 'number' },
|
|
1902
|
+
held: { type: 'number' },
|
|
1903
|
+
rate: { type: 'number' },
|
|
1904
|
+
distinct: { type: 'number' },
|
|
1905
|
+
withLesson: { type: 'number' },
|
|
1906
|
+
coverage: { type: 'number' },
|
|
1907
|
+
hits: { type: 'number' },
|
|
1908
|
+
hitsInWindow: { type: 'number' },
|
|
1909
|
+
afterFirstLesson: { type: 'number' },
|
|
1910
|
+
firstLessonAt: { type: 'number' },
|
|
1911
|
+
},
|
|
1912
|
+
required: ['days', 'lessons', 'judgeable', 'failed', 'held', 'rate', 'coverage'],
|
|
1913
|
+
additionalProperties: false,
|
|
1914
|
+
},
|
|
1915
|
+
render(_args, value) {
|
|
1916
|
+
if (value.lessons === 0) return [{ type: 'text', text: 'no lessons recorded yet' }]
|
|
1917
|
+
if (value.judgeable === 0) {
|
|
1918
|
+
return [
|
|
1919
|
+
{
|
|
1920
|
+
type: 'text',
|
|
1921
|
+
text:
|
|
1922
|
+
value.lessons +
|
|
1923
|
+
' lesson(s), none judgeable yet: a lesson is judged only once it names failures that are still recorded',
|
|
1924
|
+
},
|
|
1925
|
+
]
|
|
1926
|
+
}
|
|
1927
|
+
return [
|
|
1928
|
+
{
|
|
1929
|
+
type: 'text',
|
|
1930
|
+
text: [
|
|
1931
|
+
'impact · ' + value.days + ' day window',
|
|
1932
|
+
'lessons: ' + value.lessons + ' (' + value.model + ' model · ' + value.auto + ' auto) · ' + value.judgeable + ' judgeable',
|
|
1933
|
+
'verdict: ' + value.failed + ' of ' + value.judgeable + ' judged lessons recurred (' + value.rate + '% failed · ' + (100 - value.rate) + '% held)',
|
|
1934
|
+
'coverage: ' + value.withLesson + ' of ' + value.distinct + ' signatures carry a lesson (' + value.coverage + '%)',
|
|
1935
|
+
'failures: ' + value.hitsInWindow + ' in window · ' + value.afterFirstLesson + ' since the first lesson',
|
|
1936
|
+
].join('\n'),
|
|
1937
|
+
},
|
|
1938
|
+
]
|
|
1939
|
+
},
|
|
1940
|
+
},
|
|
1941
|
+
execute(args) {
|
|
1942
|
+
const days = Number.isFinite(args.days) ? Math.max(1, Math.min(365, Math.floor(args.days))) : 30
|
|
1943
|
+
return Promise.resolve(store.impactOf(days))
|
|
1944
|
+
},
|
|
1945
|
+
}),
|
|
1946
|
+
'error-atlas: impact tool',
|
|
1947
|
+
)
|
|
1612
1948
|
})
|
|
1613
1949
|
|
|
1614
1950
|
ctx.inject(['systemPrompt'], (scope) => {
|
|
@@ -1622,7 +1958,7 @@ export function apply(ctx, config = {}) {
|
|
|
1622
1958
|
? ''
|
|
1623
1959
|
: [
|
|
1624
1960
|
'## Task retrospectives',
|
|
1625
|
-
'A coding turn (files written, a command run, a failure diagnosed) ends with one `atlas_retro` call before the final answer: `title`, `done` (what changed, where), `cause`, `fix`, `lesson`
|
|
1961
|
+
'A coding turn (files written, a command run, a failure diagnosed) ends with one `atlas_retro` call before the final answer: `title`, `done` (what changed, where), `cause`, `fix`, `lesson`, and `essence` — one line shaped "<if this happens> -> <root cause> -> <what to do next time>", because that line is what other sessions get injected. Skip plain chat, reading and questions.',
|
|
1626
1962
|
].join('\n'),
|
|
1627
1963
|
}),
|
|
1628
1964
|
'error-atlas: retrospective prompt section',
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Error Atlas",
|
|
4
|
-
"description": "Records runtime errors and coding-task retrospectives, links each lesson back to the failures it came from, groups errors under the program directory a task touched, searches the graph by error, tool, program or session, charts failure activity over the last 24 hours and 30 days, reports truncated or muted signatures honestly, and opens the source session from any node; renders everything as an interactive GPU knowledge graph and exports Markdown. The panel copy follows the DSH language setting (Chinese / English). Lessons are injected into the model context and queryable through the atlas_lessons tool. A collapsible lesson-effectiveness board ranks tags and programs by failure rate. Two memory switches in the panel turn the reminder and the injected lessons off instantly"
|
|
4
|
+
"description": "Records runtime errors and coding-task retrospectives, links each lesson back to the failures it came from, groups errors under the program directory a task touched, searches the graph by error, tool, program or session, charts failure activity over the last 24 hours and 30 days, reports truncated or muted signatures honestly, and opens the source session from any node; renders everything as an interactive GPU knowledge graph and exports Markdown. The panel copy follows the DSH language setting (Chinese / English). Lessons are injected into the model context and queryable through the atlas_lessons tool. A collapsible lesson-effectiveness board ranks tags and programs by failure rate. Session windows sit around the DSH core as islands with their own failures, programs orbit outside them, and the graph holds about 2400 nodes. Two memory switches in the panel turn the reminder and the injected lessons off instantly. Repeated failures of one command collapse into a command fingerprint, bare exit codes with no stderr are muted as noise, and every lesson is distilled into a single rule line that is what gets injected into the context. Signatures shared by many lessons are treated as environment noise, so they stop marking lessons as failed"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "错误星图",
|
|
4
|
-
"description": "记录运行时错误与编程任务复盘,把每条教训连回它来自的错误,并在教训失效(同类错误复发)时标红告警;把错误按任务改过的程序目录聚成程序枢纽,可按错误、工具、程序或会话搜索图谱,并用柱状图看最近 24 小时与 30 天的活动量;图被截断或签名被静音时如实告知,节点可双击跳回来源会话;用 GPU 渲染成可交互的知识图谱,可一键导出 Markdown;界面文案跟随 DSH 语言设置(中文 / English);教训会主动注入到模型的上下文中,并可通过 atlas_lessons
|
|
4
|
+
"description": "记录运行时错误与编程任务复盘,把每条教训连回它来自的错误,并在教训失效(同类错误复发)时标红告警;把错误按任务改过的程序目录聚成程序枢纽,可按错误、工具、程序或会话搜索图谱,并用柱状图看最近 24 小时与 30 天的活动量;图被截断或签名被静音时如实告知,节点可双击跳回来源会话;用 GPU 渲染成可交互的知识图谱,可一键导出 Markdown;界面文案跟随 DSH 语言设置(中文 / English);教训会主动注入到模型的上下文中,并可通过 atlas_lessons 工具查询;内置一面「教训有效性」榜单,按标签与程序统计复发率,可收起;中间是 DSH 本体,往外每个会话窗口是一座岛、自己的错误围在周围,程序卫星挂在外层轨道,可容纳约 2400 个节点;面板底部可随时关闭「收尾提示」与「注入教训」两枚记忆开关;同一条命令在不同目录下的失败会被归并成命令指纹,没有 stderr 的裸退出码按噪声静音;每条教训会被压成一行的「本质规则」,注入上下文时只发这一行;被多条复盘共同引用的环境噪声不再把教训判为失效"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-error-atlas",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.26",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Records runtime errors and coding-task retrospectives, and renders
|
|
5
|
+
"description": "Records runtime errors and coding-task retrospectives, collapses noisy shell failures into command fingerprints, distils each lesson into one injected rule, and renders the whole thing as an interactive GPU knowledge graph (WebGL) with lesson links, recurrence flags, program satellites, session islands, orbit-any-node camera, search and activity trends.",
|
|
6
6
|
"icon": "icon.svg",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": "chaos-miku",
|
|
@@ -42,7 +42,9 @@
|
|
|
42
42
|
"cordis.patch.yml",
|
|
43
43
|
"locale/*.json",
|
|
44
44
|
"icon.svg",
|
|
45
|
-
"docs/*.jpg"
|
|
45
|
+
"docs/*.jpg",
|
|
46
|
+
"docs/*.png",
|
|
47
|
+
"CHANGELOG.md"
|
|
46
48
|
],
|
|
47
49
|
"bin": {
|
|
48
50
|
"atlas-retro": "bin/atlas-retro.mjs",
|
|
@@ -62,5 +64,9 @@
|
|
|
62
64
|
"immediately": true,
|
|
63
65
|
"inject": []
|
|
64
66
|
}
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"react": "18.3.1",
|
|
70
|
+
"react-dom": "18.3.1"
|
|
65
71
|
}
|
|
66
72
|
}
|