@aiwuk/team 0.0.0-stage → 5.0.8

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 (117) hide show
  1. package/CHANGELOG.md +175 -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 +113 -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-models.js +84 -0
  69. package/member-task-control.js +242 -0
  70. package/memory-backends.js +89 -0
  71. package/memory-embed.js +70 -0
  72. package/memory-hub.js +140 -0
  73. package/memory-openviking.js +101 -0
  74. package/memory-redact.js +34 -0
  75. package/memory-store.js +270 -0
  76. package/memory-sync-queue.js +28 -0
  77. package/model-policy.js +85 -0
  78. package/model-prefs.js +104 -0
  79. package/observability-api.js +76 -0
  80. package/observability-projection.js +116 -0
  81. package/onboarding.js +47 -0
  82. package/opencode.js +74 -0
  83. package/package.json +155 -4
  84. package/plan-gate.js +92 -0
  85. package/preset-guard.js +47 -0
  86. package/request-evidence.js +41 -0
  87. package/roles.js +105 -0
  88. package/roster-defaults.js +70 -0
  89. package/route-health.js +39 -0
  90. package/runtime-observability.js +78 -0
  91. package/runtime-view.js +21 -0
  92. package/scripts/memory-cli.mjs +138 -0
  93. package/search-engines.js +119 -0
  94. package/search.js +164 -0
  95. package/session-policy.js +59 -0
  96. package/session-rotation.js +206 -0
  97. package/settings-schema.js +40 -0
  98. package/shared/managed-config.js +84 -0
  99. package/single-model.js +44 -0
  100. package/skills/project-role-theme/SKILL.md +46 -0
  101. package/task-bridge.js +32 -0
  102. package/task-control-api.js +36 -0
  103. package/task-control-audit.js +60 -0
  104. package/team.js +101 -0
  105. package/teammate-context.js +120 -0
  106. package/teammate-wait.js +18 -0
  107. package/template.js +46 -0
  108. package/theme-plugin.js +172 -0
  109. package/theme.js +115 -0
  110. package/token-discipline.js +116 -0
  111. package/token-metrics.js +37 -0
  112. package/tool-names.js +10 -0
  113. package/tool-schema.js +23 -0
  114. package/transcribe.js +104 -0
  115. package/ui.js +113 -0
  116. package/usage.js +96 -0
  117. package/workflow.js +246 -0
@@ -0,0 +1,116 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Token rules for the team (part of the iron rules). Measured on this project: prompt caching already works
3
+ // (93–98% of prompt tokens are cache reads), so cost is driven by how much context every step resends —
4
+ // sessions ran at ~150k tokens per step because DSH compacts only at 80% of the window. The levers are a small,
5
+ // stable prefix and a small working context, without breaking the cache:
6
+ // 1. Idle compaction: when a team member goes idle above `idleCompactRatio` of its window, compact it then.
7
+ // The provider cache expires after a few idle minutes anyway, so this costs almost no cache.
8
+ // 2. Oversized command/search output is cut to head + tail at once (the full text is spilled to a file),
9
+ // instead of riding along in every later step until DSH's own pruner runs at compaction time.
10
+ // 3. The prefix stays stable: plugin context only changes on events, and routes do not flip per step.
11
+ import { mkdir, writeFile } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { promptTokens } from './long-context.js';
15
+ import { ownsCapabilityTeam } from './preset-guard.js';
16
+ import { createTokenMetrics, usageSample } from './token-metrics.js';
17
+ import { restrictiveToolName } from './tool-names.js';
18
+ export const TOKEN_RULES = { idleCompactRatio: 0.35, trimToolOutputChars: 6000, keepHeadChars: 3000, keepTailChars: 1500 };
19
+ const KEYS = Object.keys(TOKEN_RULES);
20
+ /** Tools whose output is a log or listing rather than content the model must read whole. */
21
+ export const TRIMMED_TOOLS = new Set(['bash', 'pwsh', 'grep', 'glob', 'job_output', 'web_fetch', 'web_search', 'omni_fetch', 'omni_search', 'session_search', 'session_event_search']);
22
+ export function validateTokenRules(value) {
23
+ if (value === undefined || value === null) return { ...TOKEN_RULES };
24
+ if (value === false) return false;
25
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(modelText('token-discipline.552'));
26
+ const extra = Object.keys(value).filter(k => !KEYS.includes(k));
27
+ if (extra.length) throw new Error(modelText('token-discipline.553', { 0: extra.join(', ') }));
28
+ const out = { ...TOKEN_RULES, ...value };
29
+ if (typeof out.idleCompactRatio !== 'number' || out.idleCompactRatio < 0.1 || out.idleCompactRatio > 0.9) throw new Error(modelText('token-discipline.554'));
30
+ for (const k of ['trimToolOutputChars', 'keepHeadChars', 'keepTailChars']) if (!Number.isSafeInteger(out[k]) || out[k] < 200 || out[k] > 200000) throw new Error(modelText('token-discipline.555', { 0: k }));
31
+ if (out.keepHeadChars + out.keepTailChars >= out.trimToolOutputChars) throw new Error(modelText('token-discipline.556'));
32
+ return out;
33
+ }
34
+ /** Model-facing token rules, appended to the file rules in the team policy. */
35
+ export function tokenRuleText(rules) {
36
+ if (!rules) return '';
37
+ return modelText('token-discipline.557', { 0: rules.trimToolOutputChars, 1: Math.round(rules.idleCompactRatio * 100) });
38
+ }
39
+ /** Head + tail of a long text with a pointer to the full copy. Pure. */
40
+ export function trimText(text, rules, spillPath) {
41
+ const head = text.slice(0, rules.keepHeadChars);
42
+ const tail = text.slice(-rules.keepTailChars);
43
+ return modelText('token-discipline.558', { 0: head, 1: text.length - head.length - tail.length, 2: text.length, 3: spillPath, 4: tail });
44
+ }
45
+ export function createTokenDiscipline(ctx, team, { spillDir = join(tmpdir(), 'capability-team-spill'), rulesOf, trimmedTools = TRIMMED_TOOLS, ratesOf } = {}) {
46
+ const compacting = new WeakSet();
47
+ const metrics = createTokenMetrics({ ratesOf });
48
+ // `compaction` is not in the plugin's inject list, and cordis throws on a direct read of an un-injected
49
+ // service (that once stopped the DSH host). It arrives through ctx.inject when the host provides it.
50
+ let compactionService;
51
+ const compaction = () => compactionService;
52
+ const rules = agent => {
53
+ if (rulesOf) return rulesOf(agent);
54
+ const project = team.pinned(agent);
55
+ if (!project) return TOKEN_RULES;
56
+ try { return team.loadSync(project, agent).effective.workflow.tokens; } catch { return TOKEN_RULES; }
57
+ };
58
+ const isMember = agent => ownsCapabilityTeam(ctx, agent) && !!ctx.agentTeams.tryMembership(agent);
59
+ const ratioOf = agent => {
60
+ try {
61
+ const projections = ctx.get?.('sessionProjections');
62
+ const tokens = promptTokens(projections, agent.session);
63
+ const window = projections?.stateOf(agent.session, 'contextPressure')?.contextWindow;
64
+ return tokens !== undefined && window ? tokens / window : undefined;
65
+ } catch { return undefined; }
66
+ };
67
+ async function compactIdleUnsafe(agent) {
68
+ const r = rules(agent);
69
+ const service = compaction();
70
+ if (!r || compacting.has(agent) || !isMember(agent) || typeof service?.compactNow !== 'function') return false;
71
+ const ratio = ratioOf(agent);
72
+ if (ratio === undefined || ratio < r.idleCompactRatio) return false;
73
+ compacting.add(agent);
74
+ const started = Date.now();
75
+ try { const done = (await service.compactNow(agent, AbortSignal.timeout(180000))) !== null; if (done) metrics.compact(agent, Date.now() - started); return done; }
76
+ finally { compacting.delete(agent); }
77
+ }
78
+ const api = {
79
+ metrics,
80
+ /** Compact an idle member whose context is above the idle threshold. Never throws; returns true when it ran. */
81
+ async compactIdle(agent) {
82
+ // busy (new work arrived), nothing compactable or any host error: skip, and try again on the next idle
83
+ try { return await compactIdleUnsafe(agent); } catch { return false; }
84
+ },
85
+ /** Post-execute decision: trim an oversized log/listing result for a team member. */
86
+ async trim(exec, result) {
87
+ const r = rules(exec.agent);
88
+ if (!r || !new Set(trimmedTools).has(restrictiveToolName(exec.name, new Set(trimmedTools))) || !isMember(exec.agent) || !Array.isArray(result?.content)) return undefined;
89
+ const text = result.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
90
+ if (text.length <= r.trimToolOutputChars || result.content.some(b => b.type !== 'text')) return undefined;
91
+ await mkdir(spillDir, { recursive: true });
92
+ const path = join(spillDir, `${String(exec.callId ?? Date.now()).replace(/[^\w.-]/g, '_')}.txt`);
93
+ await writeFile(path, text);
94
+ const trimmed = trimText(text, r, path);
95
+ metrics.trim(exec.agent, text.length, trimmed.length);
96
+ return [{ type: 'text', text: trimmed }];
97
+ },
98
+ register() {
99
+ ctx.on('agent/request', async (payload, next) => {
100
+ const result = await next();
101
+ if (isMember(payload.agent)) metrics.sample(payload.agent, payload.turn, payload.step, usageSample(ctx.get?.('sessionProjections'), payload.agent.session));
102
+ return result;
103
+ });
104
+ try { ctx.inject?.(['compaction'], scoped => { compactionService = scoped.compaction; scoped.effect?.(() => () => { compactionService = undefined; }); }); } catch { /* host without compaction */ }
105
+ // Listeners must never reject: an unhandled rejection inside a host event stops the DSH host.
106
+ ctx.on('agent/status', ({ agent, status }) => { if (status === 'idle') api.compactIdle(agent).catch(() => {}); });
107
+ ctx.on('tools/post-execute', async (exec, result, next) => {
108
+ const decision = await next();
109
+ if (decision?.kind !== 'accept' || decision.content !== undefined || result?.isError) return decision;
110
+ try { const content = await api.trim(exec, result); return content ? { ...decision, content } : decision; }
111
+ catch { return decision; }
112
+ });
113
+ },
114
+ };
115
+ return api;
116
+ }
@@ -0,0 +1,37 @@
1
+ // Numeric telemetry only; never injected into prompts. Provider billing rates are optional.
2
+ export function usageSample(projections, session) {
3
+ try {
4
+ const state = projections?.stateOf(session, 'tokenUsage');
5
+ const value = state?.totals ?? state;
6
+ return value ? { ...value, inputTokens: value.inputTokens ?? value.uncachedInputTokens } : null;
7
+ } catch { return null; }
8
+ }
9
+ const n = x => Number.isFinite(x) && x >= 0 ? x : 0;
10
+ export function estimateCost(usage, rates) {
11
+ if (!rates) return null;
12
+ return (n(usage.inputTokens) * n(rates.input) + n(usage.cacheReadTokens) * n(rates.cacheRead)
13
+ + n(usage.cacheWriteTokens) * n(rates.cacheWrite) + n(usage.outputTokens) * n(rates.output)) / 1e6;
14
+ }
15
+ export function createTokenMetrics({ now = Date.now, ratesOf = () => null } = {}) {
16
+ const sessions = new WeakMap();
17
+ const state = agent => { if (!sessions.has(agent)) sessions.set(agent, { steps: [], trimmedChars: 0, estimatedSavedTokens: 0, compactions: 0, compactionMs: 0 }); return sessions.get(agent); };
18
+ return {
19
+ sample(agent, turn, step, usage, route) {
20
+ if (!usage) return;
21
+ const row = { turn, step, at: now(), prompt: n(usage.inputTokens) + n(usage.cacheReadTokens) + n(usage.cacheWriteTokens),
22
+ cacheRead: n(usage.cacheReadTokens), cacheWrite: n(usage.cacheWriteTokens), output: n(usage.outputTokens),
23
+ estimatedCost: estimateCost(usage, ratesOf(route)), source: 'sessionProjections.tokenUsage' };
24
+ const s = state(agent), old = s.steps.findIndex(x => x.turn === turn && x.step === step);
25
+ const totals = { prompt: row.prompt, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite, output: row.output };
26
+ const baseline = old >= 0 ? s.steps[old].baseline : (s.lastTotals ?? { prompt: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
27
+ for (const key of Object.keys(totals)) row[key] = Math.max(0, totals[key] - baseline[key]);
28
+ row.baseline = baseline; s.lastTotals = totals;
29
+ row.estimatedCost = estimateCost({ inputTokens: Math.max(0, row.prompt - row.cacheRead - row.cacheWrite), cacheReadTokens: row.cacheRead, cacheWriteTokens: row.cacheWrite, outputTokens: row.output }, ratesOf(route));
30
+ if (old >= 0) s.steps[old] = row; else s.steps.push(row);
31
+ s.steps = s.steps.slice(-1000);
32
+ },
33
+ trim(agent, before, after) { const s = state(agent), saved = Math.max(0, before - after); s.trimmedChars += saved; s.estimatedSavedTokens += Math.ceil(saved / 4); },
34
+ compact(agent, duration) { const s = state(agent); s.compactions++; s.compactionMs += n(duration); },
35
+ snapshot(agent) { return structuredClone(state(agent)); },
36
+ };
37
+ }
package/tool-names.js ADDED
@@ -0,0 +1,10 @@
1
+ // Host aliases supported by the existing workflow: optional MCP envelope and one word prefix.
2
+ // A suffix is NOT proof of native tool identity. Use these candidates only to ADD restrictions,
3
+ // never to grant permissions, mark a tool read-only, or establish sandbox/confirmation trust.
4
+ export function restrictiveToolName(name, known) {
5
+ const raw = String(name ?? '').replace(/^mcp__.+?__/, '');
6
+ const has = value => typeof known === 'function' ? known(value) : known instanceof Set ? known.has(value) : Object.hasOwn(known, value);
7
+ if (has(raw)) return raw;
8
+ const tail = raw.slice(raw.indexOf('_') + 1);
9
+ return raw.includes('_') && has(tail) ? tail : raw;
10
+ }
package/tool-schema.js ADDED
@@ -0,0 +1,23 @@
1
+ // ctx.tools.register sends `parameters` to the model verbatim as JSON Schema (draft 2020-12),
2
+ // so tool parameters must be a raw object-rooted schema, not the defineTool shorthand
3
+ // (`{name: {type, required: true}}`). DSH accepts only this keyword subset.
4
+ const ALLOWED = new Set(['type', 'oneOf', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', 'description', 'title', 'default', 'examples']);
5
+
6
+ /** Object-rooted parameter schema; `required` lists property names. */
7
+ export function params(properties = {}, required = []) {
8
+ for (const name of required) if (!Object.hasOwn(properties, name)) throw new Error(`required parameter ${name} is not declared`);
9
+ return { type: 'object', properties, ...(required.length ? { required } : {}) };
10
+ }
11
+
12
+ /** Throws when a schema uses keywords outside the DSH subset or the shorthand `required: true`. */
13
+ export function assertToolSchema(schema, path = 'parameters') {
14
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) throw new Error(`${path} must be a schema object`);
15
+ for (const [key, value] of Object.entries(schema)) {
16
+ if (!ALLOWED.has(key)) throw new Error(`${path}.${key} is not a supported keyword`);
17
+ if (key === 'required' && !(Array.isArray(value) && value.every(v => typeof v === 'string'))) throw new Error(`${path}.required must be an array of property names`);
18
+ }
19
+ if (path === 'parameters' && schema.type !== 'object') throw new Error('parameters must have type "object" at the root');
20
+ for (const [name, sub] of Object.entries(schema.properties ?? {})) assertToolSchema(sub, `${path}.properties.${name}`);
21
+ if (schema.items) assertToolSchema(schema.items, `${path}.items`);
22
+ for (const [i, sub] of (schema.oneOf ?? []).entries()) assertToolSchema(sub, `${path}.oneOf[${i}]`);
23
+ }
package/transcribe.js ADDED
@@ -0,0 +1,104 @@
1
+ // Speech to text through the configured gateway (integrations.audio). The file is read locally only after the
2
+ // user approves that exact path in DSH's native question box, then uploaded as multipart with its real bytes;
3
+ // fetch's FormData sets the boundary. The key goes into the Authorization header only, never into prompts,
4
+ // tool arguments or logs. A 200 still has to carry text to count as a result.
5
+ import { m, t } from './i18n.js';
6
+ import { open, realpath } from 'node:fs/promises';
7
+ import { basename, isAbsolute, relative, resolve } from 'node:path';
8
+ import { keyOf, refreshCredentials, resolveIntegrations } from './integrations-config.js';
9
+ import { gatewayBase, httpError } from './search.js';
10
+ import { readCapped } from './search-engines.js';
11
+ import { params } from './tool-schema.js';
12
+ export const AUDIO_TYPES = { '.mp3': 'audio/mpeg', '.mp4': 'audio/mp4', '.m4a': 'audio/mp4', '.mpeg': 'audio/mpeg', '.mpga': 'audio/mpeg', '.wav': 'audio/wav', '.webm': 'audio/webm', '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.flac': 'audio/flac' };
13
+ const AUDIO_BASE = { what: m('transcribe.559'), field: 'audio.baseUrl', envName: 'OMNI_AUDIO_BASE_URL' };
14
+ const extOf = path => (/\.[^.\\/]+$/.exec(path)?.[0] ?? '').toLowerCase();
15
+ export function audioSettings(resolved = resolveIntegrations()) {
16
+ return { config: resolved.effective.audio, key: keyOf('audio', resolved), keyEnv: resolved.keys.audio.env };
17
+ }
18
+ /**
19
+ * Check type and size on an open handle, call `approve(size)`, and only then read the bytes. Nothing is read
20
+ * before approval, and the size checked is the size of the file actually read (no stat/read race).
21
+ */
22
+ export async function readAudio(path, maxBytes, approve = async () => {}) {
23
+ // A symlink named meeting.mp3 could point anywhere (e.g. a private key): check and show the real target.
24
+ const unsupported = name => new Error(m('transcribe.560', { 0: extOf(name) || m('transcribe.561'), 1: Object.keys(AUDIO_TYPES).join(' ') }));
25
+ if (!AUDIO_TYPES[extOf(path)]) throw unsupported(path);
26
+ const real = await realpath(path);
27
+ if (!AUDIO_TYPES[extOf(real)]) throw unsupported(real);
28
+ const handle = await open(real, 'r');
29
+ try {
30
+ const stat = await handle.stat();
31
+ if (!stat.isFile()) throw new Error(m('transcribe.562'));
32
+ if (stat.size > maxBytes) throw new Error(m('transcribe.563', { 0: (stat.size / 1048576).toFixed(1), 1: (maxBytes / 1048576).toFixed(1) }));
33
+ if (!stat.size) throw new Error(m('transcribe.564'));
34
+ await approve(stat.size, real);
35
+ const bytes = await handle.readFile();
36
+ if (bytes.byteLength > maxBytes) throw new Error(m('transcribe.565'));
37
+ return bytes;
38
+ } finally { await handle.close(); }
39
+ }
40
+ export async function transcribe({ bytes, name, language, prompt }, { settings, fetcher = fetch, signal } = {}) {
41
+ if (!settings) await refreshCredentials();
42
+ const { config, key, keyEnv } = settings ?? audioSettings();
43
+ if (!key) throw new Error(m('transcribe.566', { 0: keyEnv }));
44
+ const origin = gatewayBase(config.baseUrl, AUDIO_BASE);
45
+ const form = new FormData();
46
+ form.append('file', new Blob([bytes], { type: AUDIO_TYPES[extOf(name)] ?? 'application/octet-stream' }), name);
47
+ if (config.model) form.append('model', config.model);
48
+ if (language) form.append('language', language);
49
+ if (prompt) form.append('prompt', prompt);
50
+ const timeout = AbortSignal.timeout(config.timeoutMs);
51
+ const response = await fetcher(`${origin}${config.path}`, { method: 'POST', redirect: 'error', body: form,
52
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout, headers: { ...config.headers, Authorization: `Bearer ${key}` } });
53
+ if (!response.ok) throw httpError(m('transcribe.559'), response.status);
54
+ const text = await readCapped(response, m('transcribe.559'));
55
+ if (text.includes(key)) throw new Error(m('transcribe.569'));
56
+ let data; try { data = JSON.parse(text); } catch { data = { text }; }
57
+ if (typeof data?.text !== 'string' || !data.text.trim()) throw new Error(m('transcribe.570'));
58
+ return data;
59
+ }
60
+ export async function audioStatus({ settings, fetcher = fetch, signal } = {}) {
61
+ if (!settings) await refreshCredentials();
62
+ const { config, key, keyEnv } = settings ?? audioSettings();
63
+ if (!config.statusPath) throw new Error(m('transcribe.571'));
64
+ if (!key) throw new Error(m('transcribe.572', { 0: keyEnv }));
65
+ const timeout = AbortSignal.timeout(30000);
66
+ const response = await fetcher(`${gatewayBase(config.baseUrl, AUDIO_BASE)}${config.statusPath}`, { method: 'GET', redirect: 'error',
67
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout, headers: { ...config.headers, Authorization: `Bearer ${key}` } });
68
+ if (!response.ok) throw httpError(m('transcribe.573'), response.status);
69
+ const text = await readCapped(response, m('transcribe.573'));
70
+ if (text.includes(key)) throw new Error(m('transcribe.569'));
71
+ let status; try { status = JSON.parse(text); } catch { throw new Error(m('transcribe.576')); }
72
+ return { note: t('transcribe.577'), status };
73
+ }
74
+ /** Ask the user, in DSH's native question box, to allow uploading this one file. */
75
+ async function approve(ctx, exec, path, size, cwd) {
76
+ const allowLabel = t('transcribe.582');
77
+ const outside = cwd && relative(cwd, path).startsWith('..') ? t('transcribe.578') : '';
78
+ const answer = await ctx.userQuestions.ask({ agent: exec.agent, signal: exec.signal, questions: [{
79
+ id: 'upload', header: t('transcribe.579'),
80
+ question: t('transcribe.580'),
81
+ detail: t('transcribe.581', { 0: path, 1: (size / 1048576).toFixed(2), 2: outside }),
82
+ options: [{ label: allowLabel, description: t('transcribe.583') }, { label: t('transcribe.584'), description: t('transcribe.585') }],
83
+ }] });
84
+ if (!answer.answers.find(a => a.id === 'upload')?.selected.includes(allowLabel)) throw new Error(m('transcribe.587'));
85
+ }
86
+ const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] };
87
+ export function applyTranscribe(ctx) {
88
+ ctx.effect(() => ctx.tools.register({ name: 'omni_transcribe', output,
89
+ description: m('transcribe.588'),
90
+ parameters: params({ file_path: { type: 'string', description: m('transcribe.589') }, language: { type: 'string', description: m('transcribe.590') }, prompt: { type: 'string', description: m('transcribe.591') } }, ['file_path']),
91
+ async execute(args, exec) {
92
+ const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal);
93
+ const path = isAbsolute(String(args.file_path)) ? String(args.file_path) : resolve(cwd, String(args.file_path));
94
+ const max = audioSettings().config.maxBytes;
95
+ const bytes = await readAudio(path, max, (size, real) => approve(ctx, exec, real, size, cwd)); // read only after approval
96
+ const result = await transcribe({ bytes, name: basename(path), language: args.language, prompt: args.prompt }, { signal: exec.signal });
97
+ return JSON.stringify(result);
98
+ },
99
+ }));
100
+ ctx.effect(() => ctx.tools.register({ name: 'omni_transcribe_status', output, parameters: params({}, []),
101
+ description: m('transcribe.592'),
102
+ async execute(_args, exec) { return JSON.stringify(await audioStatus({ signal: exec.signal })); },
103
+ }));
104
+ }
package/ui.js ADDED
@@ -0,0 +1,113 @@
1
+ /** Profile-wide, read-only diagnostics; never activates routing for unrelated sessions. */
2
+ import { m, t } from './i18n.js';
3
+ import { readTeamSnapshot, saveTeamPolicy } from './runtime-view.js';
4
+ import { readRequestEvidence, readTeamEvidence } from './request-evidence.js';
5
+ import { applyConfigProjection } from './config-projection.js';
6
+ import { applyObservabilityProjections } from './observability-projection.js';
7
+ import { applyObservabilityApi } from './observability-api.js';
8
+ import { applyTaskControlApi } from './task-control-api.js';
9
+ import { applyDispatchConfirmApi } from './dispatch-confirm.js';
10
+ import { applyOnboardingApi } from './onboarding.js';
11
+ import { registerManagedConfigRoute } from './managed-config-route.js';
12
+ import { schemastery } from './settings-schema.js';
13
+ export const inject = ['commands', 'agentPresets', 'tools'];
14
+ // Only the welcome card's acknowledgement is a live setting (the client renders its own card, so no auto page).
15
+ const z = schemastery('@aiwuk/team');
16
+ export const Config = z ? z.object({ welcomeVersion: z.string().volatile() }) : undefined;
17
+ /**
18
+ * Live roster state from the host's Agent Teams service: status and owned tasks. No log reads, cheap to poll.
19
+ * The host's member provider/model is not kept in sync with real requests, so it is deliberately not forwarded.
20
+ */
21
+ export function teamActivity(ctx, agent) {
22
+ const teams = ctx.get?.('agentTeams');
23
+ if (!teams) return { members: [], tasks: [], unavailable: t('ui.593') };
24
+ let members, tasks = [];
25
+ try {
26
+ members = teams.listMembers(agent).map(m => ({ id: m.id, name: m.name, role: m.role, status: m.status, diagnostics: m.diagnostics ?? [] }));
27
+ } catch (error) { return { members: [], tasks: [], unavailable: t('ui.594', { 0: error.message ?? error }) }; }
28
+ try {
29
+ tasks = (teams.listTasks?.(agent) ?? []).filter(task => task.status !== 'deleted')
30
+ .map(task => ({ id: task.id, subject: task.subject, status: task.status, ...(task.ownerName ? { ownerName: task.ownerName } : {}) }));
31
+ } catch { /* task board is optional for the activity view */ }
32
+ return { members, tasks };
33
+ }
34
+ export async function inspectTeamRuntime(ctx, agent) {
35
+ const preset = ctx.agentPresets.composedPreset(agent.ctx) ?? null;
36
+ const roster = await ctx.agentPresets.list();
37
+ const definition = roster.find(p => p.id === 'capability-team');
38
+ const required = ['capability_team_read', 'capability_dispatch_plan', 'capability_free_task'];
39
+ const visible = ctx.tools.schemas(agent).map(t => t.name);
40
+ const missingTools = required.filter(name => !visible.includes(name));
41
+ let team = null, teamError = null;
42
+ if (preset === 'capability-team') {
43
+ try { team = await readTeamSnapshot(agent); }
44
+ catch (error) { teamError = String(error.message ?? error); }
45
+ }
46
+ const memberEvidence = preset === 'capability-team'
47
+ ? await readTeamEvidence(ctx.get?.('sessionQuery'), ctx.get?.('agentTeams'), agent) : [];
48
+ return {
49
+ sessionId: agent.id, preset, teamServicePresent: !!ctx.get?.('agentTeams'),
50
+ declared: !!definition, activationError: definition?.broken ?? null,
51
+ ready: preset === 'capability-team' && !!definition && !definition.broken && missingTools.length === 0 && team !== null && !teamError,
52
+ missingTools,
53
+ requestEvidence: memberEvidence.find(member => member.sessionId === agent.id) ?? await readRequestEvidence(ctx.get?.('sessionQuery'), agent.id),
54
+ memberEvidence,
55
+ team, teamError,
56
+ activity: preset === 'capability-team' ? teamActivity(ctx, agent) : null,
57
+ explanation: !definition ? t('ui.595')
58
+ : definition.broken ? t('ui.596', { 0: definition.broken })
59
+ : preset !== 'capability-team' ? t('ui.597')
60
+ : missingTools.length ? t('ui.598', { 0: missingTools.join(', ') })
61
+ : teamError ? t('ui.599')
62
+ : team === null ? t('ui.600')
63
+ : t('ui.601'),
64
+ };
65
+ }
66
+ /**
67
+ * The host logs every command result into the conversation, so the panel's snapshot is kept small:
68
+ * catalog rows become [key, tier, primaryFor?, fallbackFor?] tuples, derived/duplicate fields are dropped.
69
+ */
70
+ export function compactStatus(status) {
71
+ const { team, requestEvidence, memberEvidence, ...rest } = status;
72
+ const self = memberEvidence.some(member => member.sessionId === status.sessionId);
73
+ const out = { ...rest, memberEvidence, ...(self ? {} : { requestEvidence }) };
74
+ if (!team) return { ...out, team };
75
+ const { autoMatch: _autoMatch, modelPolicy, ...teamRest } = team;
76
+ if (!modelPolicy) return { ...out, team: teamRest };
77
+ const { tierCatalog: _tiers, rows, ...policyRest } = modelPolicy;
78
+ const tuple = row => {
79
+ const value = [row.key, row.tiers?.[0] ?? null];
80
+ if (row.primaryFor?.length || row.fallbackFor?.length) value.push(row.primaryFor ?? []);
81
+ if (row.fallbackFor?.length) value.push(row.fallbackFor);
82
+ return value;
83
+ };
84
+ return { ...out, team: { ...teamRest, modelPolicy: { ...policyRest, rows: (rows ?? []).map(tuple) } } };
85
+ }
86
+ export async function savePolicyCommand(ctx, { agent, rawInput }) {
87
+ if (ctx.agentPresets.composedPreset(agent.ctx) !== 'capability-team') throw new Error(m('ui.602'));
88
+ const input = JSON.parse(rawInput);
89
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(k => !['disabledRoutes', 'expectedRevision'].includes(k))) throw new Error(m('ui.603'));
90
+ const result = await saveTeamPolicy(agent, input.disabledRoutes, input.expectedRevision);
91
+ return { kind: 'success', text: JSON.stringify(result) };
92
+ }
93
+ export function apply(ctx) {
94
+ applyConfigProjection(ctx);
95
+ applyObservabilityProjections(ctx);
96
+ applyObservabilityApi(ctx);
97
+ applyTaskControlApi(ctx);
98
+ applyDispatchConfirmApi(ctx);
99
+ applyOnboardingApi(ctx);
100
+ registerManagedConfigRoute(ctx, 'team');
101
+ ctx.inject?.(['settings'], scoped => { scoped.effect(() => scoped.settings.configure({ auto: false }, ctx.fiber)); });
102
+ ctx.effect(() => ctx.commands.register({
103
+ name: 'capability-model-policy',
104
+ description: t('ui.604'),
105
+ input: { hint: t('ui.605') },
106
+ handler: invocation => savePolicyCommand(ctx, invocation),
107
+ }));
108
+ ctx.effect(() => ctx.commands.register({
109
+ name: 'capability-status',
110
+ description: t('ui.606'),
111
+ handler: async ({ agent }) => ({ kind: 'success', text: JSON.stringify(compactStatus(await inspectTeamRuntime(ctx, agent))) }),
112
+ }));
113
+ }
package/usage.js ADDED
@@ -0,0 +1,96 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Own-session usage. Durable assistant/attempt settles a failed request, so the next
3
+ // settlement is a distinct attempt even when plugin retry emits no llm/retry-started.
4
+ // Token usage per team member:
5
+ // each Assistant settlement contributes its last usage sample; a failed assistant/attempt
6
+ // and llm/retry-started close the slot. Same-slot message corrections replace it. Samples are attributed to the
7
+ // route of the latest request header, which is the route the attempt was sent to.
8
+ // Why this fold stays instead of DSH's native `tokenUsage` projection: the native one has no route dimension (no
9
+ // per-model rows, no lastRoute) and its init ignores the fork-inherited prefix, so a forked member would be charged
10
+ // for its parent's tokens and role budgets (maxTokens) would trip early. Wall times (turns, steps, llm/tool/ttft/decode)
11
+ // are not folded here; they come from the native `sessionStats` projection.
12
+ const BUCKETS = ['uncachedInputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'];
13
+ export const zeroUsage = () => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
14
+ const numeric = value => Number.isFinite(value) && value >= 0 ? Math.min(value, Number.MAX_SAFE_INTEGER) : 0;
15
+ const bucketsFrom = usage => ({ uncachedInputTokens: numeric(usage.inputTokens), outputTokens: numeric(usage.outputTokens),
16
+ cacheReadTokens: numeric(usage.cacheReadTokens), cacheWriteTokens: numeric(usage.cacheWriteTokens) });
17
+ const shift = (into, buckets, sign) => { for (const key of BUCKETS) into[key] = Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, into[key] + sign * buckets[key])); };
18
+ /** Budget-counted tokens = uncached input + cache write + output; excludes cache reads and is not a bill amount. */
19
+ export const billable = usage => (usage?.uncachedInputTokens ?? 0) + (usage?.cacheWriteTokens ?? 0) + (usage?.outputTokens ?? 0);
20
+ export function sampleOf(event) {
21
+ if (event.type === 'assistant/message' && event.data?.usage && typeof event.data.usage === 'object' && !Array.isArray(event.data.usage)) return event.data.usage;
22
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined;
23
+ const stream = Array.isArray(event.data?.stream) ? event.data.stream : [];
24
+ for (let i = stream.length - 1; i >= 0; i--) if (stream[i]?.type === 'chunk' && stream[i].chunk?.type === 'usage') return stream[i].chunk.usage;
25
+ return undefined;
26
+ }
27
+ export const OWN_USAGE_KEY = 'capabilityTeamUsage';
28
+ export function initOwnUsage(inherited = 0) {
29
+ return { inherited, cursor: -1, totals: zeroUsage(), routes: [], route: null, last: null, settledAttempt: false, reportedSamples: 0, attemptsWithoutUsage: 0, coverage: 'unknown', truncated: false };
30
+ }
31
+ /** Pure incremental fold. A durable attempt is a settlement, not an intermediate usage chunk. */
32
+ export function applyOwnUsage(state, event) {
33
+ if (Number.isSafeInteger(event.seq) && (event.seq < state.inherited || event.seq <= state.cursor)) return state;
34
+ if (!['request/header', 'llm/retry-started', 'assistant/attempt', 'assistant/message'].includes(event.type)) return state;
35
+ const next = { ...state, cursor: Number.isSafeInteger(event.seq) ? event.seq : state.cursor };
36
+ if (event.type === 'request/header') {
37
+ const config = event.data?.header?.config;
38
+ if (typeof config?.provider === 'string' && config.provider.length <= 128 && typeof config?.model === 'string' && config.model.length <= 512) next.route = `${config.provider}/${config.model}`;
39
+ // Headers can be deduplicated by the host. Never rely on a header alone as an attempt boundary.
40
+ return next;
41
+ }
42
+ if (event.type === 'llm/retry-started') {
43
+ if (state.last?.turn === event.data?.turn && state.last?.step === event.data?.step) next.last = null;
44
+ next.settledAttempt = false;
45
+ return next;
46
+ }
47
+ const sample = sampleOf(event);
48
+ if (!sample || typeof sample !== 'object' || Array.isArray(sample) || !['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some(key => typeof sample[key] === 'number')) {
49
+ next.attemptsWithoutUsage += 1;
50
+ next.last = null;
51
+ next.settledAttempt = event.type === 'assistant/attempt';
52
+ next.coverage = 'partial';
53
+ return next;
54
+ }
55
+ const totals = { ...state.totals }, routes = new Map(state.routes.map(row => [row.route, { ...row }]));
56
+ const buckets = bucketsFrom(sample), key = state.route ?? 'unknown';
57
+ if (Object.values(sample).some(value => typeof value === 'number' && (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER))) next.attemptsWithoutUsage += 1;
58
+ const { turn, step } = event.data;
59
+ if (!state.settledAttempt && state.last?.turn === turn && state.last?.step === step) {
60
+ shift(totals, state.last.buckets, -1);
61
+ if (routes.has(state.last.route)) shift(routes.get(state.last.route), state.last.buckets, -1);
62
+ }
63
+ if (!routes.has(key)) routes.set(key, { route: key, ...zeroUsage() });
64
+ shift(totals, buckets, 1); shift(routes.get(key), buckets, 1);
65
+ if (routes.size > 24 || BUCKETS.some(k => totals[k] === Number.MAX_SAFE_INTEGER)) next.truncated = true;
66
+ next.totals = totals; next.routes = [...routes.values()].slice(-24); next.last = { turn, step, route: key, buckets };
67
+ next.settledAttempt = event.type === 'assistant/attempt';
68
+ next.reportedSamples += 1;
69
+ next.coverage = next.attemptsWithoutUsage ? 'partial' : 'complete';
70
+ return next;
71
+ }
72
+ /** `lastRoute` is the route of this session's latest request header: the model a request was actually sent to. */
73
+ export function ownUsageView(state) {
74
+ return { schemaVersion: 1, scope: 'own-session-events', totals: state.totals, routes: state.routes, lastRoute: state.route ?? null,
75
+ reportedSamples: state.reportedSamples, attemptsWithoutUsage: state.attemptsWithoutUsage, coverage: state.coverage, truncated: state.truncated };
76
+ }
77
+ /** Usage of one session log, excluding events inherited from a parent session. */
78
+ export function foldUsage(snapshot) {
79
+ const own = snapshot.events.slice(snapshot.inheritedEventCount ?? 0).reduce(applyOwnUsage, initOwnUsage());
80
+ return { totals: own.totals, routes: own.routes.filter(u => billable(u) || u.cacheReadTokens), lastRoute: own.route ?? null,
81
+ reportedSamples: own.reportedSamples, attemptsWithoutUsage: own.attemptsWithoutUsage, coverage: own.coverage };
82
+ }
83
+ /** Own-session budget totals only. Missing/unknown usage does not block; no inherited host fallback. */
84
+ export function sessionUsage(projections, session) {
85
+ try {
86
+ const state = projections?.stateOf(session, OWN_USAGE_KEY);
87
+ return state ? state.totals ?? state : undefined;
88
+ } catch { return undefined; }
89
+ }
90
+ /** Denial for a role that has used up its per-session token budget, or undefined. */
91
+ export function budgetDenial(role, usage) {
92
+ if (!role.maxTokens || !usage) return undefined;
93
+ const used = billable(usage);
94
+ if (used < role.maxTokens) return undefined;
95
+ return modelText('usage.607', { 0: role.displayName ?? role.id, 1: role.capability, 2: used, 3: role.maxTokens });
96
+ }