@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.
- package/CHANGELOG.md +156 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/README.zh-CN.md +150 -0
- package/access.js +93 -0
- package/activity-summary.js +36 -0
- package/allocation.js +34 -0
- package/board-dispatch.js +141 -0
- package/brand.js +41 -0
- package/client/00-core-zh.js +284 -0
- package/client/01-i18n-en.js +265 -0
- package/client/02-styles.js +336 -0
- package/client/02b-stage-styles.js +65 -0
- package/client/03-shared.js +82 -0
- package/client/03b-collapsible-list.js +34 -0
- package/client/03c-config-panel.js +605 -0
- package/client/04-tool-cards.js +110 -0
- package/client/04a-dispatch-confirm.js +162 -0
- package/client/04b-more-cards.js +78 -0
- package/client/04c-model-picker.js +68 -0
- package/client/04d-confirm-dock.js +40 -0
- package/client/05-policy.js +217 -0
- package/client/06-usage.js +195 -0
- package/client/06b-model-source.js +67 -0
- package/client/07-board-parts.js +312 -0
- package/client/07a-role-theme.js +25 -0
- package/client/08-artifacts-report.js +375 -0
- package/client/08a-task-control.js +126 -0
- package/client/08b-stage-sprites.js +97 -0
- package/client/08c-stage-colors.js +55 -0
- package/client/08c-stage-scene.js +238 -0
- package/client/08d-stage-adapter.js +65 -0
- package/client/08e-stage-activity.js +83 -0
- package/client/09-team-board.js +181 -0
- package/client/09b-role-view.js +95 -0
- package/client/09c-model-tabs.js +20 -0
- package/client/10-overview.js +364 -0
- package/client/10b-onboarding.js +121 -0
- package/client/11-register.js +249 -0
- package/client.js +5053 -0
- package/config-projection.js +40 -0
- package/config.js +411 -0
- package/cordis.patch.yml +110 -0
- package/defaults.js +222 -0
- package/dispatch-confirm.js +293 -0
- package/dispatch-routes-store.js +31 -0
- package/failover.js +85 -0
- package/file-limits.js +119 -0
- package/free-worker.mjs +125 -0
- package/http-guard.js +14 -0
- package/i18n/roles.en.json +23 -0
- package/i18n/roles.zh.json +23 -0
- package/i18n/server.en.json +659 -0
- package/i18n/server.zh.json +659 -0
- package/i18n.js +34 -0
- package/icon.svg +7 -0
- package/index.js +111 -0
- package/integrations-config.js +301 -0
- package/integrations-messages.js +62 -0
- package/integrations.js +17 -0
- package/lead-route.js +30 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/long-context.js +30 -0
- package/managed-config-route.js +49 -0
- package/managed-ref.js +38 -0
- package/managed-resolve.js +47 -0
- package/member-task-control.js +242 -0
- package/memory-backends.js +89 -0
- package/memory-embed.js +70 -0
- package/memory-hub.js +150 -0
- package/memory-openviking.js +101 -0
- package/memory-redact.js +34 -0
- package/memory-store.js +219 -0
- package/memory-sync-queue.js +28 -0
- package/model-policy.js +85 -0
- package/model-prefs.js +104 -0
- package/observability-api.js +76 -0
- package/observability-projection.js +141 -0
- package/onboarding.js +47 -0
- package/opencode.js +74 -0
- package/package.json +153 -4
- package/plan-gate.js +92 -0
- package/preset-guard.js +47 -0
- package/request-evidence.js +41 -0
- package/roles.js +69 -0
- package/roster-defaults.js +70 -0
- package/route-health.js +39 -0
- package/runtime-observability.js +78 -0
- package/runtime-view.js +21 -0
- package/scripts/memory-cli.mjs +138 -0
- package/search-engines.js +119 -0
- package/search.js +164 -0
- package/session-policy.js +59 -0
- package/session-rotation.js +206 -0
- package/settings-schema.js +40 -0
- package/shared/managed-config.js +84 -0
- package/single-model.js +44 -0
- package/skills/project-role-theme/SKILL.md +46 -0
- package/task-bridge.js +32 -0
- package/task-control-api.js +36 -0
- package/task-control-audit.js +60 -0
- package/team.js +101 -0
- package/teammate-context.js +120 -0
- package/teammate-wait.js +18 -0
- package/template.js +46 -0
- package/theme-plugin.js +172 -0
- package/theme.js +115 -0
- package/token-discipline.js +116 -0
- package/token-metrics.js +37 -0
- package/tool-names.js +10 -0
- package/tool-schema.js +23 -0
- package/transcribe.js +104 -0
- package/ui.js +113 -0
- package/usage.js +92 -0
- package/workflow.js +246 -0
package/theme-plugin.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { m as modelText, t } from './i18n.js';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { roleView, describeChanges, resolveRole, seed } from './config.js';
|
|
4
|
+
import { modelCatalog, changedRoutes, verifyRoutes } from './allocation.js';
|
|
5
|
+
import { saveProject } from './theme.js';
|
|
6
|
+
import { params } from './tool-schema.js';
|
|
7
|
+
import { registerTeamReader, registerPolicyWriter } from './runtime-view.js';
|
|
8
|
+
import { modelPolicyRows, applyModelPolicy, policyRoleView } from './model-policy.js';
|
|
9
|
+
import { accessSummary } from './access.js';
|
|
10
|
+
import { toTemplate, templatePatch, TEMPLATE_FORMAT } from './template.js';
|
|
11
|
+
import { ROLE_THEMES, themePatch } from './roster-defaults.js';
|
|
12
|
+
import { singleModelFrom } from './single-model.js';
|
|
13
|
+
const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] };
|
|
14
|
+
const ENTRY_RULE = modelText('theme-plugin.506');
|
|
15
|
+
/** Model-facing team context for one Agent, rebuilt from the pinned project on every step. */
|
|
16
|
+
export function teamContext(team, agent, membership, failover, workflow, planGate) {
|
|
17
|
+
const project = team.pinned(agent);
|
|
18
|
+
if (!project) return modelText('theme-plugin.507');
|
|
19
|
+
let loaded;
|
|
20
|
+
try { loaded = team.loadSync(project, agent); }
|
|
21
|
+
catch (error) { return modelText('theme-plugin.508', { 0: error.message }); }
|
|
22
|
+
let self;
|
|
23
|
+
try {
|
|
24
|
+
const { failover: _policy, access, ...role } = applyModelPolicy(resolveRole(membership, loaded.effective), loaded.policy?.disabledRoutes);
|
|
25
|
+
self = role;
|
|
26
|
+
const limits = accessSummary(access);
|
|
27
|
+
if (limits) self.access = modelText('theme-plugin.509', { 0: limits });
|
|
28
|
+
const planState = planGate?.status(agent, membership, role);
|
|
29
|
+
if (planState) self.plan = planState;
|
|
30
|
+
const live = failover?.status(agent);
|
|
31
|
+
const allowed = [role, ...(role.fallbacks ?? [])].map(r => `${r.provider}/${r.model}`);
|
|
32
|
+
const wide = role.longContext ? `${role.longContext.provider}/${role.longContext.model}` : undefined;
|
|
33
|
+
if (live?.active && live.active === wide) self.currentRoute = modelText('theme-plugin.510', { 0: wide });
|
|
34
|
+
else if (live && !live.primary && allowed.includes(live.active)) self.currentRoute = modelText('theme-plugin.511', { 0: live.active });
|
|
35
|
+
} catch (error) { self = { error: error.message }; }
|
|
36
|
+
const roster = roleView(loaded.effective).map(entry => {
|
|
37
|
+
try {
|
|
38
|
+
const { id, displayName, capability, whenToUse, provider, model, access, singleModel } = applyModelPolicy(entry, loaded.policy?.disabledRoutes);
|
|
39
|
+
const limits = accessSummary(access);
|
|
40
|
+
return { id, displayName, capability, ...(whenToUse ? { whenToUse } : {}), model: provider ? `${provider}/${model}${singleModel ? modelText('theme-plugin.512') : ''}` : modelText('theme-plugin.513'), ...(limits ? { access: limits } : {}) };
|
|
41
|
+
} catch (error) { return { id: entry.id, displayName: entry.displayName, model: modelText('theme-plugin.514'), error: error.message }; }
|
|
42
|
+
});
|
|
43
|
+
const status = membership?.role === 'lead' ? workflow?.status(agent, loaded.effective.workflow) : undefined;
|
|
44
|
+
return modelText('theme-plugin.515', { 0: loaded.effective.name, 1: loaded.revision, 2: project, 3: JSON.stringify(self), 4: JSON.stringify(roster), 5: status ? `\n${status}` : '' });
|
|
45
|
+
}
|
|
46
|
+
async function confirm(ctx, exec, lines, revision) {
|
|
47
|
+
const answer = await ctx.userQuestions.ask({ agent: exec.agent, signal: exec.signal, questions: [{
|
|
48
|
+
id: 'confirm', header: t('theme-plugin.516'),
|
|
49
|
+
question: t('theme-plugin.517', { 0: lines.length }),
|
|
50
|
+
detail: t('theme-plugin.518', { 0: lines.join('\n'), 1: revision }),
|
|
51
|
+
options: [{ label: t('theme-plugin.519'), description: t('theme-plugin.520') }, { label: t('theme-plugin.521'), description: t('theme-plugin.522') }],
|
|
52
|
+
}] });
|
|
53
|
+
const item = answer.answers.find(a => a.id === 'confirm');
|
|
54
|
+
if (!item?.selected.includes(t('theme-plugin.523'))) throw new Error(modelText('theme-plugin.524', { 0: item?.custom ? modelText('theme-plugin.525', { 0: item.custom }) : '' }));
|
|
55
|
+
}
|
|
56
|
+
export function apply(ctx, team, failover, workflow, planGate, taskControl) {
|
|
57
|
+
const content = readFileSync(new URL('./skills/project-role-theme/SKILL.md', import.meta.url), 'utf8');
|
|
58
|
+
ctx.effect(() => ctx.skills.register({ name: 'project-role-theme', source: 'runtime', content,
|
|
59
|
+
description: modelText('theme-plugin.526') }));
|
|
60
|
+
ctx.effect(() => ctx.tools.register({ name: 'capability_team_read', parameters: params(), output,
|
|
61
|
+
description: modelText('theme-plugin.527'),
|
|
62
|
+
async execute(_args, exec) {
|
|
63
|
+
await team.refresh();
|
|
64
|
+
const { project, revision, overrides, effective, policy } = await team.load(exec.agent);
|
|
65
|
+
const { report, unknown, errors, detectedAt } = team.detection();
|
|
66
|
+
return JSON.stringify({ project, revision, defaults: team.base, overrides, effective, roles: policyRoleView(roleView(effective), policy.disabledRoutes), coolingRoutes: failover?.cooling() ?? [],
|
|
67
|
+
autoMatch: { detectedAt, report, unrecognized: unknown.slice(0, 50), unrecognizedCount: unknown.length, errors } });
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
ctx.effect(() => ctx.tools.register({ name: 'capability_model_catalog', parameters: params(), output,
|
|
71
|
+
description: modelText('theme-plugin.528'),
|
|
72
|
+
async execute(_args, exec) {
|
|
73
|
+
const { effective } = await team.load(exec.agent);
|
|
74
|
+
return JSON.stringify(await modelCatalog(ctx.llm, effective.tiers));
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
ctx.effect(() => ctx.tools.register({ name: 'capability_team_save', output,
|
|
78
|
+
description: modelText('theme-plugin.529'),
|
|
79
|
+
parameters: params({
|
|
80
|
+
expected_revision: { type: 'integer', description: modelText('theme-plugin.530') },
|
|
81
|
+
name: { type: 'string', description: modelText('theme-plugin.531') },
|
|
82
|
+
tiers: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.532', { 0: ENTRY_RULE }) },
|
|
83
|
+
capabilities: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.533', { 0: ENTRY_RULE }) },
|
|
84
|
+
failover: { oneOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], description: modelText('theme-plugin.534') },
|
|
85
|
+
workflow: { oneOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], description: modelText('theme-plugin.535') },
|
|
86
|
+
roles: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.536', { 0: ENTRY_RULE }) },
|
|
87
|
+
single_model: { type: 'string', enum: ['use-default', 'off'], description: modelText('theme-plugin.537') },
|
|
88
|
+
}, ['expected_revision']),
|
|
89
|
+
async execute(args, exec) {
|
|
90
|
+
const singleModel = args.single_model === 'off' ? null : args.single_model === 'use-default' ? singleModelFrom(ctx.get?.('agentDefaultModel')?.currentSelection?.()) : undefined;
|
|
91
|
+
const patch = { name: args.name, failover: args.failover, workflow: args.workflow, tiers: args.tiers, capabilities: args.capabilities, roles: args.roles, singleModel };
|
|
92
|
+
return JSON.stringify(await saveConfirmed(exec, patch, args.expected_revision));
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
/** Validate, check new routes against the catalog, and save only after the user approves the full diff. */
|
|
96
|
+
async function saveConfirmed(exec, patch, expectedRevision) {
|
|
97
|
+
if (ctx.agentTeams.membership(exec.agent).role !== 'lead') throw new Error(modelText('theme-plugin.538'));
|
|
98
|
+
const project = await team.pin(exec.agent);
|
|
99
|
+
const result = await saveProject(project, patch, expectedRevision, { base: team.base, root: team.root,
|
|
100
|
+
async check(before, after) {
|
|
101
|
+
const lines = describeChanges(before, after);
|
|
102
|
+
if (!lines.length) throw new Error(modelText('theme-plugin.539'));
|
|
103
|
+
const routes = changedRoutes(before, after);
|
|
104
|
+
const single = after.singleModel && `${after.singleModel.provider}/${after.singleModel.model}` !== `${before.singleModel?.provider}/${before.singleModel?.model}`
|
|
105
|
+
? [{ capability: modelText('theme-plugin.540'), ...after.singleModel }] : [];
|
|
106
|
+
if (routes.length || single.length) verifyRoutes([...routes, ...single], await modelCatalog(ctx.llm, after.tiers));
|
|
107
|
+
await confirm(ctx, exec, lines, expectedRevision);
|
|
108
|
+
} });
|
|
109
|
+
return { saved: true, project: result.project, revision: result.revision, roles: roleView(result.effective) };
|
|
110
|
+
}
|
|
111
|
+
ctx.effect(() => ctx.tools.register({ name: 'capability_team_export', parameters: params(), output,
|
|
112
|
+
description: modelText('theme-plugin.541', { 0: TEMPLATE_FORMAT }),
|
|
113
|
+
async execute(_args, exec) {
|
|
114
|
+
const { effective } = await team.load(exec.agent);
|
|
115
|
+
return JSON.stringify(toTemplate(effective), null, 2);
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
ctx.effect(() => ctx.tools.register({ name: 'capability_team_import', output,
|
|
119
|
+
description: modelText('theme-plugin.542', { 0: Object.keys(ROLE_THEMES).join(' / ') }),
|
|
120
|
+
parameters: params({
|
|
121
|
+
expected_revision: { type: 'integer', description: modelText('theme-plugin.543') },
|
|
122
|
+
template: { type: 'object', additionalProperties: true, description: modelText('theme-plugin.544', { 0: TEMPLATE_FORMAT }) },
|
|
123
|
+
theme: { type: 'string', enum: Object.keys(ROLE_THEMES), description: modelText('theme-plugin.545') },
|
|
124
|
+
}, ['expected_revision']),
|
|
125
|
+
async execute(args, exec) {
|
|
126
|
+
const { effective } = await team.load(exec.agent);
|
|
127
|
+
if ((args.template === undefined) === (args.theme === undefined)) throw new Error(modelText('theme-plugin.546'));
|
|
128
|
+
const patch = args.theme === undefined ? templatePatch(args.template, effective) : themePatch(args.theme, effective);
|
|
129
|
+
const result = await saveConfirmed(exec, patch, args.expected_revision);
|
|
130
|
+
const unassigned = result.roles.filter(r => r.provider === undefined).map(r => r.id);
|
|
131
|
+
return JSON.stringify({ ...result, ...(unassigned.length ? { unassigned, next: modelText('theme-plugin.547') } : {}) });
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
// Install the team context in each Team member's own scope, like the native Team identity.
|
|
135
|
+
const installed = new Map();
|
|
136
|
+
const install = async agent => {
|
|
137
|
+
if (installed.has(agent) || !ownsCapabilityTeam(ctx, agent) || ctx.agentTeams.tryMembership(agent) === undefined) return;
|
|
138
|
+
installed.set(agent, undefined);
|
|
139
|
+
// Pin before the first step; a failure must not block creation and resurfaces on the next request.
|
|
140
|
+
await team.pin(agent).catch(() => {});
|
|
141
|
+
if (!installed.has(agent)) return; // disposed meanwhile
|
|
142
|
+
const removeReader = registerTeamReader(agent, async () => {
|
|
143
|
+
await team.refresh();
|
|
144
|
+
const { project, revision, effective, policy } = await team.load(agent);
|
|
145
|
+
return { project, revision, name: effective.name, ...(effective.singleModel ? { singleModel: effective.singleModel } : {}), roles: policyRoleView(roleView(effective), policy.disabledRoutes), workflow: effective.workflow,
|
|
146
|
+
modelPolicy: { ...modelPolicyRows(effective.tiers, roleView(effective), policy.disabledRoutes, [...(team.detection().pools ?? []), ...(team.detection().unknown ?? [])]), revision: policy.revision },
|
|
147
|
+
autoMatch: team.detection().report, catalogErrors: team.detection().errors ?? [], actualRequest: null,
|
|
148
|
+
...(effective.tierDrift ? { tierDrift: effective.tierDrift } : {}),
|
|
149
|
+
modelNotice: t('theme-plugin.548') };
|
|
150
|
+
});
|
|
151
|
+
const removeContext = agent.ctx.systemPrompt.context({ name: 'capability-team:roster', order: 98, required: true,
|
|
152
|
+
text: () => (ownsCapabilityTeam(ctx, agent) ? teamContext(team, agent, ctx.agentTeams.tryMembership(agent), failover, workflow, planGate) : '') });
|
|
153
|
+
const removeBudgetContext = taskControl && ctx.agentTeams.tryMembership(agent)?.role === 'lead'
|
|
154
|
+
? agent.ctx.systemPrompt.context({ name: 'capability-team:task-budget', order: 99, required: true,
|
|
155
|
+
text: () => (ownsCapabilityTeam(ctx, agent) ? taskControl.summary(agent) : '') }) : () => {};
|
|
156
|
+
const removeWriter = registerPolicyWriter(agent, (routes, revision) => team.savePolicy(agent, routes, revision));
|
|
157
|
+
installed.set(agent, () => { removeReader(); removeWriter(); removeContext(); removeBudgetContext(); });
|
|
158
|
+
};
|
|
159
|
+
for (const agent of ctx.agents.list()) void install(agent);
|
|
160
|
+
ctx.on('agent/created', ({ agent }) => install(agent));
|
|
161
|
+
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent); });
|
|
162
|
+
// A blank session reused for another preset is recomposed without agent/created or agent/disposed.
|
|
163
|
+
ctx.on('agent-preset/selected', sessionId => {
|
|
164
|
+
let agent; try { agent = ctx.agents?.get?.(sessionId); } catch { return; }
|
|
165
|
+
if (!agent) return;
|
|
166
|
+
if (ownsCapabilityTeam(ctx, agent)) { void install(agent); return; }
|
|
167
|
+
installed.get(agent)?.(); installed.delete(agent);
|
|
168
|
+
});
|
|
169
|
+
ctx.effect(() => () => { for (const dispose of installed.values()) dispose?.(); installed.clear(); });
|
|
170
|
+
}
|
|
171
|
+
export { seed };
|
|
172
|
+
import { ownsCapabilityTeam } from './preset-guard.js';
|
package/theme.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Per-project team configuration store: overrides on top of the preset base.
|
|
3
|
+
import { mkdir, readFile, writeFile, rename, unlink, open, stat } from 'node:fs/promises';
|
|
4
|
+
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { resolve, join, dirname } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { effectiveConfig, migrateProject, applyPatch, seed } from './config.js';
|
|
9
|
+
import { pinRoster } from './roster-defaults.js';
|
|
10
|
+
export const STALE_LOCK_MS = 30000;
|
|
11
|
+
export function defaultRoot() {
|
|
12
|
+
return process.env.CAPABILITY_TEAM_HOME || join(homedir(), '.dsh', 'capability-team-themes');
|
|
13
|
+
}
|
|
14
|
+
function identity(dir) {
|
|
15
|
+
const project = resolve(dir);
|
|
16
|
+
return process.platform === 'win32' ? project.toLowerCase() : project;
|
|
17
|
+
}
|
|
18
|
+
export function themePath(cwd, root = defaultRoot()) {
|
|
19
|
+
return join(root, createHash('sha256').update(identity(cwd)).digest('hex') + '.json');
|
|
20
|
+
}
|
|
21
|
+
/** Nearest ancestor (inclusive) that already owns a configuration, else the directory itself. */
|
|
22
|
+
export function projectRoot(cwd, root = defaultRoot()) {
|
|
23
|
+
let dir = resolve(cwd);
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (existsSync(themePath(dir, root))) return dir;
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
if (parent === dir) return resolve(cwd);
|
|
28
|
+
dir = parent;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const cache = new Map();
|
|
32
|
+
function parse(raw, path) {
|
|
33
|
+
try { return migrateProject(JSON.parse(raw)); }
|
|
34
|
+
catch (error) { throw new Error(modelText('theme.549', { 0: path, 1: error.message })); }
|
|
35
|
+
}
|
|
36
|
+
const EMPTY = Object.freeze({ revision: 0, tiers: {}, capabilities: {}, roles: {} });
|
|
37
|
+
function remember(path, info, raw) {
|
|
38
|
+
const value = parse(raw, path);
|
|
39
|
+
cache.set(path, { mtimeMs: info.mtimeMs, size: info.size, value });
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function cached(path, info) {
|
|
43
|
+
const hit = cache.get(path);
|
|
44
|
+
return hit && hit.mtimeMs === info.mtimeMs && hit.size === info.size ? hit.value : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Read stored overrides; cached by mtime and size so a request costs one stat. */
|
|
47
|
+
export async function readOverrides(project, root = defaultRoot()) {
|
|
48
|
+
const path = themePath(project, root);
|
|
49
|
+
let info;
|
|
50
|
+
try { info = await stat(path); } catch (error) { if (error.code === 'ENOENT') { cache.delete(path); return EMPTY; } throw error; }
|
|
51
|
+
return cached(path, info) ?? remember(path, info, await readFile(path, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
/** Synchronous variant for prompt assembly, which cannot await. */
|
|
54
|
+
export function readOverridesSync(project, root = defaultRoot()) {
|
|
55
|
+
const path = themePath(project, root);
|
|
56
|
+
let info;
|
|
57
|
+
try { info = statSync(path); } catch (error) { if (error.code === 'ENOENT') { cache.delete(path); return EMPTY; } throw error; }
|
|
58
|
+
return cached(path, info) ?? remember(path, info, readFileSync(path, 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
export async function loadProject(project, base = seed, root) {
|
|
61
|
+
const overrides = await readOverrides(project, root);
|
|
62
|
+
return { project, revision: overrides.revision, overrides, effective: effectiveConfig(overrides, base, { trustedFrom: overrides }) };
|
|
63
|
+
}
|
|
64
|
+
export function loadProjectSync(project, base = seed, root) {
|
|
65
|
+
const overrides = readOverridesSync(project, root);
|
|
66
|
+
return { project, revision: overrides.revision, overrides, effective: effectiveConfig(overrides, base, { trustedFrom: overrides }) };
|
|
67
|
+
}
|
|
68
|
+
function alive(pid) {
|
|
69
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
70
|
+
}
|
|
71
|
+
export async function acquireLock(lockPath) {
|
|
72
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
const handle = await open(lockPath, 'wx');
|
|
75
|
+
try { await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() })); }
|
|
76
|
+
catch (error) { await handle.close(); await unlink(lockPath); throw error; }
|
|
77
|
+
return handle;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code !== 'EEXIST') throw error;
|
|
80
|
+
let owner = {};
|
|
81
|
+
try { owner = JSON.parse(await readFile(lockPath, 'utf8')); } catch { /* empty or partial lock: judged by file age */ }
|
|
82
|
+
const since = Number(owner.at) || (await stat(lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
|
|
83
|
+
const knownOwner = Number.isSafeInteger(owner.pid) && owner.pid > 0;
|
|
84
|
+
// A confirmation dialog can stay open for minutes. Age never invalidates a live owner's lock.
|
|
85
|
+
const stale = knownOwner ? !alive(owner.pid) : Date.now() - since > STALE_LOCK_MS;
|
|
86
|
+
if (attempt || !stale) throw new Error(modelText('theme.550'));
|
|
87
|
+
await unlink(lockPath).catch(e => { if (e.code !== 'ENOENT') throw e; });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Apply one patch under a lock with revision CAS; the merged result must validate.
|
|
93
|
+
* `check(before, after)` may reject, for example on a catalog or confirmation failure.
|
|
94
|
+
*/
|
|
95
|
+
export async function saveProject(project, patch, expectedRevision, { base = seed, root = defaultRoot(), check } = {}) {
|
|
96
|
+
const path = themePath(project, root);
|
|
97
|
+
await mkdir(dirname(path), { recursive: true });
|
|
98
|
+
const lock = await acquireLock(path + '.lock');
|
|
99
|
+
const tmp = path + '.' + randomUUID() + '.tmp';
|
|
100
|
+
try {
|
|
101
|
+
const previous = await loadProject(project, base, root);
|
|
102
|
+
if (!Number.isSafeInteger(expectedRevision) || previous.revision !== expectedRevision) throw new Error(modelText('theme.551', { 0: previous.revision }));
|
|
103
|
+
const nextOverrides = pinRoster(applyPatch(previous.overrides, patch), previous.overrides, base.roles);
|
|
104
|
+
const effective = effectiveConfig(nextOverrides, base, { trustedFrom: previous.overrides });
|
|
105
|
+
if (check) await check(previous.effective, effective);
|
|
106
|
+
const stored = { ...nextOverrides, revision: previous.revision + 1, project: resolve(project) };
|
|
107
|
+
await writeFile(tmp, JSON.stringify(stored, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
108
|
+
await rename(tmp, path);
|
|
109
|
+
cache.delete(path);
|
|
110
|
+
return { project: resolve(project), revision: stored.revision, effective };
|
|
111
|
+
} finally {
|
|
112
|
+
try { await unlink(tmp).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
|
|
113
|
+
finally { try { await lock.close(); } finally { await unlink(path + '.lock').catch(e => { if (e.code !== 'ENOENT') throw e; }); } }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -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
|
+
}
|
package/token-metrics.js
ADDED
|
@@ -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
|
+
}
|