@rikaidev/yomi 0.4.2 → 0.6.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 (86) hide show
  1. package/README.md +46 -4
  2. package/dist/ambient/ambient-event-graph.js +172 -0
  3. package/dist/ambient/ambient-observer.js +71 -0
  4. package/dist/ambient/event-graph-reducer.js +300 -0
  5. package/dist/ambient/ingestion-adapter.js +203 -0
  6. package/dist/ambient/journal/crypto.js +58 -0
  7. package/dist/ambient/journal/journal-store.js +284 -0
  8. package/dist/ambient/journal/types.js +7 -0
  9. package/dist/ambient/summaries/retriever.js +55 -0
  10. package/dist/ambient/summaries/summary-engine.js +128 -0
  11. package/dist/ambient/summaries/types.js +4 -0
  12. package/dist/attention/policy.js +101 -0
  13. package/dist/auth/credential-store.js +47 -17
  14. package/dist/auth/keychain.js +7 -54
  15. package/dist/cli/query.js +875 -0
  16. package/dist/cli/work-item.js +24 -0
  17. package/dist/insight/relationships.js +1 -1
  18. package/dist/line/auth/protocol/pwless-login-flow.js +12 -0
  19. package/dist/line/auth/pwless/index.js +1 -1
  20. package/dist/line/client/auth-service/client.js +7 -8
  21. package/dist/line/client/buddy-service/client.js +73 -0
  22. package/dist/line/client/index.js +28 -3
  23. package/dist/line/client/obs-media-client.js +38 -0
  24. package/dist/line/client/parsers.js +1 -0
  25. package/dist/line/client/square-service/client.js +298 -0
  26. package/dist/line/client/sticker-meta.js +32 -0
  27. package/dist/line/client/sync-service/client.js +31 -3
  28. package/dist/line/client/talk-service/command-client.js +9 -1
  29. package/dist/line/client/talk-service/contact-query.js +8 -1
  30. package/dist/line/client/talk-service/message-box-query.js +17 -0
  31. package/dist/line/client/talk-service/message-id-query.js +27 -3
  32. package/dist/line/client/talk-service/query-client.js +32 -26
  33. package/dist/line/client/talk-service/requests.js +10 -11
  34. package/dist/line/core/auth-session-runtime.js +17 -2
  35. package/dist/line/core/chat-event.js +35 -0
  36. package/dist/line/core/chat-runtime-service.js +18 -23
  37. package/dist/line/core/config.js +1 -0
  38. package/dist/line/core/directory-runtime-service.js +10 -2
  39. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +8 -16
  40. package/dist/line/core/flex-message.js +453 -0
  41. package/dist/line/core/mention.js +1 -1
  42. package/dist/line/core/message-preview.js +60 -0
  43. package/dist/line/core/message-query-service.js +0 -20
  44. package/dist/line/core/service.js +1 -3
  45. package/dist/mcp/handlers/contacts.js +12 -6
  46. package/dist/mcp/handlers/conversations.js +91 -4
  47. package/dist/mcp/handlers/login.js +1 -1
  48. package/dist/mcp/handlers/message-actions.js +1 -1
  49. package/dist/mcp/handlers/search.js +1 -1
  50. package/dist/mcp/handlers/send.js +8 -11
  51. package/dist/mcp/handlers/shared.js +1 -1
  52. package/dist/mcp/integrator.js +168 -0
  53. package/dist/mcp/media.js +135 -4
  54. package/dist/mcp/names.js +78 -6
  55. package/dist/mcp/phi-guard.js +2 -2
  56. package/dist/search/bigram.js +2 -2
  57. package/dist/search/collector.js +1 -1
  58. package/dist/search/default-embedder.js +12 -3
  59. package/dist/search/embedder.js +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/work-item/adapter.js +1 -0
  62. package/dist/work-item/destination-contracts.js +1 -0
  63. package/dist/work-item/event-context.js +73 -0
  64. package/dist/work-item/gold-contract.js +101 -0
  65. package/dist/work-item/gold-coverage.js +96 -0
  66. package/dist/work-item/gold-derivation.js +67 -0
  67. package/dist/work-item/gold-split.js +143 -0
  68. package/dist/work-item/index.js +23 -0
  69. package/dist/work-item/line-adapter.js +111 -0
  70. package/dist/work-item/llama-cpp-work-event-adapter.js +239 -0
  71. package/dist/work-item/local-provider.js +38 -0
  72. package/dist/work-item/local-work-event-provider.js +56 -0
  73. package/dist/work-item/onnx-work-event-adapter.js +223 -0
  74. package/dist/work-item/provider.js +21 -0
  75. package/dist/work-item/registry.js +19 -0
  76. package/dist/work-item/service.js +85 -0
  77. package/dist/work-item/store.js +97 -0
  78. package/dist/work-item/types.js +79 -0
  79. package/dist/work-item/work-event-graph-engine.js +63 -0
  80. package/dist/work-item/work-event-inference.js +267 -0
  81. package/dist/work-item/work-event-matcher.js +105 -0
  82. package/dist/work-item/work-event-model-manifest.js +39 -0
  83. package/dist/work-item/work-event-store.js +125 -0
  84. package/dist/work-item/work-event.js +123 -0
  85. package/package.json +26 -2
  86. package/run.mjs +371 -2
package/dist/mcp/names.js CHANGED
@@ -12,6 +12,20 @@
12
12
  * process lifetime. Unresolvable MIDs are never given a fabricated
13
13
  * placeholder — callers get `null` and may fall back to the raw MID.
14
14
  */
15
+ /**
16
+ * The name the authenticated user sees for a contact in the LINE app.
17
+ *
18
+ * LINE lets users rename friends locally; getContacts returns that name as
19
+ * `displayNameOverridden` alongside the friend's own `displayName`. The LINE
20
+ * app shows the override when present, so Yomi should too — otherwise names
21
+ * an agent reports don't match what the user sees.
22
+ *
23
+ * @param contact - Normalized contact from getContacts (may be null).
24
+ * @returns The user-set name, else the friend's own display name, else null.
25
+ */
26
+ export function contactDisplayName(contact) {
27
+ return contact?.displayNameOverridden || contact?.displayName || null;
28
+ }
15
29
  /**
16
30
  * Resolve display names for LINE user (contact) MIDs.
17
31
  *
@@ -35,9 +49,10 @@ export async function resolveUserNames(service, mids) {
35
49
  if (missing.length > 0) {
36
50
  const contacts = await service.client.getContacts(missing);
37
51
  for (const contact of contacts) {
38
- if (contact?.mid && contact.displayName) {
39
- service.nameCache.set(contact.mid, contact.displayName);
40
- resolved.set(contact.mid, contact.displayName);
52
+ const name = contactDisplayName(contact);
53
+ if (contact?.mid && name) {
54
+ service.nameCache.set(contact.mid, name);
55
+ resolved.set(contact.mid, name);
41
56
  }
42
57
  }
43
58
  }
@@ -106,6 +121,59 @@ async function resolveGroupNames(service, chatIds) {
106
121
  }
107
122
  return resolved;
108
123
  }
124
+ function mentionedUserMids(metadata) {
125
+ try {
126
+ const value = typeof metadata?.MENTION === 'string'
127
+ ? JSON.parse(metadata.MENTION)
128
+ : metadata?.MENTION;
129
+ const mentions = value?.MENTIONEES ?? value?.MENTIONS ?? [];
130
+ return mentions
131
+ .map((item) => item?.M ?? item?.mid)
132
+ .filter((mid) => typeof mid === 'string' && mid.startsWith('u'));
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ }
138
+ /** Legacy rooms have no chatName; LINE presents their participant names. */
139
+ async function resolveRoomNames(service, roomIds) {
140
+ const resolved = new Map();
141
+ await Promise.all(Array.from(new Set(roomIds.filter(Boolean))).map(async (roomId) => {
142
+ const cached = service.chatCache.get(roomId);
143
+ if (cached?.chatName) {
144
+ resolved.set(roomId, cached.chatName);
145
+ return;
146
+ }
147
+ try {
148
+ const messages = await service.getRecentMessages(roomId, 50);
149
+ const selfMid = service.profile?.mid;
150
+ const participantMids = Array.from(new Set(messages
151
+ .flatMap((message) => [
152
+ message.from,
153
+ ...mentionedUserMids(message.contentMetadata),
154
+ ])
155
+ .filter((mid) => typeof mid === 'string' &&
156
+ mid.startsWith('u') &&
157
+ mid !== selfMid)));
158
+ const names = await resolveUserNames(service, participantMids);
159
+ const title = participantMids
160
+ .map((mid) => names.get(mid))
161
+ .filter((name) => Boolean(name))
162
+ .slice(0, 4)
163
+ .join('、');
164
+ if (!title)
165
+ return;
166
+ resolved.set(roomId, title);
167
+ service.chatCache.set(roomId, {
168
+ ...(cached ?? {}),
169
+ chatMid: roomId,
170
+ chatName: title,
171
+ });
172
+ }
173
+ catch { }
174
+ }));
175
+ return resolved;
176
+ }
109
177
  /**
110
178
  * Resolve a human-readable name for each conversation id, dispatching by
111
179
  * LINE's MID prefix convention (u = 1:1 user, c = group, r = room).
@@ -116,18 +184,22 @@ async function resolveGroupNames(service, chatIds) {
116
184
  */
117
185
  export async function resolveConversationNames(service, chatIds) {
118
186
  const userIds = chatIds.filter((id) => id?.startsWith('u'));
119
- const groupIds = chatIds.filter((id) => id?.startsWith('c') || id?.startsWith('r'));
120
- const [userNames, groupNames] = await Promise.all([
187
+ const groupIds = chatIds.filter((id) => id?.startsWith('c'));
188
+ const roomIds = chatIds.filter((id) => id?.startsWith('r'));
189
+ const [userNames, groupNames, roomNames] = await Promise.all([
121
190
  userIds.length > 0
122
191
  ? resolveUserNames(service, userIds)
123
192
  : Promise.resolve(new Map()),
124
193
  groupIds.length > 0
125
194
  ? resolveGroupNames(service, groupIds)
126
195
  : Promise.resolve(new Map()),
196
+ roomIds.length > 0
197
+ ? resolveRoomNames(service, roomIds)
198
+ : Promise.resolve(new Map()),
127
199
  ]);
128
200
  const result = new Map();
129
201
  for (const id of chatIds) {
130
- result.set(id, userNames.get(id) ?? groupNames.get(id) ?? null);
202
+ result.set(id, userNames.get(id) ?? groupNames.get(id) ?? roomNames.get(id) ?? null);
131
203
  }
132
204
  return result;
133
205
  }
@@ -14,7 +14,7 @@
14
14
  * extra response content block reporting how many values were masked and of
15
15
  * what types, so the redaction is visible rather than a quiet rewrite.
16
16
  */
17
- /** Middle-redaction marker used in masked values (Q12〇〇9 style). */
17
+ /** Middle-redaction marker used in masked values (for example, Q12••9). */
18
18
  const MASK_MIDDLE = '〇〇';
19
19
  /**
20
20
  * Luhn check for candidate payment-card digit strings.
@@ -37,7 +37,7 @@ function passesLuhn(digits) {
37
37
  return digits.length >= 13 && digits.length <= 19 && sum % 10 === 0;
38
38
  }
39
39
  /**
40
- * Mask one matched value, preserving a little edge context (Q12〇〇9): the
40
+ * Mask one matched value, preserving a little edge context (for example, Q12••9): the
41
41
  * first few characters and, for longer values, the last one.
42
42
  * @param value - The raw matched substring.
43
43
  * @returns The masked substring.
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * The FTS5 `unicode61` tokenizer has no Chinese/Japanese/Korean word
5
5
  * segmentation: a whole CJK run collapses into a single opaque token, so a
6
- * substring like "會議" inside "會議通知" never matches. Yomi has no ICU
6
+ * two-character CJK substring inside a longer CJK term never matches. Yomi has no ICU
7
7
  * tokenizer available and takes on no new dependency to get one, so instead
8
8
  * both the indexed text and the search query are rewritten into overlapping
9
9
  * character bigrams for CJK runs before ever reaching FTS5. Overlapping
10
- * bigrams turn "會議通知" into "會議 議通 通知" — searching for "會議"
10
+ * overlapping bigrams preserve exact two-character search terms inside longer CJK text
11
11
  * (itself a single bigram once transformed) becomes a plain token match.
12
12
  * This covers the common case of 2+ character CJK words; single-character
13
13
  * CJK terms still match via the emitted unigram.
@@ -20,7 +20,7 @@
20
20
  * Also batch-embeds each chat's collected messages (when an Embedder is
21
21
  * supplied) so semantic search has vectors to rank over. The embedder is
22
22
  * always caller-injected — never imported directly here — so the MCP
23
- * server can pass its DefaultEmbedder while a host app (e.g. inboxd) can
23
+ * server can pass its DefaultEmbedder while a host app (e.g. Yomi Desktop) can
24
24
  * pass its own local model instead. Embedding failure (model unavailable,
25
25
  * offline first run, etc.) is caught per-chat and never aborts collection:
26
26
  * messages + bigram search_text are always stored regardless of whether
@@ -22,6 +22,8 @@
22
22
  * instruction prefix, stored documents do not. Using the model's official
23
23
  * recommended instruction for retrieval tasks.
24
24
  */
25
+ import { homedir } from 'node:os';
26
+ import { join } from 'node:path';
25
27
  const MODEL_ID = 'Xenova/bge-small-zh-v1.5';
26
28
  const BGE_QUERY_INSTRUCTION = '为这个句子生成表示以用于检索相关文章:';
27
29
  let pipelinePromise = null;
@@ -35,9 +37,16 @@ let pipelinePromise = null;
35
37
  function getPipeline() {
36
38
  if (!pipelinePromise) {
37
39
  pipelinePromise = import('@huggingface/transformers')
38
- .then(({ pipeline }) => pipeline('feature-extraction', MODEL_ID, {
39
- device: 'cpu',
40
- }))
40
+ .then(({ pipeline, env }) => {
41
+ env.cacheDir =
42
+ process.env.YOMI_MODEL_CACHE_DIR ||
43
+ (process.platform === 'darwin'
44
+ ? join(homedir(), 'Library', 'Caches', 'Yomi', 'models')
45
+ : join(process.env.LOCALAPPDATA || homedir(), 'Yomi', 'models'));
46
+ return pipeline('feature-extraction', MODEL_ID, {
47
+ device: 'cpu',
48
+ });
49
+ })
41
50
  .catch((error) => {
42
51
  // Reset the memo on failure so a later call can retry (e.g. the
43
52
  // first attempt failed offline, network comes back later) instead
@@ -6,7 +6,7 @@
6
6
  * a default implementation (./default-embedder.ts, transformers.js/ONNX) so
7
7
  * the MCP server works standalone, but nothing in store.ts/collector.ts
8
8
  * imports that default directly — they only depend on this interface, so a
9
- * host app (e.g. inboxd) can inject its own embedder (its local MLX
9
+ * host app (e.g. Yomi Desktop) can inject its own embedder (its local MLX
10
10
  * `generateEmbedding`) instead. See ./default-embedder.ts's doc comment for
11
11
  * the exact adapter shape.
12
12
  */
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * virtual filesystem). MUST stay equal to the "version" field in
5
5
  * package.json — the test in version.test.ts enforces that.
6
6
  */
7
- export const YOMI_VERSION = '0.4.2';
7
+ export const YOMI_VERSION = '0.6.0';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Expand anchors over the event graph rather than taking a fixed number of
3
+ * neighbouring messages. Edges are traversed in both directions because a
4
+ * later delivery can revise the meaning of an earlier request.
5
+ */
6
+ export function assembleEventContext(allMessages, graph, anchorMessageIds, options = {}) {
7
+ const minimumWeight = options.minimumEdgeWeight ?? 0.3;
8
+ const maximumMessages = Math.max(1, options.maximumMessages ?? 64);
9
+ const byId = new Map(allMessages.map((message) => [message.messageId, message]));
10
+ const validAnchors = [...new Set(anchorMessageIds)].filter((id) => byId.has(id));
11
+ const chatId = validAnchors.map((id) => byId.get(id).chatId)[0] ?? '';
12
+ if (validAnchors.length === 0) {
13
+ return {
14
+ schemaVersion: 1,
15
+ chatId,
16
+ anchorMessageIds: [],
17
+ messages: [],
18
+ edges: [],
19
+ complete: false,
20
+ boundary: 'missing_anchor',
21
+ };
22
+ }
23
+ const adjacency = new Map();
24
+ for (const edge of graph.edges.filter((item) => item.weight >= minimumWeight)) {
25
+ const parent = byId.get(edge.parentId);
26
+ const child = byId.get(edge.childId);
27
+ if (!parent ||
28
+ !child ||
29
+ parent.chatId !== chatId ||
30
+ child.chatId !== chatId)
31
+ continue;
32
+ adjacency.set(edge.parentId, [
33
+ ...(adjacency.get(edge.parentId) ?? []),
34
+ { id: edge.childId, edge },
35
+ ]);
36
+ adjacency.set(edge.childId, [
37
+ ...(adjacency.get(edge.childId) ?? []),
38
+ { id: edge.parentId, edge },
39
+ ]);
40
+ }
41
+ const distance = new Map(validAnchors.map((id) => [id, 0]));
42
+ const queue = [...validAnchors];
43
+ const includedEdges = new Map();
44
+ let truncated = false;
45
+ while (queue.length > 0) {
46
+ const current = queue.shift();
47
+ for (const next of adjacency.get(current) ?? []) {
48
+ includedEdges.set(`${next.edge.parentId}:${next.edge.childId}`, next.edge);
49
+ if (distance.has(next.id))
50
+ continue;
51
+ if (distance.size >= maximumMessages) {
52
+ truncated = true;
53
+ continue;
54
+ }
55
+ distance.set(next.id, (distance.get(current) ?? 0) + 1);
56
+ queue.push(next.id);
57
+ }
58
+ }
59
+ const messages = [...distance.keys()]
60
+ .map((id) => byId.get(id))
61
+ .sort((left, right) => left.sentAt - right.sentAt);
62
+ const selected = new Set(messages.map((message) => message.messageId));
63
+ const edges = [...includedEdges.values()].filter((edge) => selected.has(edge.parentId) && selected.has(edge.childId));
64
+ return {
65
+ schemaVersion: 1,
66
+ chatId,
67
+ anchorMessageIds: validAnchors,
68
+ messages,
69
+ edges,
70
+ complete: !truncated,
71
+ boundary: truncated ? 'message_limit' : 'graph_boundary',
72
+ };
73
+ }
@@ -0,0 +1,101 @@
1
+ const ACTS = new Set([
2
+ 'inform',
3
+ 'ask',
4
+ 'answer',
5
+ 'directive',
6
+ 'commit',
7
+ 'delegate',
8
+ 'acknowledge',
9
+ 'deliver',
10
+ 'accept',
11
+ 'reject',
12
+ 'cancel',
13
+ ]);
14
+ const OWNERSHIP = new Set([
15
+ 'unassigned',
16
+ 'owned',
17
+ 'delegated',
18
+ 'unknown',
19
+ ]);
20
+ const OWNER_KINDS = new Set([
21
+ 'self',
22
+ 'conversation_member',
23
+ 'external_party',
24
+ 'unknown',
25
+ ]);
26
+ const DEPENDENCIES = new Set([
27
+ 'none',
28
+ 'waiting_person',
29
+ 'waiting_external_system',
30
+ 'waiting_decision',
31
+ 'unknown',
32
+ ]);
33
+ const LIFECYCLES = new Set([
34
+ 'proposed',
35
+ 'committed',
36
+ 'in_progress',
37
+ 'blocked',
38
+ 'delivered',
39
+ 'accepted',
40
+ 'cancelled',
41
+ 'resolved',
42
+ 'uncertain',
43
+ ]);
44
+ export function validateFactorizedGold(value) {
45
+ const errors = [];
46
+ if (!value.id)
47
+ errors.push('id is required');
48
+ if (!Array.isArray(value.context) || value.context.length === 0)
49
+ errors.push('context is required');
50
+ if (!Number.isInteger(value.targetIndex) ||
51
+ value.targetIndex < 0 ||
52
+ value.targetIndex >= value.context.length)
53
+ errors.push('targetIndex is outside context');
54
+ if (!value.dialogueActs.length ||
55
+ value.dialogueActs.some((act) => !ACTS.has(act)))
56
+ errors.push('dialogueActs are invalid');
57
+ if (value.createsWork && !value.action)
58
+ errors.push('work requires an action frame');
59
+ if (!OWNERSHIP.has(value.ownership.state))
60
+ errors.push('ownership is invalid');
61
+ if (!Array.isArray(value.ownership.ownerKinds) ||
62
+ value.ownership.ownerKinds.length === 0 ||
63
+ value.ownership.ownerKinds.some((kind) => !OWNER_KINDS.has(kind)))
64
+ errors.push('ownerKinds are invalid');
65
+ if (!DEPENDENCIES.has(value.dependency))
66
+ errors.push('dependency is invalid');
67
+ if (!LIFECYCLES.has(value.lifecycle))
68
+ errors.push('lifecycle is invalid');
69
+ if (value.completion.accepted && !value.completion.delivered)
70
+ errors.push('acceptance requires delivery');
71
+ if (value.lifecycle === 'delivered' && !value.completion.delivered)
72
+ errors.push('delivered lifecycle requires delivery evidence');
73
+ if (value.lifecycle === 'accepted' && !value.completion.accepted)
74
+ errors.push('accepted lifecycle requires acceptance evidence');
75
+ if (value.lifecycle === 'cancelled' && !value.completion.cancelled)
76
+ errors.push('cancelled lifecycle requires cancellation evidence');
77
+ if (value.evidenceIndexes) {
78
+ if (value.evidenceIndexes.length === 0)
79
+ errors.push('evidenceIndexes cannot be empty');
80
+ if (new Set(value.evidenceIndexes).size !== value.evidenceIndexes.length)
81
+ errors.push('evidenceIndexes must be unique');
82
+ if (value.evidenceIndexes.some((index) => !Number.isInteger(index) ||
83
+ index < 0 ||
84
+ index >= value.context.length)) {
85
+ errors.push('evidenceIndexes are outside context');
86
+ }
87
+ if (!value.evidenceIndexes.includes(value.targetIndex))
88
+ errors.push('evidenceIndexes must include targetIndex');
89
+ }
90
+ if (value.evidenceMessageIds) {
91
+ if (value.evidenceMessageIds.length === 0)
92
+ errors.push('evidenceMessageIds cannot be empty');
93
+ if (new Set(value.evidenceMessageIds).size !== value.evidenceMessageIds.length)
94
+ errors.push('evidenceMessageIds must be unique');
95
+ if (value.evidenceIndexes &&
96
+ value.evidenceMessageIds.length !== value.evidenceIndexes.length) {
97
+ errors.push('evidence index and message id counts differ');
98
+ }
99
+ }
100
+ return errors;
101
+ }
@@ -0,0 +1,96 @@
1
+ export const REQUIRED_GOLD_COVERAGE = {
2
+ work: ['true', 'false'],
3
+ dialogue: [
4
+ 'inform',
5
+ 'ask',
6
+ 'answer',
7
+ 'directive',
8
+ 'commit',
9
+ 'delegate',
10
+ 'acknowledge',
11
+ 'deliver',
12
+ 'accept',
13
+ 'reject',
14
+ 'cancel',
15
+ ],
16
+ ownership: ['unassigned', 'owned', 'delegated'],
17
+ dependency: [
18
+ 'none',
19
+ 'waiting_person',
20
+ 'waiting_external_system',
21
+ 'waiting_decision',
22
+ ],
23
+ lifecycle: [
24
+ 'proposed',
25
+ 'committed',
26
+ 'in_progress',
27
+ 'blocked',
28
+ 'delivered',
29
+ 'accepted',
30
+ 'cancelled',
31
+ 'resolved',
32
+ ],
33
+ completion: ['delivered', 'accepted', 'cancelled'],
34
+ };
35
+ export function factorizedCoverageLabels(record) {
36
+ return [
37
+ `work:${record.createsWork}`,
38
+ ...record.dialogueActs.map((label) => `dialogue:${label}`),
39
+ `ownership:${record.ownership.state}`,
40
+ `dependency:${record.dependency}`,
41
+ `lifecycle:${record.lifecycle}`,
42
+ ...(record.completion.delivered ? ['completion:delivered'] : []),
43
+ ...(record.completion.accepted ? ['completion:accepted'] : []),
44
+ ...(record.completion.cancelled ? ['completion:cancelled'] : []),
45
+ ];
46
+ }
47
+ function count(records, head, label) {
48
+ if (head === 'work')
49
+ return records.filter((record) => String(record.createsWork) === label)
50
+ .length;
51
+ if (head === 'dialogue')
52
+ return records.filter((record) => record.dialogueActs.includes(label)).length;
53
+ if (head === 'ownership')
54
+ return records.filter((record) => record.ownership.state === label).length;
55
+ if (head === 'dependency')
56
+ return records.filter((record) => record.dependency === label).length;
57
+ if (head === 'lifecycle')
58
+ return records.filter((record) => record.lifecycle === label).length;
59
+ return records.filter((record) => record.completion[label]).length;
60
+ }
61
+ /** Every factorized head must be observable; aggregate sample count is not a gate. */
62
+ export function auditGoldCoverage(split) {
63
+ const issues = [];
64
+ for (const partition of ['train', 'validation', 'test']) {
65
+ const records = split[partition];
66
+ const required = partition === 'train' ? 2 : 1;
67
+ for (const [head, labels] of Object.entries(REQUIRED_GOLD_COVERAGE)) {
68
+ for (const label of labels) {
69
+ const observed = count(records, head, label);
70
+ if (observed < required)
71
+ issues.push({ partition, head, label, observed, required });
72
+ }
73
+ }
74
+ }
75
+ return issues;
76
+ }
77
+ export function assertGoldCoverage(split) {
78
+ const issues = auditGoldCoverage(split);
79
+ if (issues.length > 0) {
80
+ const summary = issues
81
+ .map((issue) => `${issue.partition}.${issue.head}.${issue.label}=${issue.observed}/${issue.required}`)
82
+ .join(', ');
83
+ throw new Error(`Factorized gold coverage gate failed: ${summary}`);
84
+ }
85
+ }
86
+ export function auditAcceptanceCoverage(records) {
87
+ const observed = new Set(records.flatMap(factorizedCoverageLabels));
88
+ return Object.entries(REQUIRED_GOLD_COVERAGE)
89
+ .flatMap(([head, labels]) => labels.map((label) => `${head}:${label}`))
90
+ .filter((label) => !observed.has(label));
91
+ }
92
+ export function assertAcceptanceCoverage(records) {
93
+ const missing = auditAcceptanceCoverage(records);
94
+ if (missing.length > 0)
95
+ throw new Error(`Independent acceptance coverage gate failed: ${missing.join(', ')}`);
96
+ }
@@ -0,0 +1,67 @@
1
+ export function validateGoldDerivation(value) {
2
+ const errors = [];
3
+ if (value.schemaVersion !== 1)
4
+ errors.push('unsupported schemaVersion');
5
+ if (!value.derivationId)
6
+ errors.push('derivationId is required');
7
+ if (!Number.isInteger(value.revision) || value.revision < 1)
8
+ errors.push('revision must be positive');
9
+ if (value.revision === 1 && value.previousRevision !== null)
10
+ errors.push('first revision cannot have a predecessor');
11
+ if (value.revision > 1 && value.previousRevision !== value.revision - 1)
12
+ errors.push('revision chain is invalid');
13
+ if (!value.conversationId)
14
+ errors.push('conversationId is required');
15
+ if (!value.graphEventId)
16
+ errors.push('graphEventId is required');
17
+ if (value.reasons.length === 0)
18
+ errors.push('at least one derivation reason is required');
19
+ if (new Set(value.evidenceMessageIds).size !== value.evidenceMessageIds.length)
20
+ errors.push('evidenceMessageIds must be unique');
21
+ if (new Set(value.derivedGoldIds).size !== value.derivedGoldIds.length)
22
+ errors.push('derivedGoldIds must be unique');
23
+ if (value.disposition === 'included' && value.derivedGoldIds.length !== 1)
24
+ errors.push('included context requires exactly one gold record');
25
+ if (value.disposition === 'split' && value.derivedGoldIds.length < 2)
26
+ errors.push('split context requires at least two gold records');
27
+ if (!['included', 'split'].includes(value.disposition) &&
28
+ value.derivedGoldIds.length > 0)
29
+ errors.push('excluded context cannot produce gold records');
30
+ if (!value.contextComplete &&
31
+ !value.reasons.includes('context_boundary_truncated') &&
32
+ !value.reasons.includes('missing_antecedent')) {
33
+ errors.push('incomplete context requires a boundary reason');
34
+ }
35
+ return errors;
36
+ }
37
+ export function createGoldDerivation(input) {
38
+ const revision = {
39
+ ...input,
40
+ schemaVersion: 1,
41
+ revision: 1,
42
+ previousRevision: null,
43
+ };
44
+ const errors = validateGoldDerivation(revision);
45
+ if (errors.length > 0)
46
+ throw new Error(errors.join('; '));
47
+ return revision;
48
+ }
49
+ /** Revisions make exclusions and earlier interpretations explicitly reversible. */
50
+ export function reviseGoldDerivation(previous, input) {
51
+ if (previous.derivationId !== input.derivationId)
52
+ throw new Error('Cannot revise a different derivation');
53
+ if (previous.conversationId !== input.conversationId ||
54
+ previous.graphEventId !== input.graphEventId) {
55
+ throw new Error('Derivation source cannot change across revisions');
56
+ }
57
+ const revision = {
58
+ ...input,
59
+ schemaVersion: 1,
60
+ revision: previous.revision + 1,
61
+ previousRevision: previous.revision,
62
+ };
63
+ const errors = validateGoldDerivation(revision);
64
+ if (errors.length > 0)
65
+ throw new Error(errors.join('; '));
66
+ return revision;
67
+ }