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.
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: 1200,
37
- maxLog: 400,
38
- maxNodes: 240,
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
- if (typeof parsed.prefs.prompt === 'boolean') state.prefs.prompt = parsed.prefs.prompt
344
- if (typeof parsed.prefs.inject === 'boolean') state.prefs.inject = parsed.prefs.inject
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: 2,
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
- const lessons = lessonList()
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
- const stale = lessons.filter((item) => item.stale > 0).slice(0, 2)
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.join('/') + '] ' : ''
734
- const text = item.lesson || item.done || item.title
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 visible = all.filter(
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 detail = summarizeArguments(exec.arguments)
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: 'exit code ' + code + (detail ? ' · ' + detail : ''),
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` (the rule that prevents a repeat), `tags`. Skip plain chat, reading and questions.',
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.2",
3
+ "version": "0.11.26",
4
4
  "type": "module",
5
- "description": "Records runtime errors and coding-task retrospectives, and renders them as an interactive GPU knowledge graph (WebGL) with lesson links, recurrence flags, program hubs, search, activity trends, and a caption switch.",
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
  }