@aiwuk/team 0.0.0-stage → 5.0.3

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 (116) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/README.zh-CN.md +150 -0
  5. package/access.js +93 -0
  6. package/activity-summary.js +36 -0
  7. package/allocation.js +34 -0
  8. package/board-dispatch.js +141 -0
  9. package/brand.js +41 -0
  10. package/client/00-core-zh.js +284 -0
  11. package/client/01-i18n-en.js +265 -0
  12. package/client/02-styles.js +336 -0
  13. package/client/02b-stage-styles.js +65 -0
  14. package/client/03-shared.js +82 -0
  15. package/client/03b-collapsible-list.js +34 -0
  16. package/client/03c-config-panel.js +605 -0
  17. package/client/04-tool-cards.js +110 -0
  18. package/client/04a-dispatch-confirm.js +162 -0
  19. package/client/04b-more-cards.js +78 -0
  20. package/client/04c-model-picker.js +68 -0
  21. package/client/04d-confirm-dock.js +40 -0
  22. package/client/05-policy.js +217 -0
  23. package/client/06-usage.js +195 -0
  24. package/client/06b-model-source.js +67 -0
  25. package/client/07-board-parts.js +312 -0
  26. package/client/07a-role-theme.js +25 -0
  27. package/client/08-artifacts-report.js +375 -0
  28. package/client/08a-task-control.js +126 -0
  29. package/client/08b-stage-sprites.js +97 -0
  30. package/client/08c-stage-colors.js +55 -0
  31. package/client/08c-stage-scene.js +238 -0
  32. package/client/08d-stage-adapter.js +65 -0
  33. package/client/08e-stage-activity.js +83 -0
  34. package/client/09-team-board.js +181 -0
  35. package/client/09b-role-view.js +95 -0
  36. package/client/09c-model-tabs.js +20 -0
  37. package/client/10-overview.js +364 -0
  38. package/client/10b-onboarding.js +121 -0
  39. package/client/11-register.js +249 -0
  40. package/client.js +5053 -0
  41. package/config-projection.js +40 -0
  42. package/config.js +411 -0
  43. package/cordis.patch.yml +110 -0
  44. package/defaults.js +222 -0
  45. package/dispatch-confirm.js +293 -0
  46. package/dispatch-routes-store.js +31 -0
  47. package/failover.js +85 -0
  48. package/file-limits.js +119 -0
  49. package/free-worker.mjs +125 -0
  50. package/http-guard.js +14 -0
  51. package/i18n/roles.en.json +23 -0
  52. package/i18n/roles.zh.json +23 -0
  53. package/i18n/server.en.json +659 -0
  54. package/i18n/server.zh.json +659 -0
  55. package/i18n.js +34 -0
  56. package/icon.svg +7 -0
  57. package/index.js +111 -0
  58. package/integrations-config.js +301 -0
  59. package/integrations-messages.js +62 -0
  60. package/integrations.js +17 -0
  61. package/lead-route.js +30 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/long-context.js +30 -0
  65. package/managed-config-route.js +49 -0
  66. package/managed-ref.js +38 -0
  67. package/managed-resolve.js +47 -0
  68. package/member-task-control.js +242 -0
  69. package/memory-backends.js +89 -0
  70. package/memory-embed.js +70 -0
  71. package/memory-hub.js +150 -0
  72. package/memory-openviking.js +101 -0
  73. package/memory-redact.js +34 -0
  74. package/memory-store.js +219 -0
  75. package/memory-sync-queue.js +28 -0
  76. package/model-policy.js +85 -0
  77. package/model-prefs.js +104 -0
  78. package/observability-api.js +76 -0
  79. package/observability-projection.js +141 -0
  80. package/onboarding.js +47 -0
  81. package/opencode.js +74 -0
  82. package/package.json +153 -4
  83. package/plan-gate.js +92 -0
  84. package/preset-guard.js +47 -0
  85. package/request-evidence.js +41 -0
  86. package/roles.js +69 -0
  87. package/roster-defaults.js +70 -0
  88. package/route-health.js +39 -0
  89. package/runtime-observability.js +78 -0
  90. package/runtime-view.js +21 -0
  91. package/scripts/memory-cli.mjs +138 -0
  92. package/search-engines.js +119 -0
  93. package/search.js +164 -0
  94. package/session-policy.js +59 -0
  95. package/session-rotation.js +206 -0
  96. package/settings-schema.js +40 -0
  97. package/shared/managed-config.js +84 -0
  98. package/single-model.js +44 -0
  99. package/skills/project-role-theme/SKILL.md +46 -0
  100. package/task-bridge.js +32 -0
  101. package/task-control-api.js +36 -0
  102. package/task-control-audit.js +60 -0
  103. package/team.js +101 -0
  104. package/teammate-context.js +120 -0
  105. package/teammate-wait.js +18 -0
  106. package/template.js +46 -0
  107. package/theme-plugin.js +172 -0
  108. package/theme.js +115 -0
  109. package/token-discipline.js +116 -0
  110. package/token-metrics.js +37 -0
  111. package/tool-names.js +10 -0
  112. package/tool-schema.js +23 -0
  113. package/transcribe.js +104 -0
  114. package/ui.js +113 -0
  115. package/usage.js +92 -0
  116. package/workflow.js +246 -0
package/memory-hub.js ADDED
@@ -0,0 +1,150 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Memory hub: what the team learned (handoffs, accepted results, pitfalls, decisions) is kept per project,
3
+ // recalled into each member's runtime context when it receives work, and searchable on demand. Ingestion is
4
+ // automatic for handoffs and teammates' final reports; members can also write a memory explicitly.
5
+ // Recall is capped (MEMORY_LIMITS.injectChars) and only changes when the task changes, so it costs little
6
+ // context and does not resend the runtime snapshot every step.
7
+ import { createEmbedder } from './memory-embed.js';
8
+ import { createMemoryBackend } from './memory-backends.js';
9
+ import { redactSecrets } from './memory-redact.js';
10
+ import { MEMORY_KINDS, MEMORY_LIMITS, createMemoryStore } from './memory-store.js';
11
+ import { baseName } from './session-rotation.js';
12
+ import { params } from './tool-schema.js';
13
+ import { ownsCapabilityTeam } from './preset-guard.js';
14
+ const output = { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] };
15
+ const SCOPES = ['project', 'role', 'session', 'global'];
16
+ const messageText = message => (Array.isArray(message?.content) ? message.content.map(c => c?.text ?? '').join('\n') : String(message?.text ?? message?.content ?? '')).trim();
17
+ export function createMemoryHub(ctx, team, { store = createMemoryStore(team.root), embedder = createEmbedder(), backend = createMemoryBackend({ label: modelText('memory-hub.280') }), secrets = () => [] } = {}) {
18
+ const recall = new Map(); // agent id → { query, block }
19
+ const reports = new Map(); // `${session}:${callId}` → text of a pending teammate report
20
+ const identity = agent => {
21
+ const m = ctx.agentTeams.tryMembership(agent);
22
+ if (!m || !ownsCapabilityTeam(ctx, agent)) return undefined;
23
+ return { project: team.pinned(agent) ?? team.pinned(m.root ?? agent), role: m.role === 'lead' ? 'lead' : baseName(m.name), session: agent.id, lead: m.role === 'lead', leadId: (m.root ?? agent).id, name: m.name ?? 'lead' };
24
+ };
25
+ const embedOne = async (text, purpose) => { const out = await embedder.embed([text], purpose); return out ? { vector: out.vectors[0], model: out.model } : null; };
26
+ async function backfill(project) {
27
+ if (!embedder.available) return;
28
+ const missing = await store.unembedded(project, 16, embedder.vectorKey);
29
+ if (!missing.length) return;
30
+ const out = await embedder.embed(missing.map(m => m.text), 'passage');
31
+ if (out) for (let i = 0; i < missing.length; i++) await store.setVector(missing[i], out.vectors[i], out.model);
32
+ }
33
+ const hub = {
34
+ async write(who, { kind, text, scope = 'role', tags, key }) {
35
+ if (!who?.project) throw new Error(modelText('memory-hub.281'));
36
+ if (!SCOPES.includes(scope)) throw new Error(modelText('memory-hub.282', { 0: SCOPES.join(' / ') }));
37
+ if (scope === 'global' && !who.lead) throw new Error(modelText('memory-hub.283'));
38
+ // Secrets never enter memory: it is recalled into other members' prompts and may leave this machine.
39
+ const clean = redactSecrets(String(text ?? ''), await Promise.resolve(secrets()).catch(() => []));
40
+ const embedded = await embedOne(clean, 'passage');
41
+ const s = scope === 'global' ? { kind: 'global' } : scope === 'project' ? { kind: 'project', project: who.project }
42
+ : scope === 'session' ? { kind: 'session', project: who.project, session: who.session } : { kind: 'role', project: who.project, role: who.role };
43
+ const saved = await store.write({ scope: s, kind, text: clean, tags: Array.isArray(tags) ? tags.map(String).slice(0, 10) : [], key: key ? String(key).slice(0, 120) : null,
44
+ vector: embedded?.vector ?? null, model: embedded?.model ?? null, source: { member: who.name, session: who.session, lead: who.leadId, at: Date.now() } });
45
+ // Session-scoped notes stay local; everything else also becomes shared long-term memory.
46
+ if (scope !== 'session') {
47
+ try {
48
+ if (saved.superseded?.length) backend.revoke?.(saved.superseded);
49
+ if (!saved.merged || saved.superseded?.length) backend.enqueue(saved.memory, who);
50
+ } catch (error) { saved.syncError = error.message; }
51
+ }
52
+ return saved;
53
+ },
54
+ async search(who, { query, k = 6, kinds }) {
55
+ // The query goes to the embedding service and the shared backend, so it is filtered like a stored memory.
56
+ const q = redactSecrets(String(query ?? '').slice(0, 4000), await Promise.resolve(secrets()).catch(() => [])).slice(0, 2000);
57
+ const limit = Math.min(Math.max(1, k), 20);
58
+ const [embedded, shared] = await Promise.all([q ? embedOne(q, 'query') : null, kinds ? [] : backend.recall(q)]);
59
+ void backfill(who.project).catch(() => {});
60
+ const local = await store.search({ project: who.project, role: who.lead ? undefined : who.role, session: who.session, query: q, vector: embedded?.vector, vectorModel: embedded?.model, k: limit, kinds });
61
+ // Team memories first (scoped to this project/role), then shared long-term ones not already covered.
62
+ const seen = new Set(local.map(r => r.memory.text));
63
+ const results = []; let chars = 0;
64
+ for (const row of [...local, ...shared.filter(r => !seen.has(r.memory.text))]) {
65
+ const size = JSON.stringify(row.memory.text).length;
66
+ if (results.length >= limit || chars + size > 12000) continue;
67
+ results.push(row); chars += size;
68
+ }
69
+ return { results, mode: embedded ? 'hybrid' : 'text', embedError: embedded ? null : embedder.lastError, ...(backend.enabled ? { shared: { backend: backend.name, count: shared.length, error: backend.lastError } } : {}) };
70
+ },
71
+ backend,
72
+ /** Called by rotation after a member writes its handoff. */
73
+ async ingestHandoff(h) {
74
+ const who = { project: team.pinned(ctx.agents.get(h.session)) ?? undefined, role: h.role, session: h.session, lead: false, leadId: h.lead, name: h.member };
75
+ if (!who.project) return;
76
+ const w = h.written ?? {};
77
+ if (w.done || w.remaining) await hub.write(who, { kind: 'handoff', scope: 'role', key: `handoff:${h.role}`, text: modelText('memory-hub.284', { 0: h.member, 1: w.done || '—', 2: w.remaining || '—', 3: w.verify ? modelText('memory-hub.285', { 0: w.verify }) : '' }) });
78
+ if (w.pitfalls) await hub.write(who, { kind: 'pitfall', scope: 'project', text: modelText('memory-hub.286', { 0: h.role, 1: w.pitfalls }) });
79
+ },
80
+ /** Memory block for a member's runtime context; refreshed when its task text changes. */
81
+ async refreshRecall(agent, query) {
82
+ const who = identity(agent);
83
+ if (!who?.project || !query) return;
84
+ const prior = recall.get(agent.id);
85
+ if (prior?.query === query) return;
86
+ const { results } = await hub.search(who, { query, k: 8 });
87
+ const block = store.format(results, MEMORY_LIMITS.injectChars);
88
+ recall.set(agent.id, { query, block, ids: results.map(r => r.memory.id) });
89
+ const local = results.filter(r => SCOPES.includes(r.memory.scope.kind)); // backend hits are not in the store
90
+ if (local.length) await store.markUsed(local.map(r => r.memory));
91
+ },
92
+ recallText(agent) {
93
+ const r = recall.get(agent.id);
94
+ return r?.block ? modelText('memory-hub.287', { 0: r.block }) : '';
95
+ },
96
+ register() {
97
+ ctx.on('agent/created', ({ agent }) => {
98
+ if (!ownsCapabilityTeam(ctx, agent) || !ctx.agentTeams.tryMembership(agent)) return;
99
+ agent.ctx.systemPrompt.context({ name: 'capability-team:memory', order: 97, text: () => hub.recallText(agent) });
100
+ });
101
+ ctx.on('agent/inbox/claimed', ({ agent, message }) => {
102
+ const text = messageText(message);
103
+ if (text.length >= 8 && ownsCapabilityTeam(ctx, agent) && ctx.agentTeams.tryMembership(agent)) void hub.refreshRecall(agent, text.slice(0, 2000)).catch(() => {});
104
+ });
105
+ // A teammate's successful final report to the Lead becomes a 'result' memory of its role.
106
+ try {
107
+ ctx.on('session/event', (session, event) => {
108
+ const agent = session?.id && ctx.agents.get(session.id);
109
+ if (!ownsCapabilityTeam(ctx, agent) || ctx.agentTeams.tryMembership(agent)?.role !== 'teammate') return;
110
+ const d = event?.data ?? {};
111
+ if (event.type === 'tool/call' && d.name === 'send_message') {
112
+ let args = {}; try { args = typeof d.arguments === 'string' ? JSON.parse(d.arguments) : d.arguments ?? {}; } catch { return; }
113
+ if (args.target === 'lead' && String(args.message ?? '').length >= 200) reports.set(`${session.id}:${d.callId}`, String(args.message));
114
+ } else if (event.type === 'tool/result') {
115
+ const key = `${session.id}:${d.message?.toolCallId}`;
116
+ const text = reports.get(key);
117
+ if (text === undefined) return;
118
+ reports.delete(key);
119
+ if (!d.message?.isError) void hub.write(identity(agent), { kind: 'result', scope: 'role', text }).catch(() => {});
120
+ }
121
+ });
122
+ } catch { /* host without session observers: explicit writes still work */ }
123
+ ctx.effect(() => ctx.tools.register({ name: 'capability_memory_search', output,
124
+ description: modelText('memory-hub.288'),
125
+ parameters: params({ query: { type: 'string', description: modelText('memory-hub.289') }, k: { type: 'integer', description: modelText('memory-hub.290') },
126
+ kinds: { type: 'array', items: { type: 'string', enum: MEMORY_KINDS }, description: modelText('memory-hub.291') } }, ['query']),
127
+ async execute(args, exec) {
128
+ const who = identity(exec.agent);
129
+ if (!who) throw new Error(modelText('memory-hub.292'));
130
+ const { results, mode, embedError, shared } = await hub.search(who, args);
131
+ return JSON.stringify({ mode, ...(embedError ? { note: modelText('memory-hub.293', { 0: embedError }) } : {}),
132
+ ...(shared ? { shared: shared.error ? modelText('memory-hub.294', { 0: shared.backend, 1: shared.error }) : modelText('memory-hub.295', { 0: shared.count, 1: shared.backend }) } : {}),
133
+ results: results.map(r => ({ id: r.memory.id, kind: r.memory.kind, scope: r.memory.scope.kind, text: r.memory.text, via: r.via, updatedAt: r.memory.updatedAt })) });
134
+ },
135
+ }));
136
+ ctx.effect(() => ctx.tools.register({ name: 'capability_memory_write', output,
137
+ description: modelText('memory-hub.296'),
138
+ parameters: params({ kind: { type: 'string', enum: ['pitfall', 'decision', 'note'] }, text: { type: 'string' }, scope: { type: 'string', enum: SCOPES },
139
+ key: { type: 'string', description: modelText('memory-hub.297') }, tags: { type: 'array', items: { type: 'string' } } }, ['kind', 'text']),
140
+ async execute(args, exec) {
141
+ const { memory, merged, syncError } = await hub.write(identity(exec.agent), args);
142
+ return JSON.stringify({ saved: true, merged, ...(syncError ? { syncWarning: syncError } : {}), id: memory.id, kind: memory.kind, scope: memory.scope.kind, embedded: !!memory.vector });
143
+ },
144
+ }));
145
+ // Plugin reload or shutdown: send what is still queued for the shared long-term memory.
146
+ ctx.effect(() => () => { void backend.flush().catch(() => {}); });
147
+ },
148
+ };
149
+ return hub;
150
+ }
@@ -0,0 +1,101 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // OpenViking backend for the memory hub (integrations.memory.backend = 'openviking').
3
+ // The team's own store stays the source for role/session-scoped recall; every memory is also sent to the
4
+ // OpenViking server, which extracts and indexes it as long-term memory shared by every agent and session that
5
+ // uses that server (DSH, Codex, OpenClaw...). Recall merges OpenViking's context search into team results.
6
+ // The server is optional at run time: when it is down, writes stay queued for the next flush and recall falls
7
+ // back to the local store, so a stopped server never blocks a member.
8
+ import { keyOf, refreshCredentials, resolveIntegrations } from './integrations-config.js';
9
+ import { createSyncQueue } from './memory-sync-queue.js';
10
+ import { defaultRoot } from './theme.js';
11
+ import { join } from 'node:path';
12
+ export const OV_SESSION_PREFIX = 'capability-team';
13
+ const FLUSH_MS = 20000;
14
+ const MAX_QUEUE = 200;
15
+ const MAX_BATCH = 40;
16
+ export function memorySettings(resolved = resolveIntegrations()) {
17
+ return { config: resolved.effective.memory, key: keyOf('memory', resolved) };
18
+ }
19
+ // `label` names the caller (e.g. 'Capability Team' for the team plugin, 'Global Memory' for global-memory) at the start of
20
+ // every message sent upstream; the default is neutral (no caller name).
21
+ export function createOpenViking({ settings, queuePath, label = '', warn = () => {}, fetcher = fetch, now = Date.now, schedule = (fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; } } = {}) {
22
+ const current = () => settings ?? memorySettings();
23
+ const prefix = String(label ?? '').replace(/[【】\r\n]/g, '').trim().slice(0, 40);
24
+ const queue = createSyncQueue(queuePath ?? (settings ? null : join(defaultRoot(), 'memory', 'openviking-queue.json')), { max: MAX_QUEUE, warn });
25
+ let timer = null;
26
+ let flushing = null;
27
+ const api = {
28
+ name: 'openviking',
29
+ lastError: null,
30
+ get enabled() { const { config } = current(); return config.backend === 'openviking' && !!config.url; },
31
+ async request(method, path, body, timeoutMs) {
32
+ if (!settings) await refreshCredentials().catch(() => {});
33
+ const { config, key } = current();
34
+ const headers = { 'Content-Type': 'application/json', ...(key ? { 'X-API-Key': key } : {}),
35
+ ...(config.account ? { 'X-OpenViking-Account': config.account } : {}), ...(config.user ? { 'X-OpenViking-User': config.user } : {}) };
36
+ const response = await fetcher(`${config.url}/api/v1${path}`, { method, headers, redirect: 'error',
37
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs) });
38
+ const text = await response.text();
39
+ if (!response.ok) throw new Error(`OpenViking HTTP ${response.status}${response.status === 401 || response.status === 403 ? modelText('memory-openviking.298') : ''}`);
40
+ let data; try { data = JSON.parse(text); } catch { throw new Error(modelText('memory-openviking.299')); }
41
+ if (data?.status && data.status !== 'ok') throw new Error(modelText('memory-openviking.300', { 0: String(data.status).slice(0, 40) }));
42
+ return data?.result ?? data;
43
+ },
44
+ /** Queue one memory for the next commit; the text is already redacted by the hub. */
45
+ enqueue(memory, who) {
46
+ if (!api.enabled) return false;
47
+ // Only the project's folder name goes upstream: the full path exposes this machine's layout and user name.
48
+ const projectName = String(who?.project ?? '').split(/[\\/]/).filter(Boolean).pop();
49
+ const where = [projectName ? modelText('memory-openviking.301', { 0: projectName }) : null, memory.scope?.kind === 'role' ? modelText('memory-openviking.302', { 0: memory.scope.role }) : null].filter(Boolean).join(' · ');
50
+ queue.push({ role: 'user', content: `【${prefix}${memory.kind === 'pitfall' ? modelText('memory-openviking.303') : memory.kind === 'decision' ? modelText('memory-openviking.304') : modelText('memory-openviking.305')}${where ? ` · ${where}` : ''}】${memory.text}` });
51
+ api.lastError = null;
52
+ if (!timer) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS);
53
+ return true;
54
+ },
55
+ /** Send queued memories as one session and commit it for extraction; failures keep them queued. */
56
+ async flush() {
57
+ if (flushing) return flushing;
58
+ if (!queue.size() || !api.enabled) return { sent: 0 };
59
+ flushing = (async () => {
60
+ const batch = queue.peek(MAX_BATCH);
61
+ const sid = `${OV_SESSION_PREFIX}-${now()}`;
62
+ try {
63
+ await api.request('POST', '/sessions', { session_id: sid });
64
+ for (const { role, content } of batch) await api.request('POST', `/sessions/${sid}/messages`, { role, content });
65
+ await api.request('POST', `/sessions/${sid}/commit`, {});
66
+ queue.ack(batch);
67
+ api.lastError = null;
68
+ return { sent: batch.length, session: sid };
69
+ } catch (error) { api.lastError = error.message; throw error; }
70
+ finally { flushing = null; if (queue.size() && !timer) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS); }
71
+ })();
72
+ return flushing;
73
+ },
74
+ /** Long-term memories relevant to `query`, as hub results; [] when disabled or unreachable. */
75
+ async recall(query, limit) {
76
+ if (!api.enabled || !query) return [];
77
+ const { config } = current();
78
+ try {
79
+ const result = await api.request('POST', '/search/search', { query: String(query).slice(0, 2000), mode: 'context', limit: limit ?? config.recallLimit });
80
+ api.lastError = null;
81
+ return (Array.isArray(result?.entries) ? result.entries : []).filter(e => typeof e?.text === 'string' && e.text.trim() && !queue.revoked().some(t => e.text.includes(t))).slice(0, limit ?? config.recallLimit)
82
+ .map(e => ({ score: Number(e.score) || 0, via: ['openviking'], memory: { id: e.uri, kind: 'note', scope: { kind: 'openviking', category: e.category }, text: e.text.trim().slice(0, 1200), uri: e.uri } }));
83
+ } catch (error) { api.lastError = error.message; return []; }
84
+ },
85
+ async status() {
86
+ if (!api.enabled) return { enabled: false };
87
+ const { config } = current();
88
+ const response = await fetcher(`${config.url}/health`, { signal: AbortSignal.timeout(5000), redirect: 'error' });
89
+ return { enabled: true, healthy: response.ok, queued: queue.size(), lastError: api.lastError, ...(response.ok ? await response.json() : {}) };
90
+ },
91
+ revoke(memories) {
92
+ const texts = memories.map(m => m.text).filter(Boolean);
93
+ queue.revoke(texts);
94
+ for (const text of texts) queue.push({ role: 'user', content: modelText('memory-openviking.306', { 0: text }) });
95
+ if (!timer) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS);
96
+ },
97
+ pending: () => queue.size(),
98
+ };
99
+ if (queue.size()) timer = schedule(() => { timer = null; void api.flush().catch(() => {}); }, FLUSH_MS);
100
+ return api;
101
+ }
@@ -0,0 +1,34 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Secret filter applied to every memory before it is stored locally or sent to an external memory service.
3
+ // Memories are recalled into other members' prompts later, so a leaked token would spread; values are
4
+ // replaced with a marker instead of rejecting the whole memory, which usually still carries the useful lesson.
5
+ const PATTERNS = [
6
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
7
+ /\b(?:sk|pk|rk)-(?:[A-Za-z0-9_-]{2,16}-)?[A-Za-z0-9_-]{16,}\b/g,
8
+ /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/g,
9
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
10
+ /\bAKIA[0-9A-Z]{16}\b/g,
11
+ /\b(?:tvly|xai|gsk|glpat)-[A-Za-z0-9_-]{16,}\b/g,
12
+ /\b(?:jina|hf|npm)_[A-Za-z0-9_]{16,}\b/g,
13
+ /\bBSA[A-Za-z0-9_-]{20,}\b/g,
14
+ /\bAIza[0-9A-Za-z_-]{35}\b/g,
15
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
16
+ /\b(Bearer|Basic)\s+[A-Za-z0-9._~+\/=-]{16,}/gi,
17
+ /\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@\/]+:[^\s@\/]+@/gi,
18
+ // Any name ending in one of these, so OPENVIKING_API_KEY=… and GH_TOKEN=… match as well as key=…
19
+ /\b((?:[\w-]*(?:key|token|secret|password|passwd|pwd)|auth)["']?\s*[:=]\s*["']?)[^\s"',;]{8,}/gi,
20
+ ];
21
+ export const REDACTED = modelText('memory-redact.307');
22
+ /** Text with likely secrets replaced; `extra` are exact values (e.g. keys from the credential store). */
23
+ export function redactSecrets(text, extra = []) {
24
+ let out = String(text ?? '');
25
+ for (const value of extra) if (typeof value === 'string' && value.length >= 8) out = out.split(value).join(REDACTED);
26
+ for (const re of PATTERNS) {
27
+ out = out.replace(re, (match, prefix) => {
28
+ if (/^(bearer|basic)$/i.test(prefix ?? '')) return `${prefix} ${REDACTED}`;
29
+ if (typeof prefix === 'string' && /[:=@]|:\/\/$/.test(prefix)) return prefix.endsWith('://') ? `${prefix}${REDACTED}@` : `${prefix}${REDACTED}`;
30
+ return REDACTED;
31
+ });
32
+ }
33
+ return out;
34
+ }
@@ -0,0 +1,219 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Memory store: one JSONL file per project plus a global file, a BM25 full-text index built in memory
3
+ // (Latin words + CJK character bigrams), optional vectors, and hybrid ranking by reciprocal rank fusion.
4
+ // Writes merge near-duplicates instead of piling them up; ranking decays with age and rises with use.
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { cosine } from './memory-embed.js';
9
+ import { acquireLock } from './theme.js';
10
+ /** Several DSH processes (Desktop, `dsh web`, profiles) share the files: writes and compaction take a file lock. */
11
+ async function withFileLock(path, fn) {
12
+ let handle = null;
13
+ for (let attempt = 0; !handle; attempt++) {
14
+ try { handle = await acquireLock(`${path}.lock`); }
15
+ catch (error) { if (attempt >= 100 || ['EACCES', 'EPERM', 'ENOENT'].includes(error.code) && attempt >= 3) throw error; await new Promise(r => setTimeout(r, 30)); }
16
+ }
17
+ try { return await fn(); } finally { await handle.close().catch(() => {}); await unlink(`${path}.lock`).catch(() => {}); }
18
+ }
19
+ export const MEMORY_KINDS = ['handoff', 'result', 'pitfall', 'decision', 'note'];
20
+ export const MEMORY_LIMITS = { maxText: 2000, maxPerFile: 2000, halfLifeDays: 30, pruneDays: 180, duplicateCosine: 0.92, injectChars: 2400 };
21
+ const DAY = 86400000;
22
+ /** Tokens for BM25: lowercase Latin/digit words and CJK unigrams + bigrams. */
23
+ /** Superseded JSONL lines tolerated beyond twice the live count before a file is rewritten. */
24
+ const COMPACT_SLACK = 200;
25
+ export function tokenize(text) {
26
+ const out = [];
27
+ const s = String(text ?? '').toLowerCase();
28
+ for (const w of s.match(/[a-z0-9_][a-z0-9_.-]*/g) ?? []) out.push(w);
29
+ const cjk = s.match(/[\u3400-\u9fff\uf900-\ufaff]+/g) ?? [];
30
+ for (const run of cjk) { for (let i = 0; i < run.length; i++) { out.push(run[i]); if (i + 1 < run.length) out.push(run.slice(i, i + 2)); } }
31
+ return out;
32
+ }
33
+ const normText = text => String(text ?? '').replace(/\s+/g, ' ').trim();
34
+ const fileKey = project => (project ? createHash('sha256').update(project).digest('hex').slice(0, 32) : 'global');
35
+ /** Whether a memory is visible to a reader: its project, and role/session scopes when narrower. */
36
+ export function visible(m, { project, role, session }) {
37
+ if (m.supersededBy) return false;
38
+ if (m.scope.kind === 'global') return true;
39
+ if (m.scope.project !== project) return false;
40
+ if (m.scope.kind === 'role') return !role || m.scope.role === role;
41
+ if (m.scope.kind === 'session') return m.scope.session === session;
42
+ return true;
43
+ }
44
+ function bm25(docs, query) {
45
+ const q = [...new Set(tokenize(query))];
46
+ if (!q.length || !docs.length) return new Map();
47
+ const tf = docs.map(d => { const m = new Map(); for (const t of tokenize(d.text)) m.set(t, (m.get(t) ?? 0) + 1); return m; });
48
+ const lengths = tf.map(m => [...m.values()].reduce((a, b) => a + b, 0));
49
+ const avg = lengths.reduce((a, b) => a + b, 0) / docs.length || 1;
50
+ const scores = new Map();
51
+ for (const term of q) {
52
+ const df = tf.filter(m => m.has(term)).length;
53
+ if (!df) continue;
54
+ const idf = Math.log(1 + (docs.length - df + 0.5) / (df + 0.5));
55
+ tf.forEach((m, i) => {
56
+ const f = m.get(term); if (!f) return;
57
+ scores.set(docs[i].id, (scores.get(docs[i].id) ?? 0) + idf * (f * 2.2) / (f + 1.2 * (0.25 + 0.75 * lengths[i] / avg)));
58
+ });
59
+ }
60
+ return scores;
61
+ }
62
+ /** Hybrid rank: RRF over BM25 and cosine, times recency decay and a small boost for memories that helped before. */
63
+ export function rank(docs, query, queryVector, { now = Date.now(), halfLifeDays = MEMORY_LIMITS.halfLifeDays, vectorModel } = {}) {
64
+ const text = bm25(docs, query);
65
+ const byText = [...text.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
66
+ // Vectors from another model or dimension count are not comparable, even when the lengths match.
67
+ const byVec = queryVector ? docs.filter(d => !vectorModel || d.model === vectorModel).map(d => [d.id, cosine(d.vector, queryVector)]).filter(([, s]) => s !== undefined && s > 0.2).sort((a, b) => b[1] - a[1]).map(([id]) => id) : [];
68
+ const fused = new Map();
69
+ byText.forEach((id, i) => fused.set(id, (fused.get(id) ?? 0) + 1 / (60 + i)));
70
+ byVec.forEach((id, i) => fused.set(id, (fused.get(id) ?? 0) + 1 / (60 + i)));
71
+ return docs.filter(d => fused.has(d.id)).map(d => {
72
+ const age = Math.max(0, now - (d.updatedAt ?? d.createdAt)) / DAY;
73
+ const decay = Math.pow(0.5, age / halfLifeDays);
74
+ return { memory: d, score: fused.get(d.id) * (0.3 + 0.7 * decay) * (1 + Math.min(d.hits ?? 0, 10) * 0.05), via: [text.has(d.id) ? 'text' : null, byVec.includes(d.id) ? 'vector' : null].filter(Boolean) };
75
+ }).sort((a, b) => b.score - a.score);
76
+ }
77
+ export function createMemoryStore(root, { now = Date.now, limits = MEMORY_LIMITS } = {}) {
78
+ const dir = join(root, 'memory');
79
+ const cache = new Map(); // file key → memories
80
+ const seen = new Map(); // file key → size:mtime of the file as last read or written by this process
81
+ const lines = new Map(); // file key → JSONL lines in the file (live records plus superseded appends)
82
+ const pathOf = key => join(dir, `${key}.jsonl`);
83
+ const stamp = async key => { try { const s = await stat(pathOf(key)); return `${s.size}:${s.mtimeMs}`; } catch { return 'none'; } };
84
+ async function load(key) {
85
+ // Another process may have appended or compacted since: re-read when the file changed under us.
86
+ // Stamped before reading, so an append that lands during the read makes the next load read again.
87
+ const current = await stamp(key);
88
+ if (cache.has(key) && seen.get(key) === current) return cache.get(key);
89
+ let list = [];
90
+ try {
91
+ const latest = new Map();
92
+ let count = 0;
93
+ for (const line of (await readFile(pathOf(key), 'utf8')).split('\n')) {
94
+ if (!line.trim()) continue;
95
+ count++;
96
+ try { const m = JSON.parse(line); latest.set(m.id, m); } catch { /* a torn last line is skipped */ }
97
+ }
98
+ list = [...latest.values()].filter(m => !m.deleted);
99
+ lines.set(key, count);
100
+ } catch (error) { if (error.code !== 'ENOENT') throw error; lines.set(key, 0); }
101
+ const previous = cache.get(key);
102
+ if (previous) { previous.length = 0; previous.push(...list); list = previous; } // keep callers' array identity
103
+ cache.set(key, list);
104
+ seen.set(key, current);
105
+ return list;
106
+ }
107
+ async function persist(key, record) {
108
+ await mkdir(dir, { recursive: true });
109
+ await withFileLock(pathOf(key), async () => {
110
+ const before = await stamp(key);
111
+ await appendFile(pathOf(key), JSON.stringify(record) + '\n');
112
+ lines.set(key, (lines.get(key) ?? 0) + 1);
113
+ // Only our own append happened since the last read: the cache is still current.
114
+ if (seen.get(key) === before) seen.set(key, await stamp(key));
115
+ });
116
+ // Every update (hits on recall, merges, vectors) appends the whole record again; without this the file grows
117
+ // without bound even though the live set is capped. Rewrite once superseded lines outnumber live ones.
118
+ if ((lines.get(key) ?? 0) > 2 * (cache.get(key)?.length ?? 0) + COMPACT_SLACK) await compact(key);
119
+ }
120
+ /** Rewrite the file from its current content (re-read under the lock, so other processes' appends survive). */
121
+ async function compact(key, select = list => list) {
122
+ await mkdir(dir, { recursive: true });
123
+ return withFileLock(pathOf(key), async () => {
124
+ seen.delete(key);
125
+ const list = await load(key);
126
+ const keep = [...select(list)]; // a copy: `list` is cleared and refilled below
127
+ const temp = `${pathOf(key)}.${randomUUID()}.tmp`;
128
+ await writeFile(temp, keep.map(m => JSON.stringify(m)).join('\n') + (keep.length ? '\n' : ''));
129
+ await rename(temp, pathOf(key));
130
+ lines.set(key, keep.length);
131
+ list.length = 0; list.push(...keep);
132
+ seen.set(key, await stamp(key));
133
+ return keep;
134
+ });
135
+ }
136
+ const keyOf = scope => (scope.kind === 'global' ? 'global' : fileKey(scope.project));
137
+ // One operation at a time per store: a write adds its record to the cached list before appending it, so a reload
138
+ // in between would drop the record from the cache while the append then marks that cache as current. The record
139
+ // would stay on disk but be invisible to every search until the file next changed.
140
+ let tail = Promise.resolve();
141
+ const serial = fn => { const run = tail.then(fn); tail = run.catch(() => {}); return run; };
142
+ const raw = {
143
+ /** Insert, or merge into a near-duplicate in the same scope (same normalized text, or cosine ≥ threshold). */
144
+ async write({ scope, kind, text, tags = [], source = {}, vector = null, model = null, key: topic = null }) {
145
+ if (!MEMORY_KINDS.includes(kind)) throw new Error(modelText('memory-store.308', { 0: MEMORY_KINDS.join(' / ') }));
146
+ const body = normText(text).slice(0, limits.maxText);
147
+ if (body.length < 4) throw new Error(modelText('memory-store.309'));
148
+ if (scope.kind !== 'global' && !scope.project) throw new Error(modelText('memory-store.310'));
149
+ const fk = keyOf(scope);
150
+ const list = await load(fk);
151
+ const same = m => m.kind === kind && JSON.stringify(m.scope) === JSON.stringify(scope) && !m.supersededBy;
152
+ const at = now();
153
+ const superseded = [];
154
+ // A memory with the same topic key replaces the older ones (conflict update: newest wins).
155
+ const supersede = async keeper => {
156
+ if (!topic) return;
157
+ for (const old of list.filter(m => same(m) && m.key === topic && m.id !== keeper.id)) { superseded.push({ ...old }); old.supersededBy = keeper.id; old.updatedAt = at; await persist(fk, old); }
158
+ };
159
+ const dup = list.find(m => same(m) && (normText(m.text) === body || (vector && m.model === model && cosine(m.vector, vector) >= limits.duplicateCosine)));
160
+ if (dup) {
161
+ Object.assign(dup, { text: body.length >= dup.text.length ? body : dup.text, tags: [...new Set([...dup.tags, ...tags])], updatedAt: at, merged: (dup.merged ?? 0) + 1, ...(topic ? { key: topic } : {}), ...(vector ? { vector, model } : {}) });
162
+ await persist(fk, dup);
163
+ await supersede(dup);
164
+ return { memory: dup, merged: true, superseded };
165
+ }
166
+ const memory = { id: randomUUID(), scope, kind, text: body, tags: [...new Set(tags)], source, key: topic, vector, model, createdAt: at, updatedAt: at, hits: 0 };
167
+ list.push(memory);
168
+ await persist(fk, memory);
169
+ await supersede(memory);
170
+ if (list.length > limits.maxPerFile) await this.prune(scope);
171
+ return { memory, merged: false, superseded };
172
+ },
173
+ /** Visible memories for a reader, ranked for `query`. */
174
+ async search({ project, role, session, query, vector, vectorModel, k = 6, kinds }) {
175
+ const pool = [...await load('global'), ...(project ? await load(fileKey(project)) : [])]
176
+ .filter(m => visible(m, { project, role, session }) && (!kinds || kinds.includes(m.kind)));
177
+ return rank(pool, query, vector, { now: now(), halfLifeDays: limits.halfLifeDays, vectorModel }).slice(0, k);
178
+ },
179
+ async markUsed(memories) {
180
+ const at = now();
181
+ for (const m of memories) { m.hits = (m.hits ?? 0) + 1; m.lastUsedAt = at; await persist(keyOf(m.scope), m); }
182
+ },
183
+ /** Memories without a vector of the current model (for backfill once embeddings work or after a model change). */
184
+ async unembedded(project, limit = 64, vectorModel = null) {
185
+ return [...await load('global'), ...(project ? await load(fileKey(project)) : [])].filter(m => !m.supersededBy && (!m.vector || (vectorModel && m.model !== vectorModel))).slice(0, limit);
186
+ },
187
+ async setVector(m, vector, model) { m.vector = vector; m.model = model; await persist(keyOf(m.scope), m); },
188
+ /** Forget: drop superseded entries and unused old ones, keep the newest maxPerFile. */
189
+ async prune(scope) {
190
+ const cutoff = now() - limits.pruneDays * DAY;
191
+ let removed = 0;
192
+ await compact(keyOf(scope), list => {
193
+ const keep = list.filter(m => !m.supersededBy && !((m.hits ?? 0) === 0 && (m.updatedAt ?? m.createdAt) < cutoff))
194
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, limits.maxPerFile);
195
+ removed = list.length - keep.length;
196
+ return keep;
197
+ });
198
+ return removed;
199
+ },
200
+ async remove(scope, id) {
201
+ const fk = keyOf(scope); const list = await load(fk);
202
+ const i = list.findIndex(m => m.id === id); if (i < 0) return false;
203
+ const [m] = list.splice(i, 1); await persist(fk, { ...m, deleted: true }); return true;
204
+ },
205
+ /** Text block for automatic injection: newest-first ranked memories within a character budget. */
206
+ format(results, maxChars = limits.injectChars) {
207
+ const lines = []; let used = 0;
208
+ for (const { memory: m } of results) {
209
+ const line = `- [${m.kind}${m.scope.kind === 'role' ? `·${m.scope.role}` : ''}] ${m.text}`;
210
+ if (used + line.length + (lines.length ? 1 : 0) > maxChars) continue;
211
+ used += line.length + (lines.length ? 1 : 0); lines.push(line);
212
+ }
213
+ return lines.join('\n');
214
+ },
215
+ };
216
+ const store = { ...raw };
217
+ for (const name of ['write', 'search', 'markUsed', 'unembedded', 'setVector', 'prune', 'remove']) store[name] = (...args) => serial(() => raw[name](...args));
218
+ return store;
219
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ // Atomic disk snapshots; overflow is explicit and never silently drops older messages.
5
+ export function createSyncQueue(path, { max = 200, warn = () => {} } = {}) {
6
+ let rows = [], revoked = [];
7
+ if (path) {
8
+ try { const saved = JSON.parse(readFileSync(path, 'utf8')); rows = saved.rows ?? []; revoked = saved.revoked ?? []; }
9
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
10
+ }
11
+ const persist = () => {
12
+ if (!path) return;
13
+ mkdirSync(dirname(path), { recursive: true });
14
+ const temp = `${path}.${randomUUID()}.tmp`;
15
+ writeFileSync(temp, JSON.stringify({ rows, revoked }), { mode: 0o600 }); renameSync(temp, path);
16
+ };
17
+ return {
18
+ push(row) {
19
+ if (rows.length >= max) { warn('OpenViking sync queue full; memory retained locally'); throw new Error('OpenViking sync queue full'); }
20
+ rows.push({ ...row, queueId: randomUUID() }); persist();
21
+ },
22
+ peek: (n = 40) => rows.slice(0, n),
23
+ ack(batch) { const ids = new Set(batch.map(r => r.queueId)); rows = rows.filter(r => !ids.has(r.queueId)); persist(); },
24
+ revoke(texts) { revoked = [...new Set([...revoked, ...texts])]; persist(); },
25
+ revoked: () => [...revoked],
26
+ size: () => rows.length,
27
+ };
28
+ }
@@ -0,0 +1,85 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // The (profile-wide) model policy is applied to a resolved role before the failover state chooses a route.
3
+ // Entries are exact provider/model routes, or prefix rules ending in "*" that match either the full
4
+ // route or the bare model id (so "@cf/*" covers every provider). No lower-tier substitution.
5
+ export const routeKey = route => `${route.provider}/${route.model}`;
6
+ export const isPrefixRule = entry => entry.endsWith('*');
7
+ export function validateDisabledRoutes(value) {
8
+ if (!Array.isArray(value) || value.length > 2000) throw new Error(modelText('model-policy.311'));
9
+ for (const v of value) {
10
+ if (typeof v !== 'string' || !v.trim() || v.length > 512 || /[\r\n\0\s]/.test(v)) throw new Error(modelText('model-policy.312'));
11
+ if (isPrefixRule(v)) {
12
+ if (v.length < 3 || v.slice(0, -1).includes('*')) throw new Error(modelText('model-policy.313', { 0: v }));
13
+ } else if (!v.includes('/') || v.includes('*')) throw new Error(modelText('model-policy.314'));
14
+ }
15
+ return [...new Set(value)].sort();
16
+ }
17
+ /** Predicate over provider/model keys for a validated disabled list. */
18
+ export function routeMatcher(disabledRoutes = []) {
19
+ const exact = new Set(), prefixes = [];
20
+ for (const entry of validateDisabledRoutes(disabledRoutes)) (isPrefixRule(entry) ? prefixes.push(entry.slice(0, -1)) : exact.add(entry));
21
+ return key => {
22
+ if (exact.has(key)) return true;
23
+ const model = key.slice(key.indexOf('/') + 1);
24
+ return prefixes.some(p => key.startsWith(p) || model.startsWith(p));
25
+ };
26
+ }
27
+ export function applyModelPolicy(role, disabledRoutes = []) {
28
+ const disabled = routeMatcher(disabledRoutes);
29
+ if (role.unassigned || !role.provider || !role.model) return role;
30
+ const candidates = [{ provider: role.provider, model: role.model }, ...(role.fallbacks ?? [])];
31
+ const allowed = candidates.filter(r => !disabled(routeKey(r)));
32
+ if (!allowed.length) throw new Error(modelText('model-policy.315', { 0: role.displayName ?? role.id }));
33
+ const [primary, ...fallbacks] = allowed;
34
+ // A disabled long-context route is dropped; requests then stay on the allowed primary and backups.
35
+ const { longContext, ...rest } = role;
36
+ const keep = longContext && !disabled(routeKey(longContext)) ? { longContext } : {};
37
+ return { ...rest, ...primary, fallbacks, ...keep, policyExcluded: candidates.filter(r => disabled(routeKey(r))).map(routeKey) };
38
+ }
39
+ /** Roles as requests see them under the policy: excluded routes dropped, roles with no allowed route marked blocked. */
40
+ export function policyRoleView(roles, disabledRoutes = []) {
41
+ return roles.map(role => {
42
+ try { return applyModelPolicy(role, disabledRoutes); }
43
+ catch (error) { return { ...role, provider: undefined, model: undefined, fallbacks: [], blocked: error.message }; }
44
+ });
45
+ }
46
+ export function allocationPreview(roles, disabledRoutes) {
47
+ return roles.map(role => {
48
+ try {
49
+ const resolved = applyModelPolicy(role, disabledRoutes);
50
+ return { id: role.id, displayName: role.displayName ?? role.id,
51
+ status: resolved.provider && resolved.model ? 'eligible' : 'unassigned',
52
+ primary: resolved.provider && resolved.model ? routeKey(resolved) : null,
53
+ fallbacks: (resolved.fallbacks ?? []).map(routeKey) };
54
+ } catch (error) {
55
+ return { id: role.id, displayName: role.displayName ?? role.id, status: 'blocked', primary: null, fallbacks: [], reason: error.message };
56
+ }
57
+ });
58
+ }
59
+ export function modelPolicyRows(tiers, roles, disabledRoutes = [], catalog = []) {
60
+ const disabledList = validateDisabledRoutes(disabledRoutes);
61
+ const isDisabled = routeMatcher(disabledList);
62
+ const rows = new Map();
63
+ for (const entry of catalog) {
64
+ const slash = typeof entry === 'string' ? entry.indexOf('/') : -1;
65
+ const candidate = typeof entry === 'string'
66
+ ? { provider: entry.slice(0, slash), model: entry.slice(slash + 1) } : entry;
67
+ if (!candidate?.provider || !candidate.model || (typeof entry === 'string' && slash < 1)) continue;
68
+ const key = routeKey(candidate);
69
+ rows.set(key, { key, provider: candidate.provider, model: candidate.model,
70
+ tiers: candidate.tier ? [candidate.tier] : [], primaryFor: [], fallbackFor: [],
71
+ disabled: isDisabled(key), verified: false });
72
+ }
73
+ for (const role of roles) {
74
+ if (!role.provider || !role.model) continue;
75
+ for (const [index, route] of [{ provider: role.provider, model: role.model }, ...(role.fallbacks ?? [])].entries()) {
76
+ const key = routeKey(route);
77
+ const row = rows.get(key) ?? { key, provider: route.provider, model: route.model, tiers: [], primaryFor: [], fallbackFor: [], disabled: isDisabled(key), verified: false };
78
+ if (!row.tiers.includes(role.tier)) row.tiers.push(role.tier);
79
+ row[index === 0 ? 'primaryFor' : 'fallbackFor'].push(role.id);
80
+ rows.set(key, row);
81
+ }
82
+ }
83
+ // Catalog tier membership may contain unallocated IDs; don't invent a provider for them.
84
+ return { rows: [...rows.values()], tierCatalog: tiers, disabledRoutes: disabledList, allocations: allocationPreview(roles, disabledRoutes) };
85
+ }