@yeaft/webchat-agent 1.0.90 → 1.0.91

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": "1.0.90",
3
+ "version": "1.0.91",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,187 @@
1
+ /**
2
+ * recovery.js — best-effort Session disk self-recovery.
3
+ *
4
+ * Instances can contain old partially migrated Session dirs where the
5
+ * conversation transcript still exists under conversation/messages/*.md, but
6
+ * session.json is missing. This module repairs Session metadata and the
7
+ * coordinator audit-log index only. It deliberately does not convert
8
+ * conversation Markdown into the audit log: the engine/UI history source is
9
+ * ConversationStore under conversation/, not sessions/<id>/messages/.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ rmSync,
18
+ statSync,
19
+ } from 'fs';
20
+ import { join } from 'path';
21
+ import { writeAtomic, openLog } from '../storage/index.js';
22
+ import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
23
+ import { loadSessionMeta, SESSION_META_FILE } from './session-store.js';
24
+
25
+ const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/;
26
+ const DEFAULT_VP_ID = 'omni';
27
+ const AUDIT_INDEX_FILE = 'index.json';
28
+
29
+ export function repairSessionStore(yeaftDir, options = {}) {
30
+ const root = join(yeaftDir, 'sessions');
31
+ if (!existsSync(root)) return { repaired: 0, sessions: [] };
32
+ const sessionIds = safeReadDir(root)
33
+ .filter(name => !name.startsWith('.') && SESSION_ID_RE.test(name))
34
+ .filter(name => isDirectory(join(root, name)));
35
+ const sessions = [];
36
+ for (const sessionId of sessionIds) {
37
+ const result = repairSessionDir(root, sessionId, options);
38
+ if (result.changed) sessions.push(result);
39
+ }
40
+ return { repaired: sessions.length, sessions };
41
+ }
42
+
43
+ export function repairSessionDir(sessionsRoot, sessionId, options = {}) {
44
+ const dir = join(sessionsRoot, sessionId);
45
+ const result = {
46
+ sessionId,
47
+ changed: false,
48
+ metaCreated: false,
49
+ auditIndexRebuilt: false,
50
+ };
51
+ if (!isDirectory(dir)) return result;
52
+
53
+ if (!loadSessionMeta(dir)) {
54
+ const meta = inferSessionMeta(dir, sessionId, options);
55
+ writeAtomic(join(dir, SESSION_META_FILE), JSON.stringify(meta, null, 2));
56
+ result.metaCreated = true;
57
+ result.changed = true;
58
+ }
59
+
60
+ if (repairAuditIndexIfNeeded(join(dir, 'messages'))) {
61
+ result.auditIndexRebuilt = true;
62
+ result.changed = true;
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ function inferSessionMeta(dir, sessionId, options = {}) {
69
+ const first = readFirstConversationMarkdownRow(dir, sessionId);
70
+ const createdAt = first?.time || safeStatTime(dir) || new Date().toISOString();
71
+ const name = inferSessionName(sessionId);
72
+ const roster = Array.isArray(options.defaultRoster)
73
+ ? options.defaultRoster.filter(v => typeof v === 'string' && v)
74
+ : [DEFAULT_VP_ID];
75
+ const defaultVpId = typeof options.defaultVpId === 'string'
76
+ ? options.defaultVpId
77
+ : (roster.includes(DEFAULT_VP_ID) ? DEFAULT_VP_ID : (roster[0] || null));
78
+ return {
79
+ id: sessionId,
80
+ name,
81
+ roster,
82
+ defaultVpId,
83
+ announcement: '',
84
+ workDir: typeof options.workDir === 'string' ? options.workDir : '',
85
+ createdAt,
86
+ };
87
+ }
88
+
89
+ function inferSessionName(sessionId) {
90
+ const raw = sessionId
91
+ .replace(/^session_/, '')
92
+ .replace(/_[A-Z0-9]{8}$/, '')
93
+ .replace(/[-_]+/g, ' ')
94
+ .trim();
95
+ return raw ? raw.replace(/\b\w/g, ch => ch.toUpperCase()) : sessionId;
96
+ }
97
+
98
+ function readFirstConversationMarkdownRow(sessionDir, sessionId) {
99
+ const dirs = [
100
+ join(sessionDir, 'conversation', 'cold'),
101
+ join(sessionDir, 'conversation', 'messages'),
102
+ ];
103
+ let first = null;
104
+ for (const dir of dirs) {
105
+ if (!existsSync(dir)) continue;
106
+ for (const file of safeReadDir(dir).filter(f => /^m\d+\.md$/.test(f)).sort()) {
107
+ const msg = parseMessage(safeRead(join(dir, file)));
108
+ if (!msg) continue;
109
+ if (msg.sessionId && msg.sessionId !== sessionId) continue;
110
+ if (!first || compareMarkdownMessages(msg, first) < 0) first = msg;
111
+ }
112
+ }
113
+ return first;
114
+ }
115
+
116
+ function repairAuditIndexIfNeeded(messagesDir) {
117
+ if (!existsSync(messagesDir)) return false;
118
+ const segmentFiles = safeReadDir(messagesDir).filter(f => /^\d+\.jsonl$/.test(f)).sort();
119
+ if (segmentFiles.length === 0) return false;
120
+
121
+ const indexPath = join(messagesDir, AUDIT_INDEX_FILE);
122
+ if (!auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles)) return false;
123
+
124
+ rmSync(indexPath, { force: true });
125
+ const log = openLog(messagesDir);
126
+ log.close();
127
+ return true;
128
+ }
129
+
130
+ function auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles) {
131
+ const parsed = readJson(indexPath);
132
+ if (!parsed || !Array.isArray(parsed.segments)) return true;
133
+ const indexFiles = parsed.segments.map(seg => seg && seg.file).filter(Boolean).sort();
134
+ if (indexFiles.length !== segmentFiles.length) return true;
135
+ for (let i = 0; i < segmentFiles.length; i++) {
136
+ if (indexFiles[i] !== segmentFiles[i]) return true;
137
+ }
138
+
139
+ const byFile = new Map(parsed.segments.map(seg => [seg.file, seg]));
140
+ for (const file of segmentFiles) {
141
+ const seg = byFile.get(file);
142
+ if (!seg) return true;
143
+ const bytes = safeSize(join(messagesDir, file));
144
+ // This catches the broken real-world case: index says an existing segment
145
+ // is empty even though the JSONL file has data. Full validation is left to
146
+ // openLog's rebuild path after we remove the stale index.
147
+ if (bytes > 0 && ((Number(seg.count) || 0) === 0 || (Number(seg.bytes) || 0) === 0)) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function compareMarkdownMessages(a, b) {
153
+ const as = parseSeqFromId(a?.id);
154
+ const bs = parseSeqFromId(b?.id);
155
+ if (Number.isFinite(as) && Number.isFinite(bs) && as !== bs) return as - bs;
156
+ return String(a?.time || '').localeCompare(String(b?.time || ''));
157
+ }
158
+
159
+ function readJson(path) {
160
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
161
+ catch { return null; }
162
+ }
163
+
164
+ function safeRead(path) {
165
+ try { return readFileSync(path, 'utf8'); }
166
+ catch { return ''; }
167
+ }
168
+
169
+ function safeReadDir(dir) {
170
+ try { return readdirSync(dir); }
171
+ catch { return []; }
172
+ }
173
+
174
+ function isDirectory(path) {
175
+ try { return statSync(path).isDirectory(); }
176
+ catch { return false; }
177
+ }
178
+
179
+ function safeSize(path) {
180
+ try { return statSync(path).size; }
181
+ catch { return 0; }
182
+ }
183
+
184
+ function safeStatTime(path) {
185
+ try { return statSync(path).mtime.toISOString(); }
186
+ catch { return null; }
187
+ }
@@ -57,6 +57,7 @@ import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
57
57
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
58
58
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store.js';
59
59
  import { ensureSessionConfigFile, saveSessionConfig, loadSessionConfig } from './session-config.js';
60
+ import { repairSessionStore } from './recovery.js';
60
61
  import {
61
62
  addOrUpdateManifestSession,
62
63
  ensureSessionsManifest,
@@ -185,6 +186,17 @@ function copySessionExtras(sourceYeaftDir, destYeaftDir, sessionId) {
185
186
  }
186
187
  }
187
188
 
189
+ function repairSessionStoreAndManifest(yeaftDir, options = {}) {
190
+ const repaired = repairSessionStore(yeaftDir, options);
191
+ ensureSessionManifestReady(yeaftDir);
192
+ for (const row of repaired.sessions || []) {
193
+ const dir = join(sessionsRoot(yeaftDir), row.sessionId);
194
+ const meta = loadSessionMeta(dir);
195
+ if (meta) addOrUpdateManifestSession(yeaftDir, meta, dir);
196
+ }
197
+ return repaired;
198
+ }
199
+
188
200
  /**
189
201
  * One-way compatibility migration for sessions previously stored under a
190
202
  * project `.yeaft/sessions` tree. New steady-state discovery uses
@@ -380,7 +392,9 @@ function scanSortedVpIds(libDir) {
380
392
  export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
381
393
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
382
394
  const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
383
- ensureSessionManifestReady(yeaftDir);
395
+ repairSessionStoreAndManifest(yeaftDir, {
396
+ defaultRoster: scanSortedVpIds(libDir),
397
+ });
384
398
  const existing = listManifestSessions(yeaftDir).map(row => row.meta);
385
399
  if (existing.length > 0) {
386
400
  return { seeded: false, sessionId: existing[0].id };
@@ -718,7 +732,7 @@ export function requireSession(yeaftDir, sessionId) {
718
732
 
719
733
  /** Convenience: snapshot all non-archived groups for WS broadcast. */
720
734
  export function snapshotSessions(yeaftDir) {
721
- ensureSessionManifestReady(yeaftDir);
735
+ repairSessionStoreAndManifest(yeaftDir);
722
736
  const byId = new Map();
723
737
  for (const row of listManifestSessions(yeaftDir)) {
724
738
  byId.set(row.meta.id, row.meta);