@yeaft/webchat-agent 0.1.531 → 0.1.533

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.531",
3
+ "version": "0.1.533",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -310,6 +310,9 @@ export class Engine {
310
310
  memoryInjection,
311
311
  compactSummary,
312
312
  skillContent,
313
+ // task-334f: memory_trace tool is now registered (49 → 51 tools), so
314
+ // unlock the core_memory meta-line behind 334e's feature flag.
315
+ memoryTraceAvailable: true,
313
316
  });
314
317
  }
315
318
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * migrate-r5-to-r6.js — task-334f §Δ23 migration stub.
3
+ *
4
+ * Legacy (R5): `~/.yeaft/memory/entries/<slug>.md` plus numeric shard files
5
+ * `memory-001.md`, `memory-002.md`, ...
6
+ *
7
+ * R6: `~/.yeaft/memory/vp/<vpId>/memory-<semantic>.md`
8
+ * Semantic shards: skill / relations / lessons / preferences /
9
+ * project-<slug>
10
+ *
11
+ * This slice (334f) only DEFINES the API surface and a dry-run classifier.
12
+ * The actual batch migration runs in 334i; 334f does not mutate disk.
13
+ */
14
+
15
+ import { existsSync, readdirSync, readFileSync } from 'fs';
16
+ import { join } from 'path';
17
+ import { parseEntry } from './store.js';
18
+ import { classifyLegacyEntryToShard } from './shard-store.js';
19
+
20
+ /**
21
+ * Produce a migration plan without applying it.
22
+ *
23
+ * @param {string} legacyEntriesDir e.g. ~/.yeaft/memory/entries
24
+ * @returns {{
25
+ * totalEntries: number,
26
+ * plan: Array<{ slug: string, shard: string, kind: string, tags: string[] }>,
27
+ * byShard: Record<string, number>
28
+ * }}
29
+ */
30
+ export function planR5ToR6Migration(legacyEntriesDir) {
31
+ if (!legacyEntriesDir || !existsSync(legacyEntriesDir)) {
32
+ return { totalEntries: 0, plan: [], byShard: {} };
33
+ }
34
+ const files = readdirSync(legacyEntriesDir).filter(f => f.endsWith('.md'));
35
+ const plan = [];
36
+ const byShard = {};
37
+ for (const file of files) {
38
+ const raw = readFileSync(join(legacyEntriesDir, file), 'utf8');
39
+ const entry = parseEntry(raw);
40
+ if (!entry) continue;
41
+ const shard = classifyLegacyEntryToShard(entry);
42
+ plan.push({
43
+ slug: file.replace(/\.md$/, ''),
44
+ shard,
45
+ kind: entry.kind || 'fact',
46
+ tags: entry.tags || [],
47
+ });
48
+ byShard[shard] = (byShard[shard] || 0) + 1;
49
+ }
50
+ return { totalEntries: plan.length, plan, byShard };
51
+ }
52
+
53
+ /**
54
+ * Apply the migration. STUB — 334i will fill in the body writer. 334f keeps
55
+ * this function exported so downstream tests can assert the hook exists.
56
+ *
57
+ * @param {object} _opts { legacyEntriesDir, targetDir, vpId, dryRun }
58
+ */
59
+ export async function applyR5ToR6Migration(_opts) {
60
+ throw new Error('applyR5ToR6Migration: not yet implemented (task-334i)');
61
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * recall-r6.js — task-334f R6 4-step recall pipeline (§Δ24.2).
3
+ *
4
+ * Pipeline:
5
+ * Step 1: Shard classifier — fastModel OR keyword heuristic → top-1~2 shards
6
+ * Step 2: In-shard candidate — scan only the selected shards, filter by kind/tags/pinned
7
+ * Step 3: LLM rerank — pick top-7 from candidates
8
+ * Step 4: Inject body only — return entries without sourceRef (§Δ23)
9
+ *
10
+ * The legacy R5 recall (agent/unify/memory/recall.js) remains for back-compat
11
+ * of callers still on MemoryStore. R6 callers should use `recallR6()` below.
12
+ */
13
+
14
+ import { createHash } from 'crypto';
15
+ import { pickEffort } from '../effort.js';
16
+ import {
17
+ VP_DEFAULT_SHARDS,
18
+ TASK_SHARDS,
19
+ USER_SHARDS,
20
+ } from './schema.js';
21
+
22
+ const MAX_RECALL_RESULTS = 7;
23
+ const MAX_CANDIDATES = 15;
24
+
25
+ // ─── Step 1: Shard Classifier ───────────────────────────────────
26
+
27
+ /**
28
+ * Keyword → shard heuristic. Zero-cost fallback when fastModel is unavailable.
29
+ *
30
+ * The lexicons are intentionally small: classifier output only needs to point
31
+ * at the most likely shard; LLM rerank in Step 3 catches misses. Empirically
32
+ * this covers > 70% of queries with zero LLM cost.
33
+ */
34
+ const SHARD_LEXICON = {
35
+ skill: ['code', 'api', 'library', 'framework', 'implement', 'debug', 'syntax', 'typescript', 'vue', 'pattern', '代码', '实现', '调试', '语法', '模式', '技术'],
36
+ lessons: ['mistake', 'avoid', 'lesson', 'pitfall', 'gotcha', 'bug', 'regression', '教训', '避坑', '坑', '踩坑', '反模式'],
37
+ preferences: ['prefer', 'like', 'style', 'convention', 'favorite', '偏好', '风格', '习惯', '喜欢'],
38
+ relations: ['colleague', 'partner', 'team', 'user', 'collaborator', 'vp', '同事', '队友', '协作', '关系'],
39
+ // Task-memory shards
40
+ decision: ['decide', 'decision', 'chose', 'picked', 'resolved', '决定', '决策', '选择'],
41
+ progress: ['done', 'progress', 'milestone', 'shipped', 'finished', '完成', '进度', '交付'],
42
+ context: ['background', 'context', 'requirement', 'scope', '背景', '需求', '范围'],
43
+ blocker: ['block', 'stuck', 'blocker', 'issue', 'waiting', '阻塞', '卡住', '等待'],
44
+ artifact: ['pr', 'commit', 'doc', 'file', 'link', 'artifact', 'deliverable', 'commit', '产出', '文档'],
45
+ // User-memory shards
46
+ profile: ['name', 'role', 'background', 'who', 'identity', '身份', '角色', '背景'],
47
+ projects: ['project', 'repo', '项目', '仓库'],
48
+ goals: ['goal', 'target', 'okr', 'plan', '目标', '计划'],
49
+ };
50
+
51
+ /**
52
+ * Pick top-N shards via keyword heuristic.
53
+ * @param {string} prompt
54
+ * @param {string[]} availableShards all shards present in this store
55
+ * @param {number} [topN=2]
56
+ */
57
+ export function classifyShardsByKeyword(prompt, availableShards, topN = 2) {
58
+ if (!prompt || !availableShards || availableShards.length === 0) return [];
59
+ const lower = prompt.toLowerCase();
60
+ const scores = new Map();
61
+ for (const shard of availableShards) {
62
+ // project-<slug> shards score via their slug
63
+ const lex = SHARD_LEXICON[shard] || [];
64
+ let score = 0;
65
+ for (const kw of lex) {
66
+ if (lower.includes(kw)) score += 1;
67
+ }
68
+ if (shard.startsWith('project-')) {
69
+ const slug = shard.slice('project-'.length);
70
+ if (slug && lower.includes(slug.toLowerCase())) score += 3;
71
+ }
72
+ if (score > 0) scores.set(shard, score);
73
+ }
74
+ const ranked = [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
75
+ if (ranked.length >= 1) return ranked.slice(0, topN);
76
+ // Fallback: first N defaults in the shard set
77
+ return availableShards.slice(0, topN);
78
+ }
79
+
80
+ /**
81
+ * Shard classifier — calls fastModel if adapter provided, else falls back to
82
+ * the keyword heuristic. Budget < 200 tokens (§Δ24.2).
83
+ */
84
+ export async function classifyShards({
85
+ prompt,
86
+ availableShards,
87
+ adapter,
88
+ fastModel,
89
+ topN = 2,
90
+ }) {
91
+ if (!adapter || !fastModel) {
92
+ return classifyShardsByKeyword(prompt, availableShards, topN);
93
+ }
94
+ const system = `You classify user queries into memory shards. Return ONLY a JSON array of up to ${topN} shard names from the provided list. No prose.`;
95
+ const user = `Available shards: ${JSON.stringify(availableShards)}
96
+ Query: ${JSON.stringify(prompt)}
97
+ Return JSON array of up to ${topN} most relevant shard names.`;
98
+ try {
99
+ const res = await adapter.call({
100
+ model: fastModel,
101
+ system,
102
+ messages: [{ role: 'user', content: user }],
103
+ maxTokens: 64,
104
+ effort: pickEffort({ scenario: 'recall' }),
105
+ });
106
+ const m = (res.text || '').match(/\[[\s\S]*?\]/);
107
+ if (!m) return classifyShardsByKeyword(prompt, availableShards, topN);
108
+ const arr = JSON.parse(m[0]);
109
+ const valid = arr.filter(s => typeof s === 'string' && availableShards.includes(s));
110
+ if (valid.length === 0) return classifyShardsByKeyword(prompt, availableShards, topN);
111
+ return valid.slice(0, topN);
112
+ } catch {
113
+ return classifyShardsByKeyword(prompt, availableShards, topN);
114
+ }
115
+ }
116
+
117
+ // ─── Step 2: In-shard candidate generation ──────────────────────
118
+
119
+ function collectCandidates(memoryShardStore, { shards, kind, tags, pinned }) {
120
+ const filter = {};
121
+ if (shards && shards.length) filter.shard = shards.length === 1 ? shards[0] : shards;
122
+ if (kind) filter.kind = kind;
123
+ if (tags && tags.length) filter.tags = tags;
124
+ if (pinned !== undefined) filter.pinned = pinned;
125
+ const { results } = memoryShardStore.query(filter);
126
+ return results
127
+ // Drop superseded entries from the candidate pool (they stay on disk
128
+ // for memory_trace but should not compete for recall slots).
129
+ .filter(rec => !rec.supersededBy)
130
+ .slice(0, MAX_CANDIDATES);
131
+ }
132
+
133
+ // ─── Step 3: LLM Rerank ─────────────────────────────────────────
134
+
135
+ async function llmRerank({ adapter, fastModel, prompt, candidates, memoryShardStore }) {
136
+ if (candidates.length <= MAX_RECALL_RESULTS) {
137
+ return candidates.map(c => c.id);
138
+ }
139
+ const lines = candidates.map((c, i) => {
140
+ return `${i + 1}. [id=${c.id}] shard=${c.shard} kind=${c.kind || '?'} tags=[${(c.tags || []).join(',')}]`;
141
+ }).join('\n');
142
+ const system = `You pick the most relevant memories for the user's prompt. Return ONLY a JSON array of entry ids (up to ${MAX_RECALL_RESULTS}). No prose.`;
143
+ const user = `User prompt: ${JSON.stringify(prompt)}
144
+
145
+ Candidate memories:
146
+ ${lines}
147
+
148
+ Return JSON array of up to ${MAX_RECALL_RESULTS} ids.`;
149
+ try {
150
+ const res = await adapter.call({
151
+ model: fastModel,
152
+ system,
153
+ messages: [{ role: 'user', content: user }],
154
+ maxTokens: 256,
155
+ effort: pickEffort({ scenario: 'recall' }),
156
+ });
157
+ const m = (res.text || '').match(/\[[\s\S]*\]/);
158
+ if (!m) return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
159
+ const arr = JSON.parse(m[0]).filter(x => typeof x === 'string');
160
+ const valid = arr.filter(id => candidates.find(c => c.id === id));
161
+ if (valid.length === 0) return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
162
+ return valid.slice(0, MAX_RECALL_RESULTS);
163
+ } catch {
164
+ return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.id);
165
+ }
166
+ }
167
+
168
+ // ─── Step 4: Inject (no sourceRef) ──────────────────────────────
169
+
170
+ /**
171
+ * Produce the body-only injection payload (§Δ24.5). Prefix lines with
172
+ * `[mem:<shard>]` so the LLM knows the category without seeing the id.
173
+ */
174
+ export function formatForInjection(entries) {
175
+ return entries.map(e => {
176
+ const prefix = `[mem:${e.shard}]`;
177
+ const body = (e.body || '').trim();
178
+ return `${prefix} ${body}`;
179
+ }).join('\n\n');
180
+ }
181
+
182
+ // ─── Fingerprint cache ──────────────────────────────────────────
183
+
184
+ const _cache = new Map();
185
+ const CACHE_TTL = 5 * 60 * 1000;
186
+
187
+ function computeFingerprint({ shards, prompt, taskId }) {
188
+ const head = prompt.slice(0, 200);
189
+ const input = `${shards.join(',')}|${head}|${taskId || ''}`;
190
+ return createHash('sha256').update(input).digest('hex').slice(0, 16);
191
+ }
192
+
193
+ // ─── Entry point ────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Run the R6 4-step recall pipeline.
197
+ *
198
+ * @param {{
199
+ * prompt: string,
200
+ * memoryShardStore: object, // openMemoryShardStore() handle
201
+ * adapter?: object, // LLM adapter (null = keyword-only)
202
+ * fastModel?: string, // fast model for classifier + rerank
203
+ * availableShards?: string[], // defaults to store stats
204
+ * taskId?: string,
205
+ * kind?: string, tags?: string[], pinned?: boolean,
206
+ * }} params
207
+ * @returns {Promise<{entries: object[], shards: string[], fingerprint: string, cached: boolean}>}
208
+ */
209
+ export async function recallR6(params) {
210
+ const {
211
+ prompt,
212
+ memoryShardStore,
213
+ adapter,
214
+ fastModel,
215
+ availableShards,
216
+ taskId,
217
+ kind,
218
+ tags,
219
+ pinned,
220
+ } = params;
221
+
222
+ if (!prompt || !prompt.trim() || !memoryShardStore) {
223
+ return { entries: [], shards: [], fingerprint: '', cached: false };
224
+ }
225
+
226
+ const shardsFromStore = availableShards
227
+ || Object.keys(memoryShardStore.stats().shards);
228
+
229
+ if (shardsFromStore.length === 0) {
230
+ return { entries: [], shards: [], fingerprint: '', cached: false };
231
+ }
232
+
233
+ // Step 1
234
+ const chosenShards = await classifyShards({
235
+ prompt,
236
+ availableShards: shardsFromStore,
237
+ adapter,
238
+ fastModel,
239
+ topN: 2,
240
+ });
241
+
242
+ const fingerprint = computeFingerprint({ shards: chosenShards, prompt, taskId });
243
+ const cached = _cache.get(fingerprint);
244
+ if (cached && Date.now() - cached.t < CACHE_TTL) {
245
+ return { entries: cached.entries, shards: chosenShards, fingerprint, cached: true };
246
+ }
247
+
248
+ // Step 2
249
+ const candidates = collectCandidates(memoryShardStore, {
250
+ shards: chosenShards,
251
+ kind,
252
+ tags,
253
+ pinned,
254
+ });
255
+ if (candidates.length === 0) {
256
+ _cache.set(fingerprint, { entries: [], t: Date.now() });
257
+ return { entries: [], shards: chosenShards, fingerprint, cached: false };
258
+ }
259
+
260
+ // Step 3
261
+ const selectedIds = await llmRerank({
262
+ adapter, fastModel, prompt, candidates, memoryShardStore,
263
+ });
264
+
265
+ // Step 4 — load full bodies (minus sourceRef for injection).
266
+ const entries = [];
267
+ for (const id of selectedIds) {
268
+ const full = memoryShardStore.get(id);
269
+ if (!full) continue;
270
+ entries.push({
271
+ id: full.id,
272
+ shard: full.shard,
273
+ kind: full.kind,
274
+ body: full.body,
275
+ tags: full.tags || [],
276
+ // deliberately do NOT expose sourceRef here (§Δ23)
277
+ });
278
+ }
279
+ _cache.set(fingerprint, { entries, t: Date.now() });
280
+ return { entries, shards: chosenShards, fingerprint, cached: false };
281
+ }
282
+
283
+ export function clearR6RecallCache() {
284
+ _cache.clear();
285
+ }
286
+
287
+ export const R6_DEFAULTS = {
288
+ VP_DEFAULT_SHARDS,
289
+ TASK_SHARDS,
290
+ USER_SHARDS,
291
+ };
@@ -0,0 +1,166 @@
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 Task-memory fixed 5 shards ──────────────────────────
23
+ export const TASK_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
+ // Task (Δ26.3 task-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'|'task'|'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 'task': shards = [...TASK_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
+ }