@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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Direct search/fetch engine drivers (Tavily, Brave, SearXNG, Jina) plus the HTTP plumbing they share with the
|
|
2
|
+
// custom gateway driver in search.js. Every driver returns normalized data:
|
|
3
|
+
// search -> [{ title, url, snippet, published? }] fetch -> { url, title?, content }
|
|
4
|
+
// Errors carry `retryable`: availability failures (timeout, network, 401/403, 404, 429, 5xx, malformed 200)
|
|
5
|
+
// let the dispatcher fall over to the next engine; invalid input (400, 413, 422) does not.
|
|
6
|
+
// Upstream bodies are never echoed and a response that contains the key is blocked.
|
|
7
|
+
/** What an upstream status means for the caller; error bodies are never echoed (they may carry secrets). */
|
|
8
|
+
import { m } from './i18n.js';
|
|
9
|
+
export const STATUS_HINT = Object.fromEntries([400, 401, 403, 404, 413, 422, 429, 502, 504].map((status, index) => [status, m(`search-engines.${373 + index}`)]));
|
|
10
|
+
const NOT_RETRYABLE = new Set([400, 413, 422]);
|
|
11
|
+
export const RESPONSE_CAP = 1024 * 1024;
|
|
12
|
+
export function httpError(label, status) {
|
|
13
|
+
const error = new Error(`${label} HTTP ${status}${STATUS_HINT[status] ? ` (${STATUS_HINT[status]})` : ''}`);
|
|
14
|
+
error.status = status; error.retryable = !NOT_RETRYABLE.has(status) && (status >= 401);
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
/** An error from an engine; `retryable` decides whether the dispatcher tries the next engine. */
|
|
18
|
+
export function engineError(message, retryable = true) { const error = new Error(message); error.retryable = retryable; return error; }
|
|
19
|
+
/** Read a response body as text with the 1MB cap. */
|
|
20
|
+
export async function readCapped(response, label) {
|
|
21
|
+
if (!response.body) throw engineError(m('search-engines.382', { 0: label }));
|
|
22
|
+
const reader = response.body.getReader(); const parts = []; let size = 0;
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
26
|
+
size += value.byteLength;
|
|
27
|
+
if (size > RESPONSE_CAP) { await reader.cancel(); throw engineError(m('search-engines.383', { 0: label })); }
|
|
28
|
+
parts.push(value);
|
|
29
|
+
}
|
|
30
|
+
} finally { reader.releaseLock(); }
|
|
31
|
+
return Buffer.concat(parts).toString('utf8');
|
|
32
|
+
}
|
|
33
|
+
/** Combine the caller's abort signal with a per-request timeout. */
|
|
34
|
+
export const withTimeout = (signal, ms) => { const timeout = AbortSignal.timeout(ms); return signal ? AbortSignal.any([signal, timeout]) : timeout; };
|
|
35
|
+
/**
|
|
36
|
+
* One JSON request: redirects are errors, the body is capped, a body echoing the key is blocked and an error
|
|
37
|
+
* body is never read. A network failure or timeout is retryable unless the caller itself aborted.
|
|
38
|
+
*/
|
|
39
|
+
export async function requestJson(label, url, init, { fetcher = fetch, key, timeoutMs, signal } = {}) {
|
|
40
|
+
let response;
|
|
41
|
+
try { response = await fetcher(url, { ...init, redirect: 'error', signal: withTimeout(signal, timeoutMs) }); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (signal?.aborted) throw error;
|
|
44
|
+
throw engineError(`${label}: ${m(error?.name === 'TimeoutError' ? 'search-engines.384' : 'search-engines.385')}`);
|
|
45
|
+
}
|
|
46
|
+
if (!response.ok) throw httpError(label, response.status);
|
|
47
|
+
const text = await readCapped(response, label);
|
|
48
|
+
if (key && text.includes(key)) throw engineError(m('search-engines.386'), false);
|
|
49
|
+
try { return JSON.parse(text); } catch { throw engineError(m('search-engines.387', { 0: label })); }
|
|
50
|
+
}
|
|
51
|
+
const clean = value => (typeof value === 'string' ? value.replace(/<[^>]{0,200}>/g, '').replace(/\s+/g, ' ').trim() : '');
|
|
52
|
+
/** Normalize one result row; rows without a URL are dropped. */
|
|
53
|
+
export function resultRow(row, { title = 'title', url = 'url', snippet = ['snippet', 'content', 'description'], published = [] } = {}) {
|
|
54
|
+
if (!row || typeof row !== 'object' || typeof row[url] !== 'string' || !row[url]) return undefined;
|
|
55
|
+
const out = { title: clean(row[title]) || row[url], url: row[url], snippet: clean(snippet.map(k => row[k]).find(v => typeof v === 'string' && v)) };
|
|
56
|
+
const date = published.map(k => row[k]).find(v => typeof v === 'string' && v);
|
|
57
|
+
if (date) out.published = date;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const rows = (list, limit, shape) => {
|
|
61
|
+
if (!Array.isArray(list)) return undefined;
|
|
62
|
+
return list.map(row => resultRow(row, shape)).filter(Boolean).slice(0, limit);
|
|
63
|
+
};
|
|
64
|
+
const noResults = label => engineError(m('search-engines.388', { 0: label }));
|
|
65
|
+
const json = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
66
|
+
export const TAVILY_ORIGIN = 'https://api.tavily.com';
|
|
67
|
+
export const BRAVE_ORIGIN = 'https://api.search.brave.com';
|
|
68
|
+
export const JINA_READER = 'https://r.jina.ai/';
|
|
69
|
+
|
|
70
|
+
/** Tavily search: POST /search with a Bearer key; news maps to topic "news". */
|
|
71
|
+
export async function tavilySearch(body, { key, config, fetcher, signal }) {
|
|
72
|
+
if (!key) throw engineError(m('search-engines.389', { 0: config.tavilyKeyEnv }));
|
|
73
|
+
const payload = { query: body.query, max_results: body.max_results, topic: body.search_type === 'news' ? 'news' : 'general', search_depth: 'basic' };
|
|
74
|
+
const data = await requestJson('Tavily', `${TAVILY_ORIGIN}/search`, { method: 'POST', headers: { ...json, Authorization: `Bearer ${key}` }, body: JSON.stringify(payload) }, { fetcher, key, timeoutMs: config.engineTimeoutMs, signal });
|
|
75
|
+
return rows(data?.results, body.max_results, { snippet: ['content'], published: ['published_date'] }) ?? Promise.reject(noResults('Tavily'));
|
|
76
|
+
}
|
|
77
|
+
/** Brave Search API: GET web search (news uses the news endpoint) with X-Subscription-Token. */
|
|
78
|
+
export async function braveSearch(body, { key, config, fetcher, signal }) {
|
|
79
|
+
if (!key) throw engineError(m('search-engines.390', { 0: config.braveKeyEnv }));
|
|
80
|
+
const news = body.search_type === 'news';
|
|
81
|
+
const url = new URL(`${BRAVE_ORIGIN}/res/v1/${news ? 'news' : 'web'}/search`);
|
|
82
|
+
url.searchParams.set('q', body.query); url.searchParams.set('count', String(Math.min(body.max_results, 20)));
|
|
83
|
+
const data = await requestJson('Brave', url.href, { method: 'GET', headers: { Accept: 'application/json', 'X-Subscription-Token': key } }, { fetcher, key, timeoutMs: config.engineTimeoutMs, signal });
|
|
84
|
+
// Brave omits the `web` block when a query has no hits; a reply of type "search" is still a valid answer.
|
|
85
|
+
const list = news ? data?.results : data?.web?.results ?? (data?.type === 'search' ? [] : undefined);
|
|
86
|
+
return rows(list, body.max_results, { snippet: ['description'], published: ['page_age', 'age'] }) ?? Promise.reject(noResults('Brave'));
|
|
87
|
+
}
|
|
88
|
+
/** SearXNG instance: GET {searxngUrl}/search?format=json; news and code map to the news / it categories. */
|
|
89
|
+
export async function searxngSearch(body, { key, config, fetcher, signal }) {
|
|
90
|
+
if (!config.searxngUrl) throw engineError(m('search-engines.391'));
|
|
91
|
+
const url = new URL(`${config.searxngUrl}/search`);
|
|
92
|
+
url.searchParams.set('q', body.query); url.searchParams.set('format', 'json');
|
|
93
|
+
const category = { news: 'news', code: 'it' }[body.search_type];
|
|
94
|
+
if (category) url.searchParams.set('categories', category);
|
|
95
|
+
const headers = { Accept: 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) };
|
|
96
|
+
const data = await requestJson('SearXNG', url.href, { method: 'GET', headers }, { fetcher, key, timeoutMs: config.engineTimeoutMs, signal });
|
|
97
|
+
return rows(data?.results, body.max_results, { snippet: ['content'], published: ['publishedDate'] }) ?? Promise.reject(noResults('SearXNG'));
|
|
98
|
+
}
|
|
99
|
+
const pageContent = (label, url, title, content) => {
|
|
100
|
+
if (typeof content !== 'string' || !content.trim()) throw engineError(m('search-engines.392', { 0: label }));
|
|
101
|
+
return { url, ...(typeof title === 'string' && title.trim() ? { title: title.trim() } : {}), content };
|
|
102
|
+
};
|
|
103
|
+
/** Jina Reader: GET r.jina.ai/{url}; works without a key, an optional key raises the rate limit. */
|
|
104
|
+
export async function jinaFetch(body, { key, config, fetcher, signal }) {
|
|
105
|
+
const headers = { Accept: 'application/json', 'X-Return-Format': body.format === 'html' ? 'html' : 'markdown', ...(key ? { Authorization: `Bearer ${key}` } : {}) };
|
|
106
|
+
const data = await requestJson('Jina', `${JINA_READER}${body.url}`, { method: 'GET', headers }, { fetcher, key, timeoutMs: config.engineTimeoutMs, signal });
|
|
107
|
+
const page = data?.data ?? data;
|
|
108
|
+
return pageContent('Jina', page?.url || body.url, page?.title, page?.content ?? page?.html);
|
|
109
|
+
}
|
|
110
|
+
/** Tavily Extract: POST /extract for one URL. */
|
|
111
|
+
export async function tavilyFetch(body, { key, config, fetcher, signal }) {
|
|
112
|
+
if (!key) throw engineError(m('search-engines.389', { 0: config.tavilyKeyEnv }));
|
|
113
|
+
const payload = { urls: [body.url], format: body.format === 'html' ? 'text' : 'markdown' };
|
|
114
|
+
const data = await requestJson('Tavily', `${TAVILY_ORIGIN}/extract`, { method: 'POST', headers: { ...json, Authorization: `Bearer ${key}` }, body: JSON.stringify(payload) }, { fetcher, key, timeoutMs: config.engineTimeoutMs, signal });
|
|
115
|
+
const page = Array.isArray(data?.results) ? data.results[0] : undefined;
|
|
116
|
+
return pageContent('Tavily', page?.url || body.url, page?.title, page?.raw_content);
|
|
117
|
+
}
|
|
118
|
+
export const SEARCH_DRIVERS = Object.freeze({ tavily: tavilySearch, brave: braveSearch, searxng: searxngSearch });
|
|
119
|
+
export const FETCH_DRIVERS = Object.freeze({ jina: jinaFetch, tavily: tavilyFetch });
|
package/search.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Search/fetch dispatcher: picks engines from integrations.search, falls over on availability failures and
|
|
3
|
+
// returns normalized results. Direct engine drivers live in search-engines.js; the `custom` engine is any
|
|
4
|
+
// gateway speaking the generic search/fetch/providers protocol, implemented here by `gateway`.
|
|
5
|
+
import { params } from './tool-schema.js';
|
|
6
|
+
import { INTEGRATION_DEFAULTS, SEARCH_ENGINES, SEARCH_KEY_FIELDS, engineKeyOf, keyOf, refreshCredentials, resolveIntegrations } from './integrations-config.js';
|
|
7
|
+
import { FETCH_DRIVERS, SEARCH_DRIVERS, engineError, httpError, readCapped, resultRow, withTimeout } from './search-engines.js';
|
|
8
|
+
export { STATUS_HINT, httpError } from './search-engines.js';
|
|
9
|
+
// Engine order, paths, headers and the provider lists are configuration (integrations.search), never code.
|
|
10
|
+
export function requestBody(kind, args, config = INTEGRATION_DEFAULTS.search) {
|
|
11
|
+
const allowed = kind === 'search' ? ['query','max_results','search_type','provider'] : ['url','format','provider'];
|
|
12
|
+
if (!args || Object.keys(args).some(key => !allowed.includes(key))) throw new Error(modelText('search.394'));
|
|
13
|
+
const body = {};
|
|
14
|
+
if (kind === 'search') {
|
|
15
|
+
if (typeof args.query !== 'string' || !args.query.trim() || args.query.length > 4000) throw new Error(modelText('search.395'));
|
|
16
|
+
body.query = args.query; body.max_results = args.max_results ?? 5; body.search_type = args.search_type ?? 'web';
|
|
17
|
+
if (!Number.isInteger(body.max_results) || body.max_results < 1 || body.max_results > 20) throw new Error(modelText('search.396'));
|
|
18
|
+
if (!['web','news','code'].includes(body.search_type)) throw new Error(modelText('search.397'));
|
|
19
|
+
} else {
|
|
20
|
+
const url = new URL(args.url);
|
|
21
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|\[|.*\.local$)/i.test(url.hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(url.hostname)) throw new Error(modelText('search.398'));
|
|
22
|
+
body.url = url.href; body.format = args.format ?? 'markdown';
|
|
23
|
+
if (!['markdown','html'].includes(body.format)) throw new Error(modelText('search.399'));
|
|
24
|
+
}
|
|
25
|
+
if (args.provider !== undefined) {
|
|
26
|
+
const allowed = kind === 'search' ? config.providers : config.fetchProviders;
|
|
27
|
+
// With a configured list only those providers pass; without one, any provider token goes to the gateway.
|
|
28
|
+
if (typeof args.provider !== 'string' || !/^[\w.:-]{1,64}$/.test(args.provider) || (allowed.length && !allowed.includes(args.provider))) throw new Error(modelText('search.400'));
|
|
29
|
+
body.provider = args.provider;
|
|
30
|
+
}
|
|
31
|
+
return body;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Gateway base from configuration; only a credential-free https origin (optional path prefix) is accepted.
|
|
35
|
+
* `field`/`envName` name where the address comes from, for the error message.
|
|
36
|
+
*/
|
|
37
|
+
export function gatewayBase(raw, { what = modelText('search.401'), field = 'search.baseUrl', envName = 'OMNI_SEARCH_BASE_URL' } = {}) {
|
|
38
|
+
if (!raw) throw new Error(modelText('search.402', { 0: what, 1: field, 2: envName }));
|
|
39
|
+
let url;
|
|
40
|
+
try { url = new URL(raw); } catch { throw new Error(modelText('search.403', { 0: envName })); }
|
|
41
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error(modelText('search.404', { 0: envName }));
|
|
42
|
+
return url.href.replace(/\/+$/, '');
|
|
43
|
+
}
|
|
44
|
+
/** Search/fetch config and every engine key as resolved now; tests and callers may pass them explicitly. */
|
|
45
|
+
export function searchSettings(resolved = resolveIntegrations()) {
|
|
46
|
+
const keys = Object.fromEntries(Object.keys(SEARCH_KEY_FIELDS).map(engine => [engine, engineKeyOf(engine, resolved)]));
|
|
47
|
+
return { config: resolved.effective.search, key: keyOf('search', resolved), keyEnv: resolved.keys.search.env, keys };
|
|
48
|
+
}
|
|
49
|
+
const engineKey = (settings, engine) => (engine === 'custom' ? settings.keys?.custom ?? settings.key : settings.keys?.[engine]);
|
|
50
|
+
/** Whether an engine has what it needs to be tried in auto mode (a key, or a URL for SearXNG / custom). */
|
|
51
|
+
export function engineConfigured(engine, settings) {
|
|
52
|
+
const { config } = settings;
|
|
53
|
+
if (engine === 'searxng') return !!config.searxngUrl;
|
|
54
|
+
if (engine === 'custom') return !!config.baseUrl;
|
|
55
|
+
return !!engineKey(settings, engine);
|
|
56
|
+
}
|
|
57
|
+
/** Ordered search engines: the configured list, or auto = every configured engine in default order. */
|
|
58
|
+
export function activeEngines(settings) {
|
|
59
|
+
return settings.config.engines ?? SEARCH_ENGINES.filter(engine => engineConfigured(engine, settings));
|
|
60
|
+
}
|
|
61
|
+
/** Whether `custom` takes part in search (explicit list, or auto with a gateway address). */
|
|
62
|
+
export const customActive = config => (config.engines ? config.engines.includes('custom') : !!config.baseUrl);
|
|
63
|
+
const fetchEnginesOf = config => config.fetchEngines ?? INTEGRATION_DEFAULTS.search.fetchEngines;
|
|
64
|
+
/** A 200 still has to carry a real result: search needs a results list, fetch needs page content. */
|
|
65
|
+
export function checkResult(kind, data) {
|
|
66
|
+
if (kind === 'search' && !Array.isArray(data?.results)) throw engineError(modelText('search.405'));
|
|
67
|
+
if (kind === 'fetch' && !(typeof data?.content === 'string' && data.content.trim())) throw engineError(modelText('search.406'));
|
|
68
|
+
if (kind === 'providers' && !Array.isArray(data?.data)) throw engineError(modelText('search.407'));
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
/** The `custom` engine: one request to the configured gateway (search, fetch or providers), raw checked data. */
|
|
72
|
+
export async function gateway(kind, args, { fetcher = fetch, settings, key, base, signal } = {}) {
|
|
73
|
+
if (!settings && key === undefined) await refreshCredentials();
|
|
74
|
+
const current = settings ?? searchSettings();
|
|
75
|
+
const config = { ...current.config, ...(base !== undefined ? { baseUrl: base } : {}) };
|
|
76
|
+
const secret = key !== undefined ? key : engineKey(current, 'custom');
|
|
77
|
+
const body = kind === 'providers' ? undefined : requestBody(kind, args, config);
|
|
78
|
+
if (!secret) throw engineError(modelText('search.408', { 0: current.keyEnv ?? config.apiKeyEnv }));
|
|
79
|
+
const origin = gatewayBase(config.baseUrl);
|
|
80
|
+
if (kind === 'providers' && !config.providersPath) throw new Error(modelText('search.409'));
|
|
81
|
+
const path = { search: config.searchPath, fetch: config.fetchPath, providers: config.providersPath }[kind];
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetcher(`${origin}${path}`, {
|
|
85
|
+
method: body ? 'POST' : 'GET', redirect: 'error', signal: withTimeout(signal, kind === 'fetch' ? config.fetchTimeoutMs : config.timeoutMs),
|
|
86
|
+
headers: { ...config.headers, Authorization: `Bearer ${secret}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}),
|
|
87
|
+
});
|
|
88
|
+
} catch (error) { if (signal?.aborted) throw error; throw engineError(modelText('search.410', { 0: error?.name === 'TimeoutError' ? modelText('search.411') : modelText('search.412') })); }
|
|
89
|
+
// Never echo error bodies: gateways may include secrets or upstream headers.
|
|
90
|
+
if (!response.ok) throw httpError(modelText('search.413'), response.status);
|
|
91
|
+
const text = await readCapped(response, modelText('search.414'));
|
|
92
|
+
if (text.includes(secret)) throw engineError(modelText('search.415'), false);
|
|
93
|
+
let data;
|
|
94
|
+
try { data = JSON.parse(text); } catch { throw engineError(modelText('search.416')); }
|
|
95
|
+
return checkResult(kind, data);
|
|
96
|
+
}
|
|
97
|
+
const RESULT_SHAPE = { snippet: ['snippet', 'content', 'description'], published: ['published', 'published_date', 'publishedDate'] };
|
|
98
|
+
async function customSearch(body, { args, ...opts }) {
|
|
99
|
+
const data = await gateway('search', args, opts);
|
|
100
|
+
return data.results.map(row => resultRow(row, RESULT_SHAPE)).filter(Boolean).slice(0, body.max_results);
|
|
101
|
+
}
|
|
102
|
+
async function customFetch(body, { args, ...opts }) {
|
|
103
|
+
const data = await gateway('fetch', args, opts);
|
|
104
|
+
return { url: typeof data.url === 'string' && data.url ? data.url : body.url, ...(typeof data.title === 'string' && data.title.trim() ? { title: data.title.trim() } : {}), content: data.content };
|
|
105
|
+
}
|
|
106
|
+
// Run engines in order; only a retryable failure moves on. The final error lists each engine's (safe) reason.
|
|
107
|
+
async function failover(label, engines, attempt) {
|
|
108
|
+
const reasons = [];
|
|
109
|
+
for (const engine of engines) {
|
|
110
|
+
try { return await attempt(engine); }
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error?.retryable !== true) throw error;
|
|
113
|
+
reasons.push(`${engine}: ${error.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new Error(modelText('search.417', { 0: label, 1: reasons.join(';') }));
|
|
117
|
+
}
|
|
118
|
+
const NO_ENGINE = modelText('search.418');
|
|
119
|
+
/**
|
|
120
|
+
* Search, fetch or list custom providers. Search -> { engine, results }, fetch -> { engine, url, title?, content },
|
|
121
|
+
* providers -> the custom gateway's raw list.
|
|
122
|
+
*/
|
|
123
|
+
export async function omni(kind, args, { fetcher = fetch, settings, signal } = {}) {
|
|
124
|
+
if (!settings) await refreshCredentials();
|
|
125
|
+
const current = settings ?? searchSettings();
|
|
126
|
+
const { config } = current;
|
|
127
|
+
if (kind === 'providers') {
|
|
128
|
+
if (!customActive(config)) throw new Error(modelText('search.419'));
|
|
129
|
+
return gateway('providers', args, { fetcher, settings: current, signal });
|
|
130
|
+
}
|
|
131
|
+
const body = requestBody(kind, args, config);
|
|
132
|
+
const opts = key => ({ key, config, fetcher, signal, args, settings: current });
|
|
133
|
+
if (kind === 'search') {
|
|
134
|
+
const engines = activeEngines(current);
|
|
135
|
+
if (args.provider !== undefined && !engines.includes('custom')) throw new Error(modelText('search.420'));
|
|
136
|
+
if (!engines.length) throw new Error(NO_ENGINE);
|
|
137
|
+
return failover(modelText('search.421'), engines, async engine => {
|
|
138
|
+
const driver = engine === 'custom' ? customSearch : SEARCH_DRIVERS[engine];
|
|
139
|
+
return { engine, results: await driver(body, opts(engineKey(current, engine))) };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const engines = fetchEnginesOf(config);
|
|
143
|
+
if (args.provider !== undefined && !engines.includes('custom')) throw new Error(modelText('search.422'));
|
|
144
|
+
return failover(modelText('search.423'), engines, async engine => {
|
|
145
|
+
const driver = engine === 'custom' ? customFetch : FETCH_DRIVERS[engine];
|
|
146
|
+
return { engine, ...(await driver(body, opts(engineKey(current, engine)))) };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Tool schemas. `provider` exists only when the custom engine takes part; a configured provider list becomes
|
|
151
|
+
* its enum. The providers tool needs the custom engine and a providersPath.
|
|
152
|
+
*/
|
|
153
|
+
export function searchSchemas(config = INTEGRATION_DEFAULTS.search) {
|
|
154
|
+
const provider = list => (list.length ? { type: 'string', enum: list } : { type: 'string', description: modelText('search.424') });
|
|
155
|
+
const custom = customActive(config);
|
|
156
|
+
return {
|
|
157
|
+
omni_search: params({ query: { type: 'string', description: modelText('search.425') }, max_results: { type: 'integer', description: modelText('search.426') }, search_type: { type: 'string', enum: ['web','news','code'] },
|
|
158
|
+
...(custom ? { provider: provider(config.providers) } : {}) }, ['query']),
|
|
159
|
+
omni_fetch: params({ url: { type: 'string', description: modelText('search.427') }, format: { type: 'string', enum: ['markdown','html'] },
|
|
160
|
+
...(fetchEnginesOf(config).includes('custom') ? { provider: provider(config.fetchProviders) } : {}) }, ['url']),
|
|
161
|
+
...(custom && config.providersPath ? { omni_search_providers: params({}, []) } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export const schemas = searchSchemas();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { defaultRoot, acquireLock } from './theme.js';
|
|
7
|
+
import { validateDisabledRoutes } from './model-policy.js';
|
|
8
|
+
const profileNamespace = () => process.env.DSH_PROFILE_DIR ?? process.env.DSH_PROFILE ?? 'default';
|
|
9
|
+
export function sessionPolicyPath(sessionId, root = defaultRoot()) {
|
|
10
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new Error(modelText('session-policy.428'));
|
|
11
|
+
return join(root, 'sessions', createHash('sha256').update(JSON.stringify([profileNamespace(), sessionId])).digest('hex') + '.json');
|
|
12
|
+
}
|
|
13
|
+
/** One model policy per DSH profile, shared by every capability-team session. */
|
|
14
|
+
export function globalPolicyPath(root = defaultRoot()) {
|
|
15
|
+
return join(root, 'model-policy', createHash('sha256').update(JSON.stringify([profileNamespace(), 'global'])).digest('hex') + '.json');
|
|
16
|
+
}
|
|
17
|
+
function parsePolicy(raw) {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(raw);
|
|
20
|
+
if (!Number.isSafeInteger(data.revision) || data.revision < 1) throw new Error(modelText('session-policy.429'));
|
|
21
|
+
return { revision: data.revision, disabledRoutes: validateDisabledRoutes(data.disabledRoutes) };
|
|
22
|
+
} catch (error) { throw new Error(modelText('session-policy.430', { 0: error.message })); }
|
|
23
|
+
}
|
|
24
|
+
async function readPolicyAt(path) {
|
|
25
|
+
let raw;
|
|
26
|
+
try { raw = await readFile(path, 'utf8'); }
|
|
27
|
+
catch (error) { if (error.code === 'ENOENT') return { revision: 0, disabledRoutes: [] }; throw error; }
|
|
28
|
+
return parsePolicy(raw);
|
|
29
|
+
}
|
|
30
|
+
function readPolicyAtSync(path) {
|
|
31
|
+
let raw;
|
|
32
|
+
try { raw = readFileSync(path, 'utf8'); }
|
|
33
|
+
catch (error) { if (error.code === 'ENOENT') return { revision: 0, disabledRoutes: [] }; throw error; }
|
|
34
|
+
return parsePolicy(raw);
|
|
35
|
+
}
|
|
36
|
+
export const readSessionPolicy = (sessionId, root) => readPolicyAt(sessionPolicyPath(sessionId, root));
|
|
37
|
+
export const readSessionPolicySync = (sessionId, root) => readPolicyAtSync(sessionPolicyPath(sessionId, root));
|
|
38
|
+
export const saveSessionPolicy = (sessionId, disabledRoutes, expectedRevision, root) => savePolicyAt(sessionPolicyPath(sessionId, root), disabledRoutes, expectedRevision);
|
|
39
|
+
export const readGlobalPolicy = root => readPolicyAt(globalPolicyPath(root));
|
|
40
|
+
export const readGlobalPolicySync = root => readPolicyAtSync(globalPolicyPath(root));
|
|
41
|
+
export const saveGlobalPolicy = (disabledRoutes, expectedRevision, root) => savePolicyAt(globalPolicyPath(root), disabledRoutes, expectedRevision);
|
|
42
|
+
async function savePolicyAt(path, disabledRoutes, expectedRevision) {
|
|
43
|
+
const disabled = validateDisabledRoutes(disabledRoutes);
|
|
44
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new Error(modelText('session-policy.431'));
|
|
45
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
46
|
+
const lock = await acquireLock(path + '.lock');
|
|
47
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
const current = await readPolicyAt(path);
|
|
50
|
+
if (current.revision !== expectedRevision) throw new Error(modelText('session-policy.432'));
|
|
51
|
+
const next = { revision: current.revision + 1, disabledRoutes: disabled };
|
|
52
|
+
await writeFile(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx' });
|
|
53
|
+
await rename(temporary, path);
|
|
54
|
+
return next;
|
|
55
|
+
} finally {
|
|
56
|
+
try { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
57
|
+
finally { try { await lock.close(); } finally { await unlink(path + '.lock').catch(error => { if (error.code !== 'ENOENT') throw error; }); } }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Session rotation: a teammate session that has been compacted several times, is close to its context window,
|
|
3
|
+
// or keeps re-reading the same file is handed over to a fresh successor instead of piling up. The member
|
|
4
|
+
// writes a structured handoff (capability_handoff); the plugin adds facts it collects itself (git diff stat,
|
|
5
|
+
// owned board tasks, last test command). The Lead then calls capability_rotate_member, which names the
|
|
6
|
+
// successor `<role>-r<N>` (team rosters never reuse a name) and returns the exact calls to make.
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { params } from './tool-schema.js';
|
|
12
|
+
import { ownsCapabilityTeam } from './preset-guard.js';
|
|
13
|
+
import { promptTokens } from './long-context.js';
|
|
14
|
+
import { restrictiveToolName } from './tool-names.js';
|
|
15
|
+
const TRACKED_TOOLS = new Set(['read', 'write', 'edit', 'str_replace_editor', 'apply_patch', 'bash', 'pwsh', 'send_message']);
|
|
16
|
+
export const ROTATION = { softCompactions: 2, hardCompactions: 3, softRatio: 0.75, hardRatio: 0.9, softRepeatReads: 15, hardTurns: 8, maxMembers: 8 };
|
|
17
|
+
const KEYS = Object.keys(ROTATION);
|
|
18
|
+
const SUFFIX = /-r(\d+)$/;
|
|
19
|
+
export const baseName = name => String(name ?? '').replace(SUFFIX, '');
|
|
20
|
+
export const generation = name => Number(SUFFIX.exec(String(name ?? ''))?.[1] ?? 1);
|
|
21
|
+
export const successorName = name => `${baseName(name)}-r${generation(name) + 1}`;
|
|
22
|
+
const output = { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] };
|
|
23
|
+
export function validateRotation(value) {
|
|
24
|
+
if (value === undefined || value === null) return { ...ROTATION };
|
|
25
|
+
if (value === false) return false;
|
|
26
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new Error(modelText('session-rotation.433'));
|
|
27
|
+
const extra = Object.keys(value).filter(k => !KEYS.includes(k));
|
|
28
|
+
if (extra.length) throw new Error(modelText('session-rotation.434', { 0: extra.join(', ') }));
|
|
29
|
+
const out = { ...ROTATION, ...value };
|
|
30
|
+
for (const k of ['softRatio', 'hardRatio']) if (typeof out[k] !== 'number' || out[k] <= 0 || out[k] > 1) throw new Error(modelText('session-rotation.435', { 0: k }));
|
|
31
|
+
for (const k of KEYS.filter(k => !k.endsWith('Ratio'))) if (!Number.isSafeInteger(out[k]) || out[k] < 1 || out[k] > 1000) throw new Error(modelText('session-rotation.436', { 0: k }));
|
|
32
|
+
if (out.softCompactions > out.hardCompactions || out.softRatio > out.hardRatio) throw new Error(modelText('session-rotation.437'));
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** 'ok' | 'soft' | 'hard' with the reasons, from one session's counters and its context ratio. */
|
|
36
|
+
export function rotationLevel(m, ratio, limits) {
|
|
37
|
+
if (!limits || !m) return { level: 'ok', reasons: [] };
|
|
38
|
+
const hard = [], soft = [];
|
|
39
|
+
if (m.compactions >= limits.hardCompactions) hard.push(modelText('session-rotation.438', { 0: m.compactions }));
|
|
40
|
+
else if (m.compactions >= limits.softCompactions) soft.push(modelText('session-rotation.439', { 0: m.compactions }));
|
|
41
|
+
if (ratio !== undefined && ratio >= limits.hardRatio) hard.push(modelText('session-rotation.440', { 0: Math.round(ratio * 100) }));
|
|
42
|
+
else if (ratio !== undefined && ratio >= limits.softRatio) soft.push(modelText('session-rotation.441', { 0: Math.round(ratio * 100) }));
|
|
43
|
+
if (m.turns >= limits.hardTurns) hard.push(modelText('session-rotation.442', { 0: m.turns }));
|
|
44
|
+
if (m.maxRepeat >= limits.softRepeatReads) soft.push(modelText('session-rotation.443', { 0: m.maxRepeat }));
|
|
45
|
+
// Rotating a session that has produced nothing only makes the successor read everything again, so a
|
|
46
|
+
// stalled session stays at the reminder level until it edits a file or the host compacts it.
|
|
47
|
+
const progressed = (m.edits ?? 0) > 0 || m.compactions > 0;
|
|
48
|
+
if (hard.length && !progressed) return { level: 'soft', reasons: [...hard, ...soft], stalled: true };
|
|
49
|
+
return hard.length ? { level: 'hard', reasons: [...hard, ...soft] } : soft.length ? { level: 'soft', reasons: soft } : { level: 'ok', reasons: [] };
|
|
50
|
+
}
|
|
51
|
+
const TEST_CMD = /\b(npm (run )?test|node --test|pnpm test|yarn test|pytest|go test|cargo test|vitest|jest)\b/;
|
|
52
|
+
/** Fold one session event into the counters. Pure, so tests can replay a log. */
|
|
53
|
+
export function foldEvent(m, event) {
|
|
54
|
+
const data = event?.data ?? {};
|
|
55
|
+
const d = { ...data, name: restrictiveToolName(data.name, TRACKED_TOOLS) };
|
|
56
|
+
if (event?.type === 'compaction/end') m.compactions++;
|
|
57
|
+
else if (event?.type === 'turn/start') m.turns++;
|
|
58
|
+
else if (event?.type === 'tool/call') {
|
|
59
|
+
let args = {}; try { args = typeof d.arguments === 'string' ? JSON.parse(d.arguments) : d.arguments ?? {}; } catch { /* partial */ }
|
|
60
|
+
if (d.name === 'read' && typeof args.file_path === 'string') {
|
|
61
|
+
const n = (m.reads.get(args.file_path) ?? 0) + 1; m.reads.set(args.file_path, n); m.maxRepeat = Math.max(m.maxRepeat, n);
|
|
62
|
+
}
|
|
63
|
+
if (['write', 'edit', 'str_replace_editor', 'apply_patch'].includes(d.name) && !(d.name === 'str_replace_editor' && args.command === 'view')) m.edits++;
|
|
64
|
+
if (['bash', 'pwsh'].includes(d.name) && TEST_CMD.test(String(args.command ?? ''))) m.pendingTest = { callId: d.callId, command: String(args.command).slice(0, 300) };
|
|
65
|
+
} else if (event?.type === 'tool/result' && m.pendingTest && d.message?.toolCallId === m.pendingTest.callId) {
|
|
66
|
+
const text = (d.message.content ?? []).map(c => c.text ?? '').join('\n');
|
|
67
|
+
const summary = text.split('\n').filter(l => /\b(pass|fail|tests?|ok|error)\b/i.test(l)).slice(-6).join('\n');
|
|
68
|
+
m.lastTest = { command: m.pendingTest.command, isError: !!d.message.isError, summary: summary.slice(0, 600), at: event.time ?? null };
|
|
69
|
+
m.pendingTest = null;
|
|
70
|
+
}
|
|
71
|
+
return m;
|
|
72
|
+
}
|
|
73
|
+
export const emptyMetrics = () => ({ compactions: 0, turns: 0, edits: 0, reads: new Map(), maxRepeat: 0, lastTest: null, pendingTest: null });
|
|
74
|
+
const gitStat = cwd => new Promise(resolve => execFile('git', ['diff', '--stat'], { cwd, timeout: 5000, maxBuffer: 1 << 20 }, (error, stdout) =>
|
|
75
|
+
resolve(error ? null : stdout.trim().split('\n').slice(-40).join('\n'))));
|
|
76
|
+
export function createRotation(ctx, team, { root = team.root, diffStat = gitStat, now = Date.now, onHandoff } = {}) {
|
|
77
|
+
const metrics = new Map(); // session id → counters
|
|
78
|
+
const handoffs = new Map(); // `${leadId}:${memberName}` → latest handoff
|
|
79
|
+
const retired = new Map(); // `${leadId}:${memberName}` → successor name
|
|
80
|
+
const planned = new Map(); // `${leadId}:${successor}` → { from, prompt }
|
|
81
|
+
const counters = id => { if (!metrics.has(id)) metrics.set(id, emptyMetrics()); return metrics.get(id); };
|
|
82
|
+
const limitsOf = agent => { try { const project = team.pinned(agent); return project ? team.loadSync(project, agent).effective.workflow.rotation : false; } catch { return false; } };
|
|
83
|
+
const ratioOf = agent => {
|
|
84
|
+
try {
|
|
85
|
+
const projections = ctx.get?.('sessionProjections');
|
|
86
|
+
const tokens = promptTokens(projections, agent.session);
|
|
87
|
+
const window = projections?.stateOf(agent.session, 'contextPressure')?.contextWindow;
|
|
88
|
+
return tokens !== undefined && window ? tokens / window : undefined;
|
|
89
|
+
} catch { return undefined; }
|
|
90
|
+
};
|
|
91
|
+
const dir = leadId => join(root, 'handoffs', createHash('sha256').update(leadId).digest('hex').slice(0, 32));
|
|
92
|
+
const api = {
|
|
93
|
+
metrics: id => metrics.get(id),
|
|
94
|
+
status(agent) { return rotationLevel(metrics.get(agent.id), ratioOf(agent), limitsOf(agent)); },
|
|
95
|
+
retiredTo: (leadId, name) => retired.get(`${leadId}:${name}`),
|
|
96
|
+
/** Lead guard: no new work for a retired member or one past the hard line; successors may be spawned. */
|
|
97
|
+
guard(exec) {
|
|
98
|
+
const membership = ctx.agentTeams.tryMembership(exec.agent);
|
|
99
|
+
if (!ownsCapabilityTeam(ctx, exec.agent) || membership?.role !== 'lead') return undefined;
|
|
100
|
+
const lead = exec.agent;
|
|
101
|
+
if (restrictiveToolName(exec.name, TRACKED_TOOLS) === 'send_message') {
|
|
102
|
+
const target = exec.arguments?.target;
|
|
103
|
+
const next = retired.get(`${lead.id}:${target}`);
|
|
104
|
+
if (next) return modelText('session-rotation.444', { 0: target, 1: next, 2: next });
|
|
105
|
+
const member = ctx.agentTeams.listMembers(lead).find(m => m.name === target && m.role !== 'lead');
|
|
106
|
+
const agent = member && ctx.agents.get(member.id);
|
|
107
|
+
const status = agent && api.status(agent);
|
|
108
|
+
if (status?.level === 'hard') return modelText('session-rotation.445', { 0: target, 1: status.reasons.join('、') });
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
},
|
|
112
|
+
/** Whether `name` is a successor this Lead asked for via capability_rotate_member. */
|
|
113
|
+
plannedSuccessor: (leadId, name) => planned.has(`${leadId}:${name}`),
|
|
114
|
+
/** The member a planned successor replaces, if any. */
|
|
115
|
+
predecessorOf: (leadId, name) => planned.get(`${leadId}:${name}`)?.from,
|
|
116
|
+
async writeHandoff(agent, args) {
|
|
117
|
+
const membership = ctx.agentTeams.tryMembership(agent);
|
|
118
|
+
if (membership?.role !== 'teammate') throw new Error(modelText('session-rotation.446'));
|
|
119
|
+
const lead = membership.root;
|
|
120
|
+
const project = team.pinned(agent) ?? ctx.workingDirectory?.get?.(agent.session);
|
|
121
|
+
const m = metrics.get(agent.id) ?? emptyMetrics();
|
|
122
|
+
let tasks = [];
|
|
123
|
+
try { tasks = (ctx.agentTeams.listTasks?.(lead) ?? []).filter(t => t.status !== 'deleted' && t.status !== 'completed' && (t.ownerId === agent.id || t.ownerName === membership.name)).map(t => ({ id: t.id, subject: t.subject, status: t.status })); } catch { /* optional */ }
|
|
124
|
+
const text = (v, max = 4000) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
|
|
125
|
+
const handoff = { schemaVersion: 1, at: now(), lead: lead.id, member: membership.name, role: baseName(membership.name), session: agent.id,
|
|
126
|
+
written: { goal: text(args.goal), done: text(args.done), remaining: text(args.remaining), pitfalls: text(args.pitfalls), open: text(args.open), verify: text(args.verify) },
|
|
127
|
+
facts: { diffStat: project ? await diffStat(project) : null, openTasks: tasks, lastTest: m.lastTest, compactions: m.compactions, turns: m.turns } };
|
|
128
|
+
if (!handoff.written.remaining && !handoff.written.done) throw new Error(modelText('session-rotation.447'));
|
|
129
|
+
handoffs.set(`${lead.id}:${membership.name}`, handoff);
|
|
130
|
+
await mkdir(dir(lead.id), { recursive: true });
|
|
131
|
+
await writeFile(join(dir(lead.id), `${membership.name}.json`), JSON.stringify(handoff, null, 2) + '\n');
|
|
132
|
+
try { await onHandoff?.(handoff); } catch { /* the handoff is saved even if the memory hub is unavailable */ }
|
|
133
|
+
return handoff;
|
|
134
|
+
},
|
|
135
|
+
async readHandoff(leadId, name) {
|
|
136
|
+
const key = `${leadId}:${name}`;
|
|
137
|
+
if (handoffs.has(key)) return handoffs.get(key);
|
|
138
|
+
try { const h = JSON.parse(await readFile(join(dir(leadId), `${name}.json`), 'utf8')); handoffs.set(key, h); return h; } catch { return undefined; }
|
|
139
|
+
},
|
|
140
|
+
/** Plan the successor for a member: name, fresh-context prompt, and the calls the Lead makes. */
|
|
141
|
+
async rotate(lead, args) {
|
|
142
|
+
const name = String(args.role ?? '');
|
|
143
|
+
const members = ctx.agentTeams.listMembers(lead).filter(m => m.role !== 'lead');
|
|
144
|
+
const current = members.find(m => m.name === name) ?? members.filter(m => baseName(m.name) === baseName(name)).sort((a, b) => generation(b.name) - generation(a.name))[0];
|
|
145
|
+
if (!current) throw new Error(modelText('session-rotation.448', { 0: name }));
|
|
146
|
+
const limits = limitsOf(lead) || ROTATION;
|
|
147
|
+
const used = ctx.agentTeams.listMembers(lead).length;
|
|
148
|
+
if (used >= limits.maxMembers) {
|
|
149
|
+
return { rotated: false, reason: modelText('session-rotation.449', { 0: used, 1: limits.maxMembers }),
|
|
150
|
+
next: modelText('session-rotation.450', { 0: current.name }) };
|
|
151
|
+
}
|
|
152
|
+
const handoff = await api.readHandoff(lead.id, current.name);
|
|
153
|
+
if (!handoff && !args.without_handoff) {
|
|
154
|
+
return { rotated: false, reason: modelText('session-rotation.451', { 0: current.name }),
|
|
155
|
+
next: modelText('session-rotation.452', { 0: current.name }) };
|
|
156
|
+
}
|
|
157
|
+
const successor = successorName(current.name);
|
|
158
|
+
const facts = handoff?.facts ?? { diffStat: await diffStat(team.pinned(lead) ?? '.'), openTasks: [], lastTest: metrics.get(current.id)?.lastTest ?? null };
|
|
159
|
+
const w = handoff?.written ?? {};
|
|
160
|
+
const prompt = [modelText('session-rotation.453', { 0: current.name, 1: baseName(current.name) }),
|
|
161
|
+
args.task ? modelText('session-rotation.454', { 0: String(args.task).slice(0, 4000) }) : null,
|
|
162
|
+
w.goal ? modelText('session-rotation.455', { 0: w.goal }) : null, w.done ? modelText('session-rotation.456', { 0: w.done }) : null, w.remaining ? modelText('session-rotation.457', { 0: w.remaining }) : null,
|
|
163
|
+
w.pitfalls ? modelText('session-rotation.458', { 0: w.pitfalls }) : null, w.open ? modelText('session-rotation.459', { 0: w.open }) : null, w.verify ? modelText('session-rotation.460', { 0: w.verify }) : null,
|
|
164
|
+
facts.diffStat ? modelText('session-rotation.461', { 0: facts.diffStat }) : null,
|
|
165
|
+
facts.openTasks?.length ? modelText('session-rotation.462', { 0: facts.openTasks.map(t => `${t.id} ${t.subject}`).join(';') }) : null,
|
|
166
|
+
facts.lastTest ? modelText('session-rotation.463', { 0: facts.lastTest.command, 1: facts.lastTest.isError ? modelText('session-rotation.464') : modelText('session-rotation.465'), 2: facts.lastTest.summary }) : null,
|
|
167
|
+
modelText('session-rotation.466')].filter(Boolean).join('\n\n');
|
|
168
|
+
planned.set(`${lead.id}:${successor}`, { from: current.name, prompt });
|
|
169
|
+
retired.set(`${lead.id}:${current.name}`, successor);
|
|
170
|
+
return { rotated: true, from: current.name, to: successor, handoff: handoff ? 'member' : 'facts-only',
|
|
171
|
+
next: [`1. interrupt_agent target=${current.name}`, modelText('session-rotation.467', { 0: successor }),
|
|
172
|
+
facts.openTasks?.length ? modelText('session-rotation.468', { 0: facts.openTasks.map(t => t.id).join('、'), 1: successor }) : null].filter(Boolean).join('\n'), prompt };
|
|
173
|
+
},
|
|
174
|
+
register() {
|
|
175
|
+
try { ctx.on('session/event', (session, event) => { if (session?.id && ownsCapabilityTeam(ctx, ctx.agents.get(session.id)) && ctx.agentTeams.tryMembership?.(ctx.agents.get(session.id))?.role === 'teammate') foldEvent(counters(session.id), event); }); } catch { /* host without session observers */ }
|
|
176
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
177
|
+
const identity = ctx.agentTeams.tryMembership(agent);
|
|
178
|
+
if (!ownsCapabilityTeam(ctx, agent) || identity?.role !== 'teammate') return;
|
|
179
|
+
counters(agent.id);
|
|
180
|
+
// Runtime context only says something once a threshold is crossed, so it does not churn the cache.
|
|
181
|
+
agent.ctx.systemPrompt.context({ name: 'capability-team:rotation', order: 98, text: () => {
|
|
182
|
+
const s = api.status(agent);
|
|
183
|
+
if (s.level === 'ok') return '';
|
|
184
|
+
return s.level === 'hard'
|
|
185
|
+
? modelText('session-rotation.469', { 0: s.reasons.join('、') })
|
|
186
|
+
: modelText('session-rotation.470', { 0: s.reasons.join('、'), 1: s.stalled ? modelText('session-rotation.471') : '' });
|
|
187
|
+
} });
|
|
188
|
+
});
|
|
189
|
+
ctx.effect(() => ctx.tools.guard(exec => api.guard(exec)));
|
|
190
|
+
ctx.effect(() => ctx.tools.register({ name: 'capability_handoff', output,
|
|
191
|
+
description: modelText('session-rotation.472'),
|
|
192
|
+
parameters: params({ goal: { type: 'string' }, done: { type: 'string' }, remaining: { type: 'string' }, pitfalls: { type: 'string' }, open: { type: 'string' }, verify: { type: 'string' } }, ['done', 'remaining']),
|
|
193
|
+
async execute(args, exec) { const h = await api.writeHandoff(exec.agent, args); return JSON.stringify({ saved: true, member: h.member, facts: { diffStat: !!h.facts.diffStat, openTasks: h.facts.openTasks.length, lastTest: h.facts.lastTest?.command ?? null } }); },
|
|
194
|
+
}));
|
|
195
|
+
ctx.effect(() => ctx.tools.register({ name: 'capability_rotate_member', output,
|
|
196
|
+
description: modelText('session-rotation.473'),
|
|
197
|
+
parameters: params({ role: { type: 'string', description: modelText('session-rotation.474') }, task: { type: 'string', description: modelText('session-rotation.475') }, without_handoff: { type: 'boolean', description: modelText('session-rotation.476') } }, ['role']),
|
|
198
|
+
async execute(args, exec) {
|
|
199
|
+
if (ctx.agentTeams.tryMembership(exec.agent)?.role !== 'lead') throw new Error(modelText('session-rotation.477'));
|
|
200
|
+
return JSON.stringify(await api.rotate(exec.agent, args));
|
|
201
|
+
},
|
|
202
|
+
}));
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
return api;
|
|
206
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Settings-form support for the standalone omni-search and memory bundles (copied into packages/*/shared/).
|
|
2
|
+
// cordis validates an exported `Config` through Standard Schema (`Config['~standard'].validate`) and DSH's settings
|
|
3
|
+
// page projects only `.volatile()` fields, which need @deepseek-ai/schemastery >= 3.18.4 (resolved from the DSH
|
|
4
|
+
// installation through the optional peer dependency). Outside DSH (plain `node --test`, no node_modules) the
|
|
5
|
+
// library is absent: the bundles then export no Config and keep validating their own plain options.
|
|
6
|
+
let lib = null;
|
|
7
|
+
try { lib = (await import('@deepseek-ai/schemastery')).default; }
|
|
8
|
+
catch (error) { if (error?.code !== 'ERR_MODULE_NOT_FOUND' && error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; }
|
|
9
|
+
/** Refuse a stale copy without `.volatile()` (an old nested node_modules shadowing DSH's) with an actionable error. */
|
|
10
|
+
export function assertVolatile(z, owner) {
|
|
11
|
+
let ok = false;
|
|
12
|
+
try { ok = typeof z.string().default('x').volatile === 'function'; } catch { ok = false; }
|
|
13
|
+
if (!ok) throw new Error(`[${owner}] the resolved @deepseek-ai/schemastery has no .volatile() (needs >= 3.18.4). A stale nested copy (node_modules/${owner}/node_modules/@deepseek-ai/schemastery) probably shadows the one DSH ships; delete it or reinstall the plugin, then restart DSH.`);
|
|
14
|
+
return z;
|
|
15
|
+
}
|
|
16
|
+
/** The schemastery default export, or null when unavailable. `owner` names the bundle in the load error. */
|
|
17
|
+
export const schemastery = owner => (lib ? assertVolatile(lib, owner) : null);
|
|
18
|
+
/** Current value of a volatile reference or plain value; empty strings and empty lists count as unset. */
|
|
19
|
+
export function liveValue(ref) {
|
|
20
|
+
const value = typeof ref?.get === 'function' ? ref.get() : ref;
|
|
21
|
+
if (value === undefined || value === null || (typeof value === 'string' && !value.trim()) || (Array.isArray(value) && !value.length)) return undefined;
|
|
22
|
+
return Array.isArray(value) ? [...value] : typeof value === 'string' ? value.trim() : value;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Split a bundle config into its plain options and a settings layer reader.
|
|
26
|
+
* `fields` maps each settings key to its integrations target `[section, key]`; the reader returns only set values.
|
|
27
|
+
*/
|
|
28
|
+
export function splitSettings(input, fields) {
|
|
29
|
+
const plain = {}; const refs = {};
|
|
30
|
+
for (const [key, value] of Object.entries(input ?? {})) (Object.hasOwn(fields, key) ? refs : plain)[key] = value;
|
|
31
|
+
const read = () => {
|
|
32
|
+
const layer = {};
|
|
33
|
+
for (const [key, [section, target]] of Object.entries(fields)) {
|
|
34
|
+
const value = liveValue(refs[key]);
|
|
35
|
+
if (value !== undefined) (layer[section] ??= {})[target] = value;
|
|
36
|
+
}
|
|
37
|
+
return layer;
|
|
38
|
+
};
|
|
39
|
+
return { plain, read };
|
|
40
|
+
}
|