dsh-tdai-memory 0.2.13 → 0.3.0

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.
Files changed (72) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +15 -13
  3. package/README.zh.md +9 -7
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/client.js +21 -4
  6. package/cordis.patch.yml +8 -8
  7. package/index.js +36 -14
  8. package/package.json +77 -78
  9. package/recall-inject.js +86 -86
  10. package/vendor/tdai/adapters/index.js +15 -15
  11. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  12. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  13. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  14. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  15. package/vendor/tdai/adapters/standalone/index.js +5 -5
  16. package/vendor/tdai/adapters/standalone/llm-runner.js +34 -6
  17. package/vendor/tdai/config.js +3 -0
  18. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  19. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  20. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  21. package/vendor/tdai/core/index.js +8 -8
  22. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  23. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  24. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  25. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  26. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  27. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  28. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  29. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  30. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  31. package/vendor/tdai/core/record/l1-reader.js +178 -178
  32. package/vendor/tdai/core/record/l1-writer.js +141 -141
  33. package/vendor/tdai/core/report/reporter.js +86 -86
  34. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  35. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  36. package/vendor/tdai/core/scene/scene-format.js +53 -53
  37. package/vendor/tdai/core/scene/scene-index.js +82 -82
  38. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  39. package/vendor/tdai/core/seed/input.js +404 -404
  40. package/vendor/tdai/core/seed/seed-runtime.js +4 -0
  41. package/vendor/tdai/core/seed/types.js +9 -9
  42. package/vendor/tdai/core/store/bm25-client.js +117 -117
  43. package/vendor/tdai/core/store/bm25-local.js +68 -68
  44. package/vendor/tdai/core/store/embedding.js +632 -632
  45. package/vendor/tdai/core/store/factory.js +95 -95
  46. package/vendor/tdai/core/store/search-utils.js +54 -54
  47. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  48. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  49. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  50. package/vendor/tdai/core/store/types.js +17 -17
  51. package/vendor/tdai/core/tdai-core.js +4 -0
  52. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  53. package/vendor/tdai/core/tools/memory-search.js +207 -207
  54. package/vendor/tdai/core/types.js +12 -12
  55. package/vendor/tdai/utils/backup.js +189 -189
  56. package/vendor/tdai/utils/checkpoint.js +360 -360
  57. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  58. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  59. package/vendor/tdai/utils/env.js +13 -13
  60. package/vendor/tdai/utils/managed-timer.js +127 -127
  61. package/vendor/tdai/utils/manifest.js +91 -91
  62. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  63. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  64. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  65. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  66. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  67. package/vendor/tdai/utils/sanitize.js +360 -360
  68. package/vendor/tdai/utils/serial-queue.js +103 -103
  69. package/vendor/tdai/utils/session-filter.js +88 -88
  70. package/vendor/tdai/utils/session-header-fetch.js +88 -0
  71. package/vendor/tdai/utils/text-utils.js +29 -29
  72. package/vendor/tdai/utils/time.js +252 -252
@@ -1,404 +1,404 @@
1
- /**
2
- * Input loading, validation, normalization, and timestamp handling for the `seed` command.
3
- *
4
- * Responsibilities:
5
- * 1. Load raw JSON from file
6
- * 2. Detect Format A (`{ sessions: [...] }`) vs Format B (`[...]`)
7
- * 3. Six-layer validation (file → top-level → session → round → message → timestamp consistency)
8
- * 4. Normalize into NormalizedInput with auto-generated sessionIds
9
- * 5. Timestamp all-or-none check + fill strategy
10
- */
11
- import fs from "node:fs";
12
- import crypto from "node:crypto";
13
- /**
14
- * Load, validate, and normalize seed input from a file.
15
- *
16
- * Throws on fatal validation errors with a human-readable message
17
- * that includes all collected errors.
18
- */
19
- export function loadAndValidateInput(opts) {
20
- // Layer 1: File — read + parse
21
- const raw = loadRawInput(opts.input);
22
- // Layer 2: Top-level — detect A vs B
23
- const sessions = extractSessions(raw);
24
- // Layers 3-5: session / round / message validation
25
- const errors = [];
26
- validateSessions(sessions, opts.strictRoundRole, errors);
27
- if (errors.length > 0) {
28
- throw new SeedValidationError(errors);
29
- }
30
- // Layer 6: Timestamp consistency (all-have / all-missing / mixed → error)
31
- const tsResult = checkTimestampConsistency(sessions);
32
- if (tsResult.status === "mixed") {
33
- throw new SeedValidationError([{
34
- stage: "timestamp_consistency",
35
- message: "Timestamp consistency check failed: some messages have timestamps while others do not. " +
36
- "All messages must either have timestamps or none must have timestamps.",
37
- }]);
38
- }
39
- // Normalize
40
- const normalized = normalizeSessions(sessions, opts.sessionKey);
41
- return {
42
- input: {
43
- sessions: normalized.sessions,
44
- totalRounds: normalized.totalRounds,
45
- totalMessages: normalized.totalMessages,
46
- hasTimestamps: tsResult.status === "all_present",
47
- },
48
- needsTimestampConfirmation: tsResult.status === "all_missing",
49
- };
50
- }
51
- /**
52
- * Validate and normalize seed input from an already-parsed JSON object.
53
- *
54
- * This is the gateway-friendly variant of `loadAndValidateInput` — it skips
55
- * the file-system layer (Layer 1) and accepts the raw parsed body directly.
56
- * Timestamps missing from all messages are auto-filled (no interactive
57
- * confirmation needed in HTTP context).
58
- *
59
- * Throws `SeedValidationError` on validation failures.
60
- */
61
- export function validateAndNormalizeRaw(raw, opts) {
62
- const strictRoundRole = opts?.strictRoundRole ?? false;
63
- const autoFillTimestamps = opts?.autoFillTimestamps ?? true;
64
- // Layer 2: Top-level — detect A vs B
65
- const sessions = extractSessions(raw);
66
- // Layers 3-5: session / round / message validation
67
- const errors = [];
68
- validateSessions(sessions, strictRoundRole, errors);
69
- if (errors.length > 0) {
70
- throw new SeedValidationError(errors);
71
- }
72
- // Layer 6: Timestamp consistency
73
- const tsResult = checkTimestampConsistency(sessions);
74
- if (tsResult.status === "mixed") {
75
- throw new SeedValidationError([{
76
- stage: "timestamp_consistency",
77
- message: "Timestamp consistency check failed: some messages have timestamps while others do not. " +
78
- "All messages must either have timestamps or none must have timestamps.",
79
- }]);
80
- }
81
- // Normalize
82
- const normalized = normalizeSessions(sessions, opts?.sessionKey);
83
- const input = {
84
- sessions: normalized.sessions,
85
- totalRounds: normalized.totalRounds,
86
- totalMessages: normalized.totalMessages,
87
- hasTimestamps: tsResult.status === "all_present",
88
- };
89
- // Auto-fill timestamps in HTTP context (no interactive prompt)
90
- if (tsResult.status === "all_missing" && autoFillTimestamps) {
91
- fillTimestamps(input);
92
- }
93
- return input;
94
- }
95
- /**
96
- * Fill timestamps for all messages when the input has no timestamps.
97
- *
98
- * Uses a single monotonically increasing counter across ALL sessions
99
- * to guarantee global timestamp ordering. This is critical when multiple
100
- * sessions share the same sessionKey — the L0 capture cursor (advanced
101
- * per-session) would filter out later sessions whose timestamps fall
102
- * below the cursor if ordering were not globally monotonic.
103
- */
104
- export function fillTimestamps(input) {
105
- let currentTs = Date.now();
106
- for (const session of input.sessions) {
107
- for (const round of session.rounds) {
108
- for (let i = 0; i < round.messages.length; i++) {
109
- // Small offset per message to maintain strict ordering
110
- round.messages[i].timestamp = currentTs;
111
- currentTs += 100;
112
- }
113
- }
114
- }
115
- input.hasTimestamps = true;
116
- }
117
- // ============================
118
- // Validation error class
119
- // ============================
120
- export class SeedValidationError extends Error {
121
- errors;
122
- constructor(errors) {
123
- const summary = errors.map((e) => formatValidationError(e)).join("\n");
124
- super(`Seed input validation failed (${errors.length} error(s)):\n${summary}`);
125
- this.name = "SeedValidationError";
126
- this.errors = errors;
127
- }
128
- }
129
- function formatValidationError(e) {
130
- const parts = [` [${e.stage}]`];
131
- if (e.sourceIndex != null)
132
- parts.push(`session[${e.sourceIndex}]`);
133
- if (e.sessionKey)
134
- parts.push(`key="${e.sessionKey}"`);
135
- if (e.roundIndex != null)
136
- parts.push(`round[${e.roundIndex}]`);
137
- if (e.messageIndex != null)
138
- parts.push(`msg[${e.messageIndex}]`);
139
- parts.push(e.message);
140
- return parts.join(" ");
141
- }
142
- // ============================
143
- // Layer 1: File loading
144
- // ============================
145
- function loadRawInput(filePath) {
146
- if (!fs.existsSync(filePath)) {
147
- throw new SeedValidationError([{
148
- stage: "file",
149
- message: `Input file not found: ${filePath}`,
150
- }]);
151
- }
152
- const content = fs.readFileSync(filePath, "utf-8").trim();
153
- if (!content) {
154
- throw new SeedValidationError([{
155
- stage: "file",
156
- message: "Input file is empty.",
157
- }]);
158
- }
159
- try {
160
- return JSON.parse(content);
161
- }
162
- catch (err) {
163
- throw new SeedValidationError([{
164
- stage: "file",
165
- message: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
166
- }]);
167
- }
168
- }
169
- // ============================
170
- // Layer 2: Top-level format detection
171
- // ============================
172
- function extractSessions(raw) {
173
- // Format A: { sessions: [...] }
174
- if (raw != null &&
175
- typeof raw === "object" &&
176
- !Array.isArray(raw) &&
177
- "sessions" in raw) {
178
- const obj = raw;
179
- if (!Array.isArray(obj.sessions)) {
180
- throw new SeedValidationError([{
181
- stage: "top_level",
182
- message: 'Format A detected but "sessions" is not an array.',
183
- }]);
184
- }
185
- return obj.sessions;
186
- }
187
- // Format B: [...]
188
- if (Array.isArray(raw)) {
189
- return raw;
190
- }
191
- throw new SeedValidationError([{
192
- stage: "top_level",
193
- message: "Unrecognized input format. Expected either:\n" +
194
- ' Format A: { "sessions": [...] }\n' +
195
- " Format B: [ { sessionKey, conversations }, ... ]",
196
- }]);
197
- }
198
- // ============================
199
- // Layers 3-5: session / round / message validation
200
- // ============================
201
- function validateSessions(sessions, strictRoundRole, errors) {
202
- if (sessions.length === 0) {
203
- errors.push({
204
- stage: "session",
205
- message: "No sessions found in input.",
206
- });
207
- return;
208
- }
209
- for (let si = 0; si < sessions.length; si++) {
210
- const session = sessions[si];
211
- // Layer 3: session validation
212
- if (!session.sessionKey || typeof session.sessionKey !== "string" || session.sessionKey.trim() === "") {
213
- errors.push({
214
- stage: "session",
215
- sourceIndex: si,
216
- message: '"sessionKey" is required and must be a non-empty string.',
217
- });
218
- }
219
- if (!Array.isArray(session.conversations)) {
220
- errors.push({
221
- stage: "session",
222
- sourceIndex: si,
223
- sessionKey: session.sessionKey,
224
- message: '"conversations" must be a two-dimensional array (array of rounds).',
225
- });
226
- continue; // Can't validate rounds
227
- }
228
- // Check that conversations is a 2D array
229
- for (let ri = 0; ri < session.conversations.length; ri++) {
230
- const round = session.conversations[ri];
231
- // Layer 4: round validation
232
- if (!Array.isArray(round)) {
233
- errors.push({
234
- stage: "round",
235
- sourceIndex: si,
236
- sessionKey: session.sessionKey,
237
- roundIndex: ri,
238
- message: "Round must be an array of messages.",
239
- });
240
- continue;
241
- }
242
- if (round.length === 0) {
243
- errors.push({
244
- stage: "round",
245
- sourceIndex: si,
246
- sessionKey: session.sessionKey,
247
- roundIndex: ri,
248
- message: "Round must be a non-empty array.",
249
- });
250
- continue;
251
- }
252
- // Strict round-role: each round must have at least one user and one assistant
253
- if (strictRoundRole) {
254
- const roles = new Set(round.map((m) => m.role));
255
- if (!roles.has("user")) {
256
- errors.push({
257
- stage: "round",
258
- sourceIndex: si,
259
- sessionKey: session.sessionKey,
260
- roundIndex: ri,
261
- message: '--strict-round-role: round must contain at least one "user" message.',
262
- });
263
- }
264
- if (!roles.has("assistant")) {
265
- errors.push({
266
- stage: "round",
267
- sourceIndex: si,
268
- sessionKey: session.sessionKey,
269
- roundIndex: ri,
270
- message: '--strict-round-role: round must contain at least one "assistant" message.',
271
- });
272
- }
273
- }
274
- // Layer 5: message validation
275
- for (let mi = 0; mi < round.length; mi++) {
276
- const msg = round[mi];
277
- if (!msg.role || typeof msg.role !== "string") {
278
- errors.push({
279
- stage: "message",
280
- sourceIndex: si,
281
- sessionKey: session.sessionKey,
282
- roundIndex: ri,
283
- messageIndex: mi,
284
- message: '"role" is required and must be a non-empty string.',
285
- });
286
- }
287
- if (!msg.content || typeof msg.content !== "string" || msg.content.trim() === "") {
288
- errors.push({
289
- stage: "message",
290
- sourceIndex: si,
291
- sessionKey: session.sessionKey,
292
- roundIndex: ri,
293
- messageIndex: mi,
294
- message: '"content" is required and must be a non-empty string.',
295
- });
296
- }
297
- if (msg.timestamp !== undefined) {
298
- if (typeof msg.timestamp === "number") {
299
- if (!Number.isInteger(msg.timestamp)) {
300
- errors.push({
301
- stage: "message",
302
- sourceIndex: si,
303
- sessionKey: session.sessionKey,
304
- roundIndex: ri,
305
- messageIndex: mi,
306
- message: '"timestamp" must be an integer (epoch milliseconds). Negative values are allowed for dates before 1970.',
307
- });
308
- }
309
- }
310
- else if (typeof msg.timestamp === "string") {
311
- if (Number.isNaN(new Date(msg.timestamp).getTime())) {
312
- errors.push({
313
- stage: "message",
314
- sourceIndex: si,
315
- sessionKey: session.sessionKey,
316
- roundIndex: ri,
317
- messageIndex: mi,
318
- message: `"timestamp" string is not a valid ISO 8601 date: "${msg.timestamp}".`,
319
- });
320
- }
321
- }
322
- else {
323
- errors.push({
324
- stage: "message",
325
- sourceIndex: si,
326
- sessionKey: session.sessionKey,
327
- roundIndex: ri,
328
- messageIndex: mi,
329
- message: '"timestamp" must be a number (epoch ms) or an ISO 8601 string.',
330
- });
331
- }
332
- }
333
- }
334
- }
335
- }
336
- }
337
- function checkTimestampConsistency(sessions) {
338
- let hasTs = false;
339
- let missingTs = false;
340
- for (const session of sessions) {
341
- if (!Array.isArray(session.conversations))
342
- continue;
343
- for (const round of session.conversations) {
344
- if (!Array.isArray(round))
345
- continue;
346
- for (const msg of round) {
347
- if (msg.timestamp !== undefined && msg.timestamp !== null) {
348
- hasTs = true;
349
- }
350
- else {
351
- missingTs = true;
352
- }
353
- // Early exit on mixed
354
- if (hasTs && missingTs) {
355
- return { status: "mixed" };
356
- }
357
- }
358
- }
359
- }
360
- if (hasTs && !missingTs)
361
- return { status: "all_present" };
362
- if (!hasTs && missingTs)
363
- return { status: "all_missing" };
364
- // No messages at all — treat as all_missing (will be caught by session validation)
365
- return { status: "all_missing" };
366
- }
367
- // ============================
368
- // Normalization
369
- // ============================
370
- function normalizeSessions(sessions, fallbackSessionKey) {
371
- const normalized = [];
372
- let totalRounds = 0;
373
- let totalMessages = 0;
374
- for (let si = 0; si < sessions.length; si++) {
375
- const raw = sessions[si];
376
- const sessionKey = raw.sessionKey || fallbackSessionKey || "seed-user";
377
- const sessionId = raw.sessionId || crypto.randomUUID();
378
- const rounds = [];
379
- for (const rawRound of raw.conversations) {
380
- if (!Array.isArray(rawRound))
381
- continue;
382
- const messages = rawRound.map((msg) => ({
383
- role: msg.role,
384
- content: msg.content,
385
- // Normalize timestamp: ISO string → epoch ms, number → pass-through, missing → 0 (filled later)
386
- timestamp: msg.timestamp == null
387
- ? 0
388
- : typeof msg.timestamp === "string"
389
- ? new Date(msg.timestamp).getTime()
390
- : msg.timestamp,
391
- }));
392
- rounds.push({ messages });
393
- totalMessages += messages.length;
394
- }
395
- totalRounds += rounds.length;
396
- normalized.push({
397
- sessionKey,
398
- sessionId,
399
- rounds,
400
- sourceIndex: si,
401
- });
402
- }
403
- return { sessions: normalized, totalRounds, totalMessages };
404
- }
1
+ /**
2
+ * Input loading, validation, normalization, and timestamp handling for the `seed` command.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Load raw JSON from file
6
+ * 2. Detect Format A (`{ sessions: [...] }`) vs Format B (`[...]`)
7
+ * 3. Six-layer validation (file → top-level → session → round → message → timestamp consistency)
8
+ * 4. Normalize into NormalizedInput with auto-generated sessionIds
9
+ * 5. Timestamp all-or-none check + fill strategy
10
+ */
11
+ import fs from "node:fs";
12
+ import crypto from "node:crypto";
13
+ /**
14
+ * Load, validate, and normalize seed input from a file.
15
+ *
16
+ * Throws on fatal validation errors with a human-readable message
17
+ * that includes all collected errors.
18
+ */
19
+ export function loadAndValidateInput(opts) {
20
+ // Layer 1: File — read + parse
21
+ const raw = loadRawInput(opts.input);
22
+ // Layer 2: Top-level — detect A vs B
23
+ const sessions = extractSessions(raw);
24
+ // Layers 3-5: session / round / message validation
25
+ const errors = [];
26
+ validateSessions(sessions, opts.strictRoundRole, errors);
27
+ if (errors.length > 0) {
28
+ throw new SeedValidationError(errors);
29
+ }
30
+ // Layer 6: Timestamp consistency (all-have / all-missing / mixed → error)
31
+ const tsResult = checkTimestampConsistency(sessions);
32
+ if (tsResult.status === "mixed") {
33
+ throw new SeedValidationError([{
34
+ stage: "timestamp_consistency",
35
+ message: "Timestamp consistency check failed: some messages have timestamps while others do not. " +
36
+ "All messages must either have timestamps or none must have timestamps.",
37
+ }]);
38
+ }
39
+ // Normalize
40
+ const normalized = normalizeSessions(sessions, opts.sessionKey);
41
+ return {
42
+ input: {
43
+ sessions: normalized.sessions,
44
+ totalRounds: normalized.totalRounds,
45
+ totalMessages: normalized.totalMessages,
46
+ hasTimestamps: tsResult.status === "all_present",
47
+ },
48
+ needsTimestampConfirmation: tsResult.status === "all_missing",
49
+ };
50
+ }
51
+ /**
52
+ * Validate and normalize seed input from an already-parsed JSON object.
53
+ *
54
+ * This is the gateway-friendly variant of `loadAndValidateInput` — it skips
55
+ * the file-system layer (Layer 1) and accepts the raw parsed body directly.
56
+ * Timestamps missing from all messages are auto-filled (no interactive
57
+ * confirmation needed in HTTP context).
58
+ *
59
+ * Throws `SeedValidationError` on validation failures.
60
+ */
61
+ export function validateAndNormalizeRaw(raw, opts) {
62
+ const strictRoundRole = opts?.strictRoundRole ?? false;
63
+ const autoFillTimestamps = opts?.autoFillTimestamps ?? true;
64
+ // Layer 2: Top-level — detect A vs B
65
+ const sessions = extractSessions(raw);
66
+ // Layers 3-5: session / round / message validation
67
+ const errors = [];
68
+ validateSessions(sessions, strictRoundRole, errors);
69
+ if (errors.length > 0) {
70
+ throw new SeedValidationError(errors);
71
+ }
72
+ // Layer 6: Timestamp consistency
73
+ const tsResult = checkTimestampConsistency(sessions);
74
+ if (tsResult.status === "mixed") {
75
+ throw new SeedValidationError([{
76
+ stage: "timestamp_consistency",
77
+ message: "Timestamp consistency check failed: some messages have timestamps while others do not. " +
78
+ "All messages must either have timestamps or none must have timestamps.",
79
+ }]);
80
+ }
81
+ // Normalize
82
+ const normalized = normalizeSessions(sessions, opts?.sessionKey);
83
+ const input = {
84
+ sessions: normalized.sessions,
85
+ totalRounds: normalized.totalRounds,
86
+ totalMessages: normalized.totalMessages,
87
+ hasTimestamps: tsResult.status === "all_present",
88
+ };
89
+ // Auto-fill timestamps in HTTP context (no interactive prompt)
90
+ if (tsResult.status === "all_missing" && autoFillTimestamps) {
91
+ fillTimestamps(input);
92
+ }
93
+ return input;
94
+ }
95
+ /**
96
+ * Fill timestamps for all messages when the input has no timestamps.
97
+ *
98
+ * Uses a single monotonically increasing counter across ALL sessions
99
+ * to guarantee global timestamp ordering. This is critical when multiple
100
+ * sessions share the same sessionKey — the L0 capture cursor (advanced
101
+ * per-session) would filter out later sessions whose timestamps fall
102
+ * below the cursor if ordering were not globally monotonic.
103
+ */
104
+ export function fillTimestamps(input) {
105
+ let currentTs = Date.now();
106
+ for (const session of input.sessions) {
107
+ for (const round of session.rounds) {
108
+ for (let i = 0; i < round.messages.length; i++) {
109
+ // Small offset per message to maintain strict ordering
110
+ round.messages[i].timestamp = currentTs;
111
+ currentTs += 100;
112
+ }
113
+ }
114
+ }
115
+ input.hasTimestamps = true;
116
+ }
117
+ // ============================
118
+ // Validation error class
119
+ // ============================
120
+ export class SeedValidationError extends Error {
121
+ errors;
122
+ constructor(errors) {
123
+ const summary = errors.map((e) => formatValidationError(e)).join("\n");
124
+ super(`Seed input validation failed (${errors.length} error(s)):\n${summary}`);
125
+ this.name = "SeedValidationError";
126
+ this.errors = errors;
127
+ }
128
+ }
129
+ function formatValidationError(e) {
130
+ const parts = [` [${e.stage}]`];
131
+ if (e.sourceIndex != null)
132
+ parts.push(`session[${e.sourceIndex}]`);
133
+ if (e.sessionKey)
134
+ parts.push(`key="${e.sessionKey}"`);
135
+ if (e.roundIndex != null)
136
+ parts.push(`round[${e.roundIndex}]`);
137
+ if (e.messageIndex != null)
138
+ parts.push(`msg[${e.messageIndex}]`);
139
+ parts.push(e.message);
140
+ return parts.join(" ");
141
+ }
142
+ // ============================
143
+ // Layer 1: File loading
144
+ // ============================
145
+ function loadRawInput(filePath) {
146
+ if (!fs.existsSync(filePath)) {
147
+ throw new SeedValidationError([{
148
+ stage: "file",
149
+ message: `Input file not found: ${filePath}`,
150
+ }]);
151
+ }
152
+ const content = fs.readFileSync(filePath, "utf-8").trim();
153
+ if (!content) {
154
+ throw new SeedValidationError([{
155
+ stage: "file",
156
+ message: "Input file is empty.",
157
+ }]);
158
+ }
159
+ try {
160
+ return JSON.parse(content);
161
+ }
162
+ catch (err) {
163
+ throw new SeedValidationError([{
164
+ stage: "file",
165
+ message: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`,
166
+ }]);
167
+ }
168
+ }
169
+ // ============================
170
+ // Layer 2: Top-level format detection
171
+ // ============================
172
+ function extractSessions(raw) {
173
+ // Format A: { sessions: [...] }
174
+ if (raw != null &&
175
+ typeof raw === "object" &&
176
+ !Array.isArray(raw) &&
177
+ "sessions" in raw) {
178
+ const obj = raw;
179
+ if (!Array.isArray(obj.sessions)) {
180
+ throw new SeedValidationError([{
181
+ stage: "top_level",
182
+ message: 'Format A detected but "sessions" is not an array.',
183
+ }]);
184
+ }
185
+ return obj.sessions;
186
+ }
187
+ // Format B: [...]
188
+ if (Array.isArray(raw)) {
189
+ return raw;
190
+ }
191
+ throw new SeedValidationError([{
192
+ stage: "top_level",
193
+ message: "Unrecognized input format. Expected either:\n" +
194
+ ' Format A: { "sessions": [...] }\n' +
195
+ " Format B: [ { sessionKey, conversations }, ... ]",
196
+ }]);
197
+ }
198
+ // ============================
199
+ // Layers 3-5: session / round / message validation
200
+ // ============================
201
+ function validateSessions(sessions, strictRoundRole, errors) {
202
+ if (sessions.length === 0) {
203
+ errors.push({
204
+ stage: "session",
205
+ message: "No sessions found in input.",
206
+ });
207
+ return;
208
+ }
209
+ for (let si = 0; si < sessions.length; si++) {
210
+ const session = sessions[si];
211
+ // Layer 3: session validation
212
+ if (!session.sessionKey || typeof session.sessionKey !== "string" || session.sessionKey.trim() === "") {
213
+ errors.push({
214
+ stage: "session",
215
+ sourceIndex: si,
216
+ message: '"sessionKey" is required and must be a non-empty string.',
217
+ });
218
+ }
219
+ if (!Array.isArray(session.conversations)) {
220
+ errors.push({
221
+ stage: "session",
222
+ sourceIndex: si,
223
+ sessionKey: session.sessionKey,
224
+ message: '"conversations" must be a two-dimensional array (array of rounds).',
225
+ });
226
+ continue; // Can't validate rounds
227
+ }
228
+ // Check that conversations is a 2D array
229
+ for (let ri = 0; ri < session.conversations.length; ri++) {
230
+ const round = session.conversations[ri];
231
+ // Layer 4: round validation
232
+ if (!Array.isArray(round)) {
233
+ errors.push({
234
+ stage: "round",
235
+ sourceIndex: si,
236
+ sessionKey: session.sessionKey,
237
+ roundIndex: ri,
238
+ message: "Round must be an array of messages.",
239
+ });
240
+ continue;
241
+ }
242
+ if (round.length === 0) {
243
+ errors.push({
244
+ stage: "round",
245
+ sourceIndex: si,
246
+ sessionKey: session.sessionKey,
247
+ roundIndex: ri,
248
+ message: "Round must be a non-empty array.",
249
+ });
250
+ continue;
251
+ }
252
+ // Strict round-role: each round must have at least one user and one assistant
253
+ if (strictRoundRole) {
254
+ const roles = new Set(round.map((m) => m.role));
255
+ if (!roles.has("user")) {
256
+ errors.push({
257
+ stage: "round",
258
+ sourceIndex: si,
259
+ sessionKey: session.sessionKey,
260
+ roundIndex: ri,
261
+ message: '--strict-round-role: round must contain at least one "user" message.',
262
+ });
263
+ }
264
+ if (!roles.has("assistant")) {
265
+ errors.push({
266
+ stage: "round",
267
+ sourceIndex: si,
268
+ sessionKey: session.sessionKey,
269
+ roundIndex: ri,
270
+ message: '--strict-round-role: round must contain at least one "assistant" message.',
271
+ });
272
+ }
273
+ }
274
+ // Layer 5: message validation
275
+ for (let mi = 0; mi < round.length; mi++) {
276
+ const msg = round[mi];
277
+ if (!msg.role || typeof msg.role !== "string") {
278
+ errors.push({
279
+ stage: "message",
280
+ sourceIndex: si,
281
+ sessionKey: session.sessionKey,
282
+ roundIndex: ri,
283
+ messageIndex: mi,
284
+ message: '"role" is required and must be a non-empty string.',
285
+ });
286
+ }
287
+ if (!msg.content || typeof msg.content !== "string" || msg.content.trim() === "") {
288
+ errors.push({
289
+ stage: "message",
290
+ sourceIndex: si,
291
+ sessionKey: session.sessionKey,
292
+ roundIndex: ri,
293
+ messageIndex: mi,
294
+ message: '"content" is required and must be a non-empty string.',
295
+ });
296
+ }
297
+ if (msg.timestamp !== undefined) {
298
+ if (typeof msg.timestamp === "number") {
299
+ if (!Number.isInteger(msg.timestamp)) {
300
+ errors.push({
301
+ stage: "message",
302
+ sourceIndex: si,
303
+ sessionKey: session.sessionKey,
304
+ roundIndex: ri,
305
+ messageIndex: mi,
306
+ message: '"timestamp" must be an integer (epoch milliseconds). Negative values are allowed for dates before 1970.',
307
+ });
308
+ }
309
+ }
310
+ else if (typeof msg.timestamp === "string") {
311
+ if (Number.isNaN(new Date(msg.timestamp).getTime())) {
312
+ errors.push({
313
+ stage: "message",
314
+ sourceIndex: si,
315
+ sessionKey: session.sessionKey,
316
+ roundIndex: ri,
317
+ messageIndex: mi,
318
+ message: `"timestamp" string is not a valid ISO 8601 date: "${msg.timestamp}".`,
319
+ });
320
+ }
321
+ }
322
+ else {
323
+ errors.push({
324
+ stage: "message",
325
+ sourceIndex: si,
326
+ sessionKey: session.sessionKey,
327
+ roundIndex: ri,
328
+ messageIndex: mi,
329
+ message: '"timestamp" must be a number (epoch ms) or an ISO 8601 string.',
330
+ });
331
+ }
332
+ }
333
+ }
334
+ }
335
+ }
336
+ }
337
+ function checkTimestampConsistency(sessions) {
338
+ let hasTs = false;
339
+ let missingTs = false;
340
+ for (const session of sessions) {
341
+ if (!Array.isArray(session.conversations))
342
+ continue;
343
+ for (const round of session.conversations) {
344
+ if (!Array.isArray(round))
345
+ continue;
346
+ for (const msg of round) {
347
+ if (msg.timestamp !== undefined && msg.timestamp !== null) {
348
+ hasTs = true;
349
+ }
350
+ else {
351
+ missingTs = true;
352
+ }
353
+ // Early exit on mixed
354
+ if (hasTs && missingTs) {
355
+ return { status: "mixed" };
356
+ }
357
+ }
358
+ }
359
+ }
360
+ if (hasTs && !missingTs)
361
+ return { status: "all_present" };
362
+ if (!hasTs && missingTs)
363
+ return { status: "all_missing" };
364
+ // No messages at all — treat as all_missing (will be caught by session validation)
365
+ return { status: "all_missing" };
366
+ }
367
+ // ============================
368
+ // Normalization
369
+ // ============================
370
+ function normalizeSessions(sessions, fallbackSessionKey) {
371
+ const normalized = [];
372
+ let totalRounds = 0;
373
+ let totalMessages = 0;
374
+ for (let si = 0; si < sessions.length; si++) {
375
+ const raw = sessions[si];
376
+ const sessionKey = raw.sessionKey || fallbackSessionKey || "seed-user";
377
+ const sessionId = raw.sessionId || crypto.randomUUID();
378
+ const rounds = [];
379
+ for (const rawRound of raw.conversations) {
380
+ if (!Array.isArray(rawRound))
381
+ continue;
382
+ const messages = rawRound.map((msg) => ({
383
+ role: msg.role,
384
+ content: msg.content,
385
+ // Normalize timestamp: ISO string → epoch ms, number → pass-through, missing → 0 (filled later)
386
+ timestamp: msg.timestamp == null
387
+ ? 0
388
+ : typeof msg.timestamp === "string"
389
+ ? new Date(msg.timestamp).getTime()
390
+ : msg.timestamp,
391
+ }));
392
+ rounds.push({ messages });
393
+ totalMessages += messages.length;
394
+ }
395
+ totalRounds += rounds.length;
396
+ normalized.push({
397
+ sessionKey,
398
+ sessionId,
399
+ rounds,
400
+ sourceIndex: si,
401
+ });
402
+ }
403
+ return { sessions: normalized, totalRounds, totalMessages };
404
+ }