@yeaft/webchat-agent 0.1.959 → 0.1.961

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.959",
3
+ "version": "0.1.961",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -2,15 +2,12 @@
2
2
  * persist.js — Conversation message persistence
3
3
  *
4
4
  * Each message is stored as a .md file with YAML frontmatter in
5
- * ~/.yeaft/chat/messages/ or ~/.yeaft/groups/<sessionId>/conversation/messages/. Design: zero JSON, all Markdown.
5
+ * ~/.yeaft/chat/messages/ or ~/.yeaft/sessions/<sessionId>/conversation/messages/. Design: zero JSON, all Markdown.
6
6
  *
7
- * Vocabulary note: the on-disk layout literally uses `groups/<id>/` (and
8
- * the AMS registry uses `memory/sessions/<id>/`) the asymmetry is
9
- * deliberate. The `groups/` path predates the rename and we keep it as a
10
- * literal string to avoid a destructive data migration; every API
11
- * surface above the disk layer (method names, params, comments) uses
12
- * "session" vocabulary. See `ConversationStore.#sessionsDir` for the
13
- * boundary annotation.
7
+ * Vocabulary note: the primary on-disk layout uses `sessions/<id>/`. Older
8
+ * installs may still have transcript files under `groups/<id>/`; those are
9
+ * read as a legacy fallback only. Every API surface above the disk layer
10
+ * uses "session" vocabulary.
14
11
  *
15
12
  * Message format:
16
13
  * ---
@@ -199,12 +196,12 @@ function serializeMessage(msg) {
199
196
  // render a small "#source" pill next to each bubble.
200
197
  if (msg.sourceThreadId) fm.push(`sourceThreadId: ${msg.sourceThreadId}`);
201
198
  // Bug 6: persist sessionId so history replay can stamp messages with the
202
- // group they originated in. Without this, every replayed message lands
203
- // in the default group and switching back to the originating group
199
+ // session they originated in. Without this, every replayed message lands
200
+ // in the default session and switching back to the originating session
204
201
  // shows an empty pane.
205
202
  if (msg.sessionId) fm.push(`sessionId: ${msg.sessionId}`);
206
203
  if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
207
- // Group-chat attribution: when a VP authors an assistant turn (either
204
+ // Session attribution: when a VP authors an assistant turn (either
208
205
  // its own reply or a route_forward injection from another VP), stamp
209
206
  // the speaker so the UI can render the message on the correct VP track.
210
207
  // For real user messages this is unset.
@@ -424,25 +421,22 @@ export function parseMessage(raw) {
424
421
  * messages/
425
422
  * cold/
426
423
  * blobs/
427
- * groups/<sessionId>/conversation/
424
+ * sessions/<sessionId>/conversation/
428
425
  * compact/
429
426
  * messages/
430
427
  * cold/
431
428
  * blobs/
432
429
  *
433
- * Legacy compatibility: ~/.yeaft/conversation is read as an old mixed store.
434
- * New writes are split by mode: records with sessionId go to
435
- * groups/<sessionId>/conversation/, all others go to chat/.
430
+ * Legacy compatibility: ~/.yeaft/conversation is read as an old mixed store,
431
+ * and ~/.yeaft/groups/<sessionId>/conversation is read as an old session
432
+ * transcript store. New writes are split by mode: records with sessionId go to
433
+ * sessions/<sessionId>/conversation/, all others go to chat/.
436
434
  */
437
435
  export class ConversationStore {
438
436
  #dir; // root dir (e.g. ~/.yeaft)
439
437
  #chatDir; // ~/.yeaft/chat
440
- // ~/.yeaft/groupson-disk path literal kept for backward compat with
441
- // existing user data; the live disk layout is still `groups/<id>/` even
442
- // after the session-v1 meta migration (which only collapses the meta
443
- // files, not the conversation tree). All API surfaces above the disk
444
- // layer have been renamed to the "session" vocabulary.
445
- #sessionsDir;
438
+ #sessionsDir; // ~/.yeaft/sessionsprimary Session transcript store
439
+ #legacySessionsDir; // ~/.yeaft/groups read-only legacy Session transcripts
446
440
  #legacyConvDir; // ~/.yeaft/conversation (read-only compatibility)
447
441
  #convDir; // default thread dir root: ~/.yeaft/chat
448
442
  #msgDir; // default hot messages dir: ~/.yeaft/chat/messages
@@ -464,7 +458,8 @@ export class ConversationStore {
464
458
  constructor(dir) {
465
459
  this.#dir = dir;
466
460
  this.#chatDir = join(dir, 'chat');
467
- this.#sessionsDir = join(dir, 'groups');
461
+ this.#sessionsDir = join(dir, 'sessions');
462
+ this.#legacySessionsDir = join(dir, 'groups');
468
463
  this.#legacyConvDir = join(dir, 'conversation');
469
464
 
470
465
  this.#convDir = this.#chatDir;
@@ -479,17 +474,16 @@ export class ConversationStore {
479
474
  this.#legacyMsgDir = join(this.#legacyConvDir, 'messages');
480
475
  this.#legacyColdDir = join(this.#legacyConvDir, 'cold');
481
476
 
482
- // Per-(sessionId, vpId) compact summary files live under that group's
477
+ // Per-(sessionId, vpId) compact summary files live under that session's
483
478
  // conversation directory. The legacy ~/.yeaft/conversation/compact directory
484
479
  // is read for compatibility.
485
480
  this.#legacyCompactScopedDir = join(this.#legacyConvDir, 'compact');
486
481
  this.#nextSeq = null;
487
482
  this.#nextSeqByThread = new Map();
488
483
 
489
- // Ensure new chat and group-root directories exist (graceful on permission
490
- // errors). Per-group conversation directories are created lazily once a
491
- // sessionId is known. The legacy conversation directory is never created by
492
- // new versions.
484
+ // Ensure new chat and session-root directories exist (graceful on permission
485
+ // errors). Per-session conversation directories are created lazily once a
486
+ // sessionId is known. Legacy directories are never created by new versions.
493
487
  for (const d of [
494
488
  this.#chatDir, join(this.#chatDir, 'blobs'), this.#chatMsgDir, this.#chatColdDir,
495
489
  this.#sessionsDir,
@@ -724,7 +718,7 @@ export class ConversationStore {
724
718
  }
725
719
 
726
720
  /**
727
- * Check whether ANY per-(group, vp) compact summary exists for `sessionId`.
721
+ * Check whether ANY per-(session, vp) compact summary exists for `sessionId`.
728
722
  * Used by the history-replay path to decide whether to flag
729
723
  * `hasCompactSummary` for the UI without committing to one VP's view.
730
724
  *
@@ -1626,6 +1620,10 @@ export class ConversationStore {
1626
1620
  return dir;
1627
1621
  }
1628
1622
 
1623
+ #legacySessionConversationDir(sessionId) {
1624
+ return join(this.#legacySessionsDir, this.#safeDirComponent(sessionId), 'conversation');
1625
+ }
1626
+
1629
1627
  #ensureConversationDirs(dir) {
1630
1628
  for (const d of [dir, join(dir, 'blobs'), join(dir, 'messages'), join(dir, 'cold'), join(dir, 'compact')]) {
1631
1629
  if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
@@ -1633,26 +1631,34 @@ export class ConversationStore {
1633
1631
  }
1634
1632
 
1635
1633
  #sessionConversationDirs() {
1636
- if (!existsSync(this.#sessionsDir)) return [];
1637
1634
  const dirs = [];
1638
- for (const name of readdirSync(this.#sessionsDir)) {
1639
- const sessionDir = join(this.#sessionsDir, name);
1640
- try {
1641
- if (!statSync(sessionDir).isDirectory()) continue;
1642
- } catch (err) {
1643
- if (isPermissionError(err)) continue;
1644
- throw err;
1635
+ const seen = new Set();
1636
+ for (const root of [this.#sessionsDir, this.#legacySessionsDir]) {
1637
+ if (!existsSync(root)) continue;
1638
+ for (const name of readdirSync(root)) {
1639
+ const sessionDir = join(root, name);
1640
+ try {
1641
+ if (!statSync(sessionDir).isDirectory()) continue;
1642
+ } catch (err) {
1643
+ if (isPermissionError(err)) continue;
1644
+ throw err;
1645
+ }
1646
+ const conversationDir = join(sessionDir, 'conversation');
1647
+ if (!existsSync(conversationDir) || seen.has(conversationDir)) continue;
1648
+ seen.add(conversationDir);
1649
+ dirs.push(conversationDir);
1645
1650
  }
1646
- const conversationDir = join(sessionDir, 'conversation');
1647
- if (existsSync(conversationDir)) dirs.push(conversationDir);
1648
1651
  }
1649
1652
  return dirs;
1650
1653
  }
1651
1654
 
1652
1655
  #sessionMessageDirs(kind, sessionId = null) {
1653
1656
  if (sessionId) {
1654
- const dir = join(this.#sessionConversationDir(sessionId), kind);
1655
- return existsSync(dir) ? [dir] : [];
1657
+ const dirs = [
1658
+ join(this.#sessionConversationDir(sessionId), kind),
1659
+ join(this.#legacySessionConversationDir(sessionId), kind),
1660
+ ];
1661
+ return dirs.filter(dir => existsSync(dir));
1656
1662
  }
1657
1663
  return this.#sessionConversationDirs()
1658
1664
  .map(dir => join(dir, kind))
@@ -42,27 +42,34 @@ function compareNewest(a, b) {
42
42
  return String(b?.time || '').localeCompare(String(a?.time || ''));
43
43
  }
44
44
 
45
- function groupConversationMessageDirs(dir) {
46
- const groupsDir = join(dir, 'groups');
47
- if (!existsSync(groupsDir)) return [];
48
-
45
+ function sessionConversationMessageDirs(dir) {
49
46
  const dirs = [];
50
- for (const name of readdirSync(groupsDir)) {
51
- const groupDir = join(groupsDir, name);
52
- try {
53
- if (!statSync(groupDir).isDirectory()) continue;
54
- } catch {
55
- continue;
56
- }
47
+ const seen = new Set();
48
+ for (const rootName of ['sessions', 'groups']) {
49
+ const root = join(dir, rootName);
50
+ if (!existsSync(root)) continue;
51
+ for (const name of readdirSync(root)) {
52
+ const sessionDir = join(root, name);
53
+ try {
54
+ if (!statSync(sessionDir).isDirectory()) continue;
55
+ } catch {
56
+ continue;
57
+ }
57
58
 
58
- const conversationDir = join(groupDir, 'conversation');
59
- dirs.push(join(conversationDir, 'messages'), join(conversationDir, 'cold'));
59
+ const conversationDir = join(sessionDir, 'conversation');
60
+ for (const kind of ['messages', 'cold']) {
61
+ const messagesDir = join(conversationDir, kind);
62
+ if (seen.has(messagesDir)) continue;
63
+ seen.add(messagesDir);
64
+ dirs.push(messagesDir);
65
+ }
66
+ }
60
67
  }
61
68
  return dirs;
62
69
  }
63
70
 
64
71
  /**
65
- * Search Yeaft history (chat + per-group + legacy conversation) for a keyword.
72
+ * Search Yeaft history (chat + per-session + legacy conversation) for a keyword.
66
73
  *
67
74
  * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
68
75
  * @param {string} keyword — search term
@@ -75,8 +82,8 @@ export function searchMessages(dir, keyword, limit = 20) {
75
82
  const dirs = [
76
83
  join(dir, 'chat', 'messages'),
77
84
  join(dir, 'chat', 'cold'),
78
- ...groupConversationMessageDirs(dir),
79
- // Compatibility for profiles created before chat/group split.
85
+ ...sessionConversationMessageDirs(dir),
86
+ // Compatibility for profiles created before chat/session split.
80
87
  join(dir, 'conversation', 'messages'),
81
88
  join(dir, 'conversation', 'cold'),
82
89
  ];
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { promises as fsp } from 'fs';
22
22
  import { join, dirname } from 'path';
23
+ import { inspect } from 'util';
23
24
 
24
25
  import { writeMemory, writeSummary, readMemory, readSummary } from '../memory/store.js';
25
26
  import { withDreamMarker } from './state.js';
@@ -28,6 +29,18 @@ import { snapshotScope } from './snapshot.js';
28
29
  import { parseJsonSafe } from './triage.js';
29
30
  import { render } from './prompts/index.js';
30
31
 
32
+ function malformedJsonError(message, raw) {
33
+ const err = new Error(message);
34
+ err.rawSnippet = rawResponseSnippet(raw);
35
+ return err;
36
+ }
37
+
38
+ function rawResponseSnippet(raw) {
39
+ if (typeof raw === 'string') return raw.slice(0, 1000);
40
+ if (raw == null) return String(raw);
41
+ return inspect(raw, { depth: 2, maxArrayLength: 10, breakLength: 120 }).slice(0, 1000);
42
+ }
43
+
31
44
  function applySystem(language) {
32
45
  return String(language || '').toLowerCase().startsWith('zh')
33
46
  ? '你是梦境流水线的 Apply 阶段。你会根据最近的群组对话重写单个 scope 的 memory.md 和 summary.md。请只回复严格 JSON,不要输出说明文字或 markdown fence。memory_md 和 summary_md 的自然语言内容必须使用中文;JSON key、scope、schema 字段和代码标识符保持英文。'
@@ -204,7 +217,7 @@ export async function applyMergedTarget(merged, opts) {
204
217
  const raw = await opts.llm({ pass: 'create', prompt, system: applySystem(opts.language) });
205
218
  const parsed = parseJsonSafe(raw);
206
219
  if (!parsed || typeof parsed.memory_md !== 'string') {
207
- throw new Error(`apply: CREATE returned malformed JSON for ${merged.target}`);
220
+ throw malformedJsonError(`apply: CREATE returned malformed JSON for ${merged.target}`, raw);
208
221
  }
209
222
  memoryMd = parsed.memory_md;
210
223
  summaryMd = typeof parsed.summary_md === 'string' ? parsed.summary_md : '';
@@ -233,7 +246,7 @@ export async function applyMergedTarget(merged, opts) {
233
246
  const raw = await opts.llm({ pass: 'update', prompt, system: applySystem(opts.language) });
234
247
  const parsed = parseJsonSafe(raw);
235
248
  if (!parsed || typeof parsed.memory_md !== 'string') {
236
- throw new Error(`apply: UPDATE batch ${i} returned malformed JSON for ${merged.target}`);
249
+ throw malformedJsonError(`apply: UPDATE batch ${i} returned malformed JSON for ${merged.target}`, raw);
237
250
  }
238
251
  memoryMd = parsed.memory_md;
239
252
  if (typeof parsed.summary_md === 'string') summaryMd = parsed.summary_md;
@@ -176,6 +176,7 @@ export async function runDream(opts) {
176
176
  phase: 'triage',
177
177
  message: err.message,
178
178
  stack: err.stack,
179
+ rawSnippet: err.rawSnippet,
179
180
  });
180
181
  continue;
181
182
  }
@@ -227,6 +228,7 @@ export async function runDream(opts) {
227
228
  phase: 'apply',
228
229
  message: err.message,
229
230
  stack: err.stack,
231
+ rawSnippet: err.rawSnippet,
230
232
  });
231
233
  }
232
234
  }
@@ -38,10 +38,11 @@
38
38
  * `sessionId` may be inherited via `stampDreamScope()` when a scope is active.
39
39
  */
40
40
 
41
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
41
42
  import { join } from 'path';
42
43
  import { runDream } from './runner.js';
43
44
  import { createDreamScheduler } from './schedule.js';
44
- import { listSessions, openSession } from '../sessions/session-store.js';
45
+ import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
45
46
  import { readSessionState } from './state.js';
46
47
  import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS } from './limits.js';
47
48
 
@@ -56,48 +57,47 @@ import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS } from './limits.js';
56
57
  export function buildRunDreamOpts(session, onProgress) {
57
58
  const yeaftDir = session.yeaftDir;
58
59
  const memoryRoot = join(yeaftDir, 'memory');
59
- const sessionsRoot = join(yeaftDir, 'sessions');
60
+ const sessionConversationsRoot = join(yeaftDir, 'sessions');
61
+ // Legacy disk fallback for pre-session transcript directories. New writes and
62
+ // Dream's primary source use `sessions/<sessionId>/conversation`.
63
+ const legacySessionConversationsRoot = join(yeaftDir, 'groups');
60
64
 
61
65
  return {
62
66
  root: memoryRoot,
63
67
  language: session.config?.language || 'en',
64
68
  llm: makeLlm(session),
65
69
  listSessions: async () => {
66
- try { return listSessions(sessionsRoot).map(g => g.id); }
70
+ try { return listConversationSessions([sessionConversationsRoot, legacySessionConversationsRoot]); }
67
71
  catch { return []; }
68
72
  },
69
- countMessages: async (gid) => {
70
- try {
71
- const h = openSession(sessionsRoot, gid);
72
- let n = 0;
73
- for (const _m of h.streamMessages()) n += 1;
74
- return n;
75
- } catch { return 0; }
73
+ countMessages: async (sessionId) => {
74
+ try { return loadSessionConversationMessages([sessionConversationsRoot, legacySessionConversationsRoot], sessionId).length; }
75
+ catch { return 0; }
76
76
  },
77
- loadGroupDiff: async (gid, sinceId) => {
77
+ loadGroupDiff: async (sessionId, sinceId) => {
78
78
  try {
79
- const h = openSession(sessionsRoot, gid);
79
+ const messages = loadSessionConversationMessages([sessionConversationsRoot, legacySessionConversationsRoot], sessionId);
80
80
  const out = [];
81
81
  let started = !sinceId;
82
- for (const m of h.streamMessages()) {
82
+ for (const m of messages) {
83
83
  if (!started) {
84
84
  if (m.id === sinceId) started = true;
85
85
  continue;
86
86
  }
87
- out.push(translateGroupMessage(m));
87
+ out.push(translateSessionConversationMessage(m));
88
88
  }
89
89
  return out;
90
90
  } catch { return []; }
91
91
  },
92
- loadOverlapPreamble: async (gid, beforeId, n) => {
92
+ loadOverlapPreamble: async (sessionId, beforeId, n) => {
93
93
  try {
94
- const h = openSession(sessionsRoot, gid);
94
+ const messages = loadSessionConversationMessages([sessionConversationsRoot, legacySessionConversationsRoot], sessionId);
95
95
  const buf = [];
96
- for (const m of h.streamMessages()) {
96
+ for (const m of messages) {
97
97
  if (m.id === beforeId) break;
98
98
  buf.push(m);
99
99
  }
100
- return buf.slice(-n).map(translateGroupMessage);
100
+ return buf.slice(-n).map(translateSessionConversationMessage);
101
101
  } catch { return []; }
102
102
  },
103
103
  onProgress,
@@ -105,8 +105,8 @@ export function buildRunDreamOpts(session, onProgress) {
105
105
  }
106
106
 
107
107
  /**
108
- * Translate a group-store message record (id, from, role, text, ...) into
109
- * the shape runDream expects (id, role, body, vpId, author).
108
+ * Translate a persisted session conversation message into the shape runDream
109
+ * expects (id, role, body, vpId, author).
110
110
  *
111
111
  * (2026-05-13: legacy `m.meta.featureId` propagation was dropped along
112
112
  * with the Feature system. Historical messages on disk may still carry
@@ -114,19 +114,107 @@ export function buildRunDreamOpts(session, onProgress) {
114
114
  *
115
115
  * @param {Object} m
116
116
  */
117
- function translateGroupMessage(m) {
118
- const role = m.role || (m.from === 'user' ? 'user' : 'assistant');
117
+ function translateSessionConversationMessage(m) {
118
+ const role = m.role || 'assistant';
119
119
  const out = {
120
120
  id: m.id,
121
121
  role,
122
- body: m.text || '',
122
+ body: m.content || m.text || '',
123
123
  };
124
- if (role === 'assistant' && m.from && m.from !== 'user') {
125
- out.vpId = m.from;
124
+ if (role === 'assistant' && m.speakerVpId) {
125
+ out.vpId = m.speakerVpId;
126
126
  }
127
127
  return out;
128
128
  }
129
129
 
130
+ /**
131
+ * Enumerate session ids that have persisted conversation messages. `sessions/`
132
+ * is the primary layout; `groups/` is read-only legacy fallback.
133
+ *
134
+ * @param {string[]} roots session transcript roots in priority order
135
+ * @returns {string[]}
136
+ */
137
+ function listConversationSessions(roots) {
138
+ const out = new Set();
139
+ for (const root of roots) {
140
+ if (!existsSync(root)) continue;
141
+ for (const name of readdirSync(root)) {
142
+ if (name.startsWith('.')) continue;
143
+ try {
144
+ const dir = sessionConversationDir(root, name);
145
+ if (!hasReadableMessages(dir)) continue;
146
+ out.add(name);
147
+ } catch {
148
+ // Ignore partial or old session directories. Dream only needs sessions
149
+ // with readable conversation messages.
150
+ }
151
+ }
152
+ }
153
+ return [...out].sort();
154
+ }
155
+
156
+ /**
157
+ * Load hot and cold conversation messages for one Yeaft Session. This mirrors
158
+ * ConversationStore's session history source: `conversation/cold` plus
159
+ * `conversation/messages`, deduped by id and sorted by message sequence.
160
+ *
161
+ * @param {string[]} roots session transcript roots in priority order
162
+ * @param {string} sessionId
163
+ * @returns {object[]}
164
+ */
165
+ function loadSessionConversationMessages(roots, sessionId) {
166
+ const byId = new Map();
167
+ for (const root of roots) {
168
+ const dir = sessionConversationDir(root, sessionId);
169
+ for (const m of loadConversationDirMessages(dir)) {
170
+ if (!byId.has(m.id)) byId.set(m.id, m);
171
+ }
172
+ }
173
+ return [...byId.values()].sort(compareMessagesBySeq);
174
+ }
175
+
176
+ function sessionConversationDir(root, sessionId) {
177
+ return join(root, safeDirComponent(sessionId), 'conversation');
178
+ }
179
+
180
+ function hasReadableMessages(conversationDir) {
181
+ return ['messages', 'cold'].some(kind => {
182
+ const dir = join(conversationDir, kind);
183
+ try {
184
+ return statSync(dir).isDirectory() && readdirSync(dir).some(f => f.endsWith('.md'));
185
+ } catch {
186
+ return false;
187
+ }
188
+ });
189
+ }
190
+
191
+ function loadConversationDirMessages(conversationDir) {
192
+ return ['cold', 'messages'].flatMap(kind => loadMessageDir(join(conversationDir, kind)));
193
+ }
194
+
195
+ function loadMessageDir(dir) {
196
+ if (!existsSync(dir)) return [];
197
+ return readdirSync(dir)
198
+ .filter(f => f.endsWith('.md'))
199
+ .map(file => {
200
+ try { return parseMessage(readFileSync(join(dir, file), 'utf8')); }
201
+ catch { return null; }
202
+ })
203
+ .filter(m => m && m.id);
204
+ }
205
+
206
+ function compareMessagesBySeq(a, b) {
207
+ const sa = parseSeqFromId(a?.id);
208
+ const sb = parseSeqFromId(b?.id);
209
+ if (Number.isFinite(sa) && Number.isFinite(sb) && sa !== sb) return sa - sb;
210
+ return String(a?.time || '').localeCompare(String(b?.time || ''));
211
+ }
212
+
213
+ function safeDirComponent(s) {
214
+ const safe = String(s).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120).replace(/^\.+$/, '_');
215
+ return safe || '_';
216
+ }
217
+
130
218
  /**
131
219
  * Build the LLM callable that runDream's triage/apply prompts use.
132
220
  *
@@ -178,6 +178,7 @@ export async function writeDreamError(root, scope, info) {
178
178
  phase: String(info?.phase || 'unknown'),
179
179
  message: String(info?.message || ''),
180
180
  stack: stackLines.length > 0 ? stackLines.join('\n') : null,
181
+ rawSnippet: typeof info?.rawSnippet === 'string' ? info.rawSnippet.slice(0, 1000) : null,
181
182
  }, null, 2) + '\n';
182
183
  await atomicWrite(abs, body);
183
184
  } catch {