@yeaft/webchat-agent 0.1.664 → 0.1.666

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.
@@ -1,101 +0,0 @@
1
- /**
2
- * extract.js — Extract memory-worthy entries from conversation
3
- *
4
- * Called by consolidate.js during the Consolidate lifecycle.
5
- * Uses a single LLM call to identify facts, preferences, skills,
6
- * lessons, contexts, and relations from conversation messages.
7
- *
8
- * Reference: yeaft-unify-core-systems.md §3.1, yeaft-unify-design.md §6.1
9
- */
10
-
11
- import { MEMORY_KINDS } from './store.js';
12
- import { pickEffort } from '../effort.js';
13
-
14
- /**
15
- * Build the extraction prompt.
16
- * @param {object[]} messages — conversation messages to analyze
17
- * @returns {string}
18
- */
19
- function buildExtractionPrompt(messages) {
20
- const conversation = messages.map(m => {
21
- const prefix = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : 'System';
22
- return `[${prefix}]: ${m.content}`;
23
- }).join('\n\n');
24
-
25
- return `Analyze the following conversation and extract any memorable information worth saving to long-term memory.
26
-
27
- For each memory, provide:
28
- - **name**: A short slug-friendly name (e.g., "user-prefers-typescript", "project-uses-vue3")
29
- - **kind**: One of: ${MEMORY_KINDS.join(', ')}
30
- - **scope**: A tree path (e.g., "global", "tech/typescript", "work/project-name")
31
- - **tags**: Relevant keywords as an array
32
- - **importance**: "high", "normal", or "low"
33
- - **content**: 1-3 sentences describing the memory
34
-
35
- Memory kinds explained:
36
- - fact: Objective facts (project structure, tech stack)
37
- - preference: User preferences (coding style, tools)
38
- - skill: How to do something (patterns, techniques)
39
- - lesson: Lessons learned (bugs, pitfalls)
40
- - context: Temporal context (current OKR, progress)
41
- - relation: People and relationships (teammates, roles)
42
-
43
- Do NOT extract:
44
- - Specific code snippets (too large, will become stale)
45
- - Temporary debugging information
46
- - Trivial greetings or small talk
47
-
48
- Return a JSON array of memory objects. If nothing is worth remembering, return an empty array [].
49
-
50
- Conversation:
51
- ${conversation}`;
52
- }
53
-
54
- /**
55
- * Extract memory entries from a set of conversation messages.
56
- *
57
- * @param {{ messages: object[], adapter: object, config: object }} params
58
- * @returns {Promise<object[]>} — extracted memory entries
59
- */
60
- export async function extractMemories({ messages, adapter, config }) {
61
- if (!messages || messages.length === 0) return [];
62
-
63
- const system = 'You are a memory extraction assistant. Analyze conversations and extract important facts, preferences, and lessons. Return ONLY a valid JSON array, no other text.';
64
-
65
- const extractionPrompt = buildExtractionPrompt(messages);
66
-
67
- try {
68
- const result = await adapter.call({
69
- model: config.model,
70
- system,
71
- messages: [{ role: 'user', content: extractionPrompt }],
72
- maxTokens: 2048,
73
- // task-327c: extract runs inside the consolidate pipeline — the
74
- // JSON-structured output benefits from the same 'max' thinking tier.
75
- effort: pickEffort({ scenario: 'consolidate' }),
76
- });
77
-
78
- const text = result.text.trim();
79
-
80
- // Try to parse JSON array from the response
81
- const jsonMatch = text.match(/\[[\s\S]*\]/);
82
- if (!jsonMatch) return [];
83
-
84
- const entries = JSON.parse(jsonMatch[0]);
85
-
86
- // Validate and normalize entries
87
- return entries
88
- .filter(e => e && typeof e === 'object' && e.name && e.content)
89
- .map(e => ({
90
- name: String(e.name).slice(0, 80),
91
- kind: MEMORY_KINDS.includes(e.kind) ? e.kind : 'fact',
92
- scope: String(e.scope || 'global'),
93
- tags: Array.isArray(e.tags) ? e.tags.map(String) : [],
94
- importance: ['high', 'normal', 'low'].includes(e.importance) ? e.importance : 'normal',
95
- content: String(e.content),
96
- }));
97
- } catch {
98
- // LLM failure — return empty (non-critical operation)
99
- return [];
100
- }
101
- }
@@ -1,358 +0,0 @@
1
- /**
2
- * layout.js — New-layout memory file helpers (tool-on-demand model)
3
- *
4
- * Directory layout:
5
- * ~/.yeaft/memory/
6
- * index.md — classification catalog (always injected into system prompt)
7
- * user-preferences.md — merged user preferences (default-injected)
8
- * entries/*.md — atomic entries (existing layout, read by memory_query)
9
- * by-project/<slug>.md — per-project narrative summaries
10
- * by-topic/<slug>.md — per-topic narrative summaries
11
- * timeline/<YYYY-MM>.md — monthly narrative digests
12
- *
13
- * Design:
14
- * - index.md is a single human-readable file listing all classification files
15
- * with a one-line summary and entry count per section.
16
- * - Aggregate files (by-project / by-topic / timeline) are narrative prose,
17
- * not raw entry concat — produced by Dream.
18
- * - user-preferences.md is a deduped accumulation of preferences extracted
19
- * from conversations.
20
- * - Project header match: basename(cwd) is matched against by-project/<slug>.md
21
- * filenames (case-insensitive substring match either direction).
22
- *
23
- * This module lives alongside store.js; it does NOT replace MemoryStore.
24
- * Old MEMORY.md / scopes.md continue to exist for backward compatibility
25
- * but are no longer maintained by Dream after this refactor.
26
- */
27
-
28
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs';
29
- import { basename, join } from 'path';
30
- import { isPermissionError } from '../init.js';
31
-
32
- // ─── Constants ──────────────────────────────────────────────
33
-
34
- /** Classification category dirnames. */
35
- export const CATEGORY_DIRS = ['by-project', 'by-topic', 'timeline'];
36
-
37
- /** Classification single-file names living directly under memory/. */
38
- export const SINGLE_FILES = ['index.md', 'user-preferences.md'];
39
-
40
- /** Approximate character budget for prompt injection (~1.5k tokens). */
41
- export const PROMPT_INJECTION_CHAR_BUDGET = 6000;
42
-
43
- /** Character budget for project header excerpt (~300 tokens). */
44
- export const PROJECT_HEADER_CHAR_BUDGET = 1200;
45
-
46
- // ─── Path helpers ───────────────────────────────────────────
47
-
48
- /**
49
- * @param {string} yeaftDir — e.g. ~/.yeaft
50
- * @returns {string} — absolute path to memory root
51
- */
52
- export function memoryDir(yeaftDir) {
53
- return join(yeaftDir, 'memory');
54
- }
55
-
56
- /**
57
- * Ensure the new-layout directory skeleton exists (idempotent).
58
- * @param {string} yeaftDir
59
- */
60
- export function ensureLayout(yeaftDir) {
61
- const root = memoryDir(yeaftDir);
62
- const dirs = [root, ...CATEGORY_DIRS.map(d => join(root, d)), join(root, 'entries')];
63
- for (const d of dirs) {
64
- try {
65
- if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
66
- } catch (err) {
67
- if (!isPermissionError(err)) throw err;
68
- }
69
- }
70
- }
71
-
72
- // ─── File I/O ───────────────────────────────────────────────
73
-
74
- /**
75
- * Read a file under memory/ by relative path. Returns '' if missing.
76
- * @param {string} yeaftDir
77
- * @param {string} relPath — e.g. 'index.md', 'by-project/foo.md'
78
- * @returns {string}
79
- */
80
- export function readMemoryFile(yeaftDir, relPath) {
81
- const fp = join(memoryDir(yeaftDir), relPath);
82
- if (!existsSync(fp)) return '';
83
- try {
84
- return readFileSync(fp, 'utf8');
85
- } catch (err) {
86
- if (isPermissionError(err)) return '';
87
- throw err;
88
- }
89
- }
90
-
91
- /**
92
- * Write a file under memory/ by relative path. Creates parent dir if needed.
93
- * @param {string} yeaftDir
94
- * @param {string} relPath
95
- * @param {string} content
96
- */
97
- export function writeMemoryFile(yeaftDir, relPath, content) {
98
- ensureLayout(yeaftDir);
99
- const fp = join(memoryDir(yeaftDir), relPath);
100
- try {
101
- writeFileSync(fp, content, { encoding: 'utf8', mode: 0o644 });
102
- } catch (err) {
103
- if (!isPermissionError(err)) throw err;
104
- }
105
- }
106
-
107
- /**
108
- * List all classification files (relative paths) that currently exist.
109
- * Includes single files (index.md, user-preferences.md) and per-category files.
110
- *
111
- * @param {string} yeaftDir
112
- * @returns {{ path: string, size: number }[]}
113
- */
114
- export function listClassificationFiles(yeaftDir) {
115
- const root = memoryDir(yeaftDir);
116
- const out = [];
117
-
118
- for (const f of SINGLE_FILES) {
119
- const fp = join(root, f);
120
- if (existsSync(fp)) {
121
- try {
122
- out.push({ path: f, size: statSync(fp).size });
123
- } catch { /* ignore */ }
124
- }
125
- }
126
-
127
- for (const dir of CATEGORY_DIRS) {
128
- const dp = join(root, dir);
129
- if (!existsSync(dp)) continue;
130
- try {
131
- for (const f of readdirSync(dp)) {
132
- if (!f.endsWith('.md')) continue;
133
- const fp = join(dp, f);
134
- try {
135
- out.push({ path: `${dir}/${f}`, size: statSync(fp).size });
136
- } catch { /* ignore */ }
137
- }
138
- } catch { /* ignore */ }
139
- }
140
-
141
- return out;
142
- }
143
-
144
- // ─── Project header matching ────────────────────────────────
145
-
146
- /**
147
- * Given a cwd, find the best-matching by-project/<slug>.md filename.
148
- * Rule: case-insensitive substring match between basename(cwd) and slug
149
- * (either direction). Returns the first match by sort order, or null.
150
- *
151
- * @param {string} yeaftDir
152
- * @param {string} cwd
153
- * @returns {string|null} — relative path like 'by-project/claude-web-chat.md', or null
154
- */
155
- export function findProjectFile(yeaftDir, cwd) {
156
- if (!cwd) return null;
157
- const base = basename(cwd).toLowerCase();
158
- if (!base) return null;
159
-
160
- const dir = join(memoryDir(yeaftDir), 'by-project');
161
- if (!existsSync(dir)) return null;
162
-
163
- let files;
164
- try {
165
- files = readdirSync(dir).filter(f => f.endsWith('.md')).sort();
166
- } catch {
167
- return null;
168
- }
169
-
170
- for (const f of files) {
171
- const slug = f.slice(0, -3).toLowerCase();
172
- if (slug === base) return `by-project/${f}`;
173
- }
174
- for (const f of files) {
175
- const slug = f.slice(0, -3).toLowerCase();
176
- if (slug.includes(base) || base.includes(slug)) return `by-project/${f}`;
177
- }
178
-
179
- return null;
180
- }
181
-
182
- /**
183
- * Return the leading excerpt of a file capped at charBudget.
184
- * Trims to the last complete line boundary to avoid mid-line cutoff.
185
- *
186
- * @param {string} text
187
- * @param {number} charBudget
188
- * @returns {string}
189
- */
190
- export function excerpt(text, charBudget) {
191
- if (!text) return '';
192
- if (text.length <= charBudget) return text;
193
- const cut = text.slice(0, charBudget);
194
- const lastNl = cut.lastIndexOf('\n');
195
- return lastNl > charBudget * 0.6 ? cut.slice(0, lastNl) : cut;
196
- }
197
-
198
- // ─── Index rendering ────────────────────────────────────────
199
-
200
- /**
201
- * Render an auto-generated index.md from the current on-disk layout.
202
- *
203
- * Format:
204
- * # Memory Index
205
- *
206
- * ## Single files
207
- * - user-preferences.md (NNN bytes) — user-written/Dream-merged preferences
208
- *
209
- * ## by-project
210
- * - by-project/foo.md (NNN bytes)
211
- * - by-project/bar.md (NNN bytes)
212
- *
213
- * ## by-topic ...
214
- * ## timeline ...
215
- * ## entries
216
- * - N atomic entries (use memory_query to search)
217
- *
218
- * One-line summaries are pulled from the first non-empty, non-heading line
219
- * of each file.
220
- *
221
- * @param {string} yeaftDir
222
- * @param {number} entryCount — number of atomic entries (from MemoryStore)
223
- * @returns {string}
224
- */
225
- export function renderIndex(yeaftDir, entryCount) {
226
- const root = memoryDir(yeaftDir);
227
- const lines = ['# Memory Index', ''];
228
-
229
- // Single files section
230
- const singleLines = [];
231
- for (const f of SINGLE_FILES) {
232
- if (f === 'index.md') continue;
233
- const fp = join(root, f);
234
- if (!existsSync(fp)) continue;
235
- const summary = firstSummary(fp);
236
- const size = safeSize(fp);
237
- singleLines.push(`- ${f} (${size} bytes)${summary ? ` — ${summary}` : ''}`);
238
- }
239
- if (singleLines.length) {
240
- lines.push('## Single files', '', ...singleLines, '');
241
- }
242
-
243
- // Category sections
244
- for (const cat of CATEGORY_DIRS) {
245
- const dp = join(root, cat);
246
- if (!existsSync(dp)) continue;
247
- let files;
248
- try {
249
- files = readdirSync(dp).filter(f => f.endsWith('.md')).sort();
250
- } catch {
251
- continue;
252
- }
253
- if (!files.length) continue;
254
- lines.push(`## ${cat}`, '');
255
- for (const f of files) {
256
- const fp = join(dp, f);
257
- const summary = firstSummary(fp);
258
- const size = safeSize(fp);
259
- lines.push(`- ${cat}/${f} (${size} bytes)${summary ? ` — ${summary}` : ''}`);
260
- }
261
- lines.push('');
262
- }
263
-
264
- // Entries section (count only — atomic entries are searched via memory_query)
265
- lines.push('## entries', '', `- ${entryCount} atomic entries (use memory_query to search)`, '');
266
-
267
- lines.push(
268
- '_Note: use the `memory_load` tool with one or more paths to load a classification',
269
- 'file in full, or `memory_query` to search atomic entries by keywords/tags._',
270
- );
271
-
272
- return lines.join('\n') + '\n';
273
- }
274
-
275
- /**
276
- * First non-heading non-empty line of a file, trimmed to 120 chars.
277
- */
278
- function firstSummary(fp) {
279
- try {
280
- const raw = readFileSync(fp, 'utf8');
281
- for (const line of raw.split('\n')) {
282
- const s = line.trim();
283
- if (!s) continue;
284
- if (s.startsWith('#')) continue;
285
- if (s.startsWith('---')) continue;
286
- return s.length > 120 ? s.slice(0, 120) + '…' : s;
287
- }
288
- } catch { /* ignore */ }
289
- return '';
290
- }
291
-
292
- function safeSize(fp) {
293
- try {
294
- return statSync(fp).size;
295
- } catch {
296
- return 0;
297
- }
298
- }
299
-
300
- // ─── Prompt injection builder ───────────────────────────────
301
-
302
- /**
303
- * Build the memory section to inject into the system prompt every turn.
304
- *
305
- * Content, in order:
306
- * 1. index.md (full text — auto-regenerated when missing/stale)
307
- * 2. user-preferences.md (full text)
308
- * 3. Project header excerpt (first PROJECT_HEADER_CHAR_BUDGET chars of
309
- * the matching by-project/<slug>.md, if cwd matches)
310
- *
311
- * Total output is capped at PROMPT_INJECTION_CHAR_BUDGET; later sections
312
- * are dropped first if over budget.
313
- *
314
- * @param {{
315
- * yeaftDir: string,
316
- * cwd?: string,
317
- * entryCount?: number,
318
- * language?: 'en' | 'zh',
319
- * }} params
320
- * @returns {string}
321
- */
322
- export function buildMemoryInjection({ yeaftDir, cwd, entryCount = 0, language = 'en' }) {
323
- if (!yeaftDir) return '';
324
-
325
- const heading = language === 'zh' ? '## 记忆索引' : '## Memory Index';
326
- const prefHeading = language === 'zh' ? '## 用户偏好' : '## User Preferences';
327
- const projectHeading = language === 'zh' ? '## 当前项目摘要' : '## Current Project Summary';
328
-
329
- let indexText = readMemoryFile(yeaftDir, 'index.md');
330
- if (!indexText.trim()) {
331
- // Auto-generate an index on the fly so the LLM always sees something useful.
332
- indexText = renderIndex(yeaftDir, entryCount);
333
- }
334
-
335
- const prefText = readMemoryFile(yeaftDir, 'user-preferences.md');
336
-
337
- let projectText = '';
338
- const projectRel = cwd ? findProjectFile(yeaftDir, cwd) : null;
339
- if (projectRel) {
340
- projectText = excerpt(readMemoryFile(yeaftDir, projectRel), PROJECT_HEADER_CHAR_BUDGET);
341
- }
342
-
343
- const sections = [];
344
- sections.push(`${heading}\n${indexText.trim()}`);
345
- if (prefText.trim()) sections.push(`${prefHeading}\n${prefText.trim()}`);
346
- if (projectText.trim()) sections.push(`${projectHeading} (${projectRel})\n${projectText.trim()}`);
347
-
348
- // Enforce total char budget — drop from the end.
349
- let combined = sections.join('\n\n');
350
- while (combined.length > PROMPT_INJECTION_CHAR_BUDGET && sections.length > 1) {
351
- sections.pop();
352
- combined = sections.join('\n\n');
353
- }
354
- if (combined.length > PROMPT_INJECTION_CHAR_BUDGET) {
355
- combined = excerpt(combined, PROMPT_INJECTION_CHAR_BUDGET);
356
- }
357
- return combined;
358
- }
@@ -1,166 +0,0 @@
1
- /**
2
- * schema.js — task-334f R6 Memory schema constants.
3
- *
4
- * References:
5
- * §Δ22 Memory 本质定性 (6 公理)
6
- * §Δ23 Memory Entry Schema 扩字段
7
- * §Δ25 Shard 语义初始分类
8
- * §Δ26.3 Shard 软上限
9
- *
10
- * This module is **data only** — no I/O, no LLM calls. It is safe to import
11
- * from any layer (store, recall, tools) without circular hazards.
12
- */
13
-
14
- // ─── §Δ25.1 VP-memory default shard set ─────────────────────────
15
- export const VP_DEFAULT_SHARDS = Object.freeze([
16
- 'skill',
17
- 'relations',
18
- 'lessons',
19
- 'preferences',
20
- ]);
21
-
22
- // ─── §Δ25.2 Feature-memory fixed 5 shards ───────────────────────
23
- export const FEATURE_SHARDS = Object.freeze([
24
- 'decision',
25
- 'progress',
26
- 'context',
27
- 'blocker',
28
- 'artifact',
29
- ]);
30
-
31
- // ─── §Δ25.3 User-memory default shard set ───────────────────────
32
- export const USER_SHARDS = Object.freeze([
33
- 'profile',
34
- 'preferences',
35
- 'projects',
36
- 'goals',
37
- 'relations',
38
- ]);
39
-
40
- /**
41
- * §Δ26.3 soft-cap table. `project-<slug>` is matched via the dedicated
42
- * helper `softCapFor()` below because its key is dynamic.
43
- *
44
- * Shape: { entries: number, bytes: number }
45
- */
46
- export const SOFT_CAPS = Object.freeze({
47
- // VP
48
- skill: { entries: 80, bytes: 64 * 1024 },
49
- lessons: { entries: 80, bytes: 64 * 1024 },
50
- preferences: { entries: 80, bytes: 64 * 1024 },
51
- relations: { entries: 50, bytes: 32 * 1024 },
52
- // Feature (Δ26.3 feature-memory row)
53
- decision: { entries: 40, bytes: 24 * 1024 },
54
- progress: { entries: 40, bytes: 24 * 1024 },
55
- context: { entries: 40, bytes: 24 * 1024 },
56
- blocker: { entries: 40, bytes: 24 * 1024 },
57
- artifact: { entries: 40, bytes: 24 * 1024 },
58
- // User (Δ26.3 user-memory row — 60 entries / 48 KiB per shard)
59
- profile: { entries: 60, bytes: 48 * 1024 },
60
- projects: { entries: 60, bytes: 48 * 1024 },
61
- goals: { entries: 60, bytes: 48 * 1024 },
62
- });
63
-
64
- /** Project shards are dynamic: `project-<slug>` → 150 entries / 128 KiB. */
65
- export const PROJECT_SHARD_SOFT_CAP = Object.freeze({
66
- entries: 150,
67
- bytes: 128 * 1024,
68
- });
69
-
70
- /** Default cap if callers ask for a shard not in the canonical set. */
71
- export const DEFAULT_SOFT_CAP = Object.freeze({
72
- entries: 80,
73
- bytes: 64 * 1024,
74
- });
75
-
76
- /**
77
- * Trigger threshold: when a dream sweep finds ≥ PROJECT_DERIVE_THRESHOLD
78
- * memory entries tagged with the same groupId, it may derive a
79
- * `project-<slug>` shard. (§Δ25.1)
80
- */
81
- export const PROJECT_DERIVE_THRESHOLD = 30;
82
-
83
- /**
84
- * Max number of VP-memory shards — re-compression kicks in when exceeded
85
- * (§Δ25.1: "最大 shard 数软上限 12").
86
- */
87
- export const MAX_VP_SHARDS = 12;
88
-
89
- /**
90
- * Return the soft cap for a given shard name.
91
- * Handles the dynamic `project-<slug>` case.
92
- */
93
- export function softCapFor(shardName) {
94
- if (typeof shardName !== 'string' || !shardName) return DEFAULT_SOFT_CAP;
95
- if (shardName.startsWith('project-')) return PROJECT_SHARD_SOFT_CAP;
96
- return SOFT_CAPS[shardName] || DEFAULT_SOFT_CAP;
97
- }
98
-
99
- /**
100
- * Build a schema object suitable for `openShardStore(dir, schema)` (334o).
101
- *
102
- * @param {'vp'|'feature'|'user'} kind
103
- * @param {{ extraShards?: string[] }} [opts] e.g. existing project shards
104
- * @returns {{ shards: string[], softCap: Record<string,{entries:number,bytes:number}>, defaultSoftCap: object }}
105
- */
106
- export function buildShardSchema(kind, opts = {}) {
107
- let shards;
108
- switch (kind) {
109
- case 'vp': shards = [...VP_DEFAULT_SHARDS]; break;
110
- case 'feature': shards = [...FEATURE_SHARDS]; break;
111
- case 'user': shards = [...USER_SHARDS]; break;
112
- default: throw new Error(`buildShardSchema: unknown kind "${kind}"`);
113
- }
114
- if (Array.isArray(opts.extraShards)) {
115
- for (const s of opts.extraShards) {
116
- if (typeof s === 'string' && s && !shards.includes(s)) shards.push(s);
117
- }
118
- }
119
- const softCap = {};
120
- for (const s of shards) softCap[s] = softCapFor(s);
121
- return {
122
- shards,
123
- softCap,
124
- defaultSoftCap: DEFAULT_SOFT_CAP,
125
- };
126
- }
127
-
128
- /**
129
- * R6 memory-entry authored-by enum (§Δ23).
130
- * Free-form strings are allowed; these are canonical examples.
131
- */
132
- export const AUTHORED_BY = Object.freeze({
133
- VP: (vpId) => `vp:${vpId}`,
134
- USER: (uid) => `user:${uid}`,
135
- SUMMARY: 'system:summary-extractor',
136
- DREAM: 'system:dream',
137
- });
138
-
139
- /**
140
- * Validate an R6 entry shape (schema-level, no I/O).
141
- * Throws on structural violation. Callers that want soft warnings should
142
- * wrap in try/catch.
143
- */
144
- export function validateR6Entry(entry) {
145
- if (!entry || typeof entry !== 'object') throw new Error('entry must be an object');
146
- if (!entry.id || typeof entry.id !== 'string') throw new Error('entry.id required');
147
- if (!entry.shard || typeof entry.shard !== 'string') throw new Error('entry.shard required');
148
- if (!entry.kind || typeof entry.kind !== 'string') throw new Error('entry.kind required');
149
- // sourceRef is required except for identity/preference pure-declaration entries.
150
- const needsSourceRef = !(entry.kind === 'identity' || entry.kind === 'preference');
151
- if (needsSourceRef) {
152
- if (!entry.sourceRef || typeof entry.sourceRef !== 'object') {
153
- throw new Error('entry.sourceRef required for kind=' + entry.kind);
154
- }
155
- if (!Array.isArray(entry.sourceRef.msgIds) || entry.sourceRef.msgIds.length === 0) {
156
- throw new Error('entry.sourceRef.msgIds required (non-empty array)');
157
- }
158
- }
159
- if (entry.supersedes != null && !Array.isArray(entry.supersedes)) {
160
- throw new Error('entry.supersedes must be an array when present');
161
- }
162
- if (entry.supersededBy != null && typeof entry.supersededBy !== 'string') {
163
- throw new Error('entry.supersededBy must be a string when present');
164
- }
165
- return true;
166
- }