@aiwuk/team 0.0.0-stage → 5.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +175 -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 +113 -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-models.js +84 -0
- package/member-task-control.js +242 -0
- package/memory-backends.js +89 -0
- package/memory-embed.js +70 -0
- package/memory-hub.js +140 -0
- package/memory-openviking.js +101 -0
- package/memory-redact.js +34 -0
- package/memory-store.js +270 -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 +116 -0
- package/onboarding.js +47 -0
- package/opencode.js +74 -0
- package/package.json +155 -4
- package/plan-gate.js +92 -0
- package/preset-guard.js +47 -0
- package/request-evidence.js +41 -0
- package/roles.js +105 -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 +96 -0
- package/workflow.js +246 -0
package/runtime-view.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Shared read-only bridge. Keys are Agent objects, never guessed from a project or display name.
|
|
3
|
+
const readers = new WeakMap();
|
|
4
|
+
const writers = new WeakMap();
|
|
5
|
+
export function registerPolicyWriter(agent, save) {
|
|
6
|
+
writers.set(agent, save);
|
|
7
|
+
return () => { if (writers.get(agent) === save) writers.delete(agent); };
|
|
8
|
+
}
|
|
9
|
+
export async function saveTeamPolicy(agent, routes, revision) {
|
|
10
|
+
const save = writers.get(agent);
|
|
11
|
+
if (!save) throw new Error(modelText('runtime-view.372'));
|
|
12
|
+
return save(routes, revision);
|
|
13
|
+
}
|
|
14
|
+
export function registerTeamReader(agent, read) {
|
|
15
|
+
readers.set(agent, read);
|
|
16
|
+
return () => { if (readers.get(agent) === read) readers.delete(agent); };
|
|
17
|
+
}
|
|
18
|
+
export async function readTeamSnapshot(agent) {
|
|
19
|
+
const read = readers.get(agent);
|
|
20
|
+
return read ? await read() : null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bridge from the team memory hub to outside coding agents (e.g. a Claude Code UserPromptSubmit hook).
|
|
3
|
+
// Recall sees only project and global pitfalls/decisions: teammates' role-private reports and session memories
|
|
4
|
+
// never leave the hub, nothing is embedded or sent over the network, and recall never updates hit counters.
|
|
5
|
+
// Writes are explicit, local, redacted, and limited to project/global scope so every team role can recall them.
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { m } from '../i18n.js';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { createMemoryStore, tokenize } from '../memory-store.js';
|
|
10
|
+
import { redactSecrets } from '../memory-redact.js';
|
|
11
|
+
import { defaultRoot, projectRoot } from '../theme.js';
|
|
12
|
+
|
|
13
|
+
export const RECALL_KINDS = ['pitfall', 'decision'];
|
|
14
|
+
export const RECALL_ROLE = 'external-agent'; // never a team role id, so role-scoped memories stay hidden
|
|
15
|
+
export const RECALL_HEADER = m('memory.recallHeader');
|
|
16
|
+
export const RECALL_DEFAULTS = { k: 6, maxChars: 2400, maxQuery: 2000, minShared: 2 };
|
|
17
|
+
|
|
18
|
+
export const WRITE_KINDS = ['pitfall', 'decision', 'note'];
|
|
19
|
+
export const WRITE_SCOPES = ['project', 'global'];
|
|
20
|
+
export const WRITE_LIMITS = { minText: 4, maxText: 2000, maxKey: 120, maxTags: 10, maxTag: 64 };
|
|
21
|
+
|
|
22
|
+
const meaningful = text => new Set(tokenize(text).filter(t => t.length >= 2));
|
|
23
|
+
|
|
24
|
+
/** Store one memory written on purpose by an outside agent; returns what was saved, never the raw input. */
|
|
25
|
+
export async function writeMemory({ kind, text, scope = 'project', key, tags = [], cwd, root = defaultRoot(), now = Date.now }) {
|
|
26
|
+
if (!WRITE_KINDS.includes(kind)) throw new Error(`kind must be one of ${WRITE_KINDS.join(', ')}`);
|
|
27
|
+
if (!WRITE_SCOPES.includes(scope)) throw new Error(`scope must be one of ${WRITE_SCOPES.join(', ')}`);
|
|
28
|
+
const body = redactSecrets(String(text ?? '').replace(/\s+/g, ' ').trim());
|
|
29
|
+
if (body.length < WRITE_LIMITS.minText) throw new Error('text is too short');
|
|
30
|
+
if (body.length > WRITE_LIMITS.maxText) throw new Error(`text exceeds ${WRITE_LIMITS.maxText} characters`);
|
|
31
|
+
const topic = key == null || key === '' ? null : redactSecrets(String(key).trim());
|
|
32
|
+
if (topic !== null && (!topic || topic.length > WRITE_LIMITS.maxKey)) throw new Error(`key must be 1-${WRITE_LIMITS.maxKey} characters`);
|
|
33
|
+
const labels = [...new Set(tags.map(t => redactSecrets(String(t).trim())).filter(Boolean))];
|
|
34
|
+
if (labels.length > WRITE_LIMITS.maxTags || labels.some(t => t.length > WRITE_LIMITS.maxTag)) throw new Error(`at most ${WRITE_LIMITS.maxTags} tags of ${WRITE_LIMITS.maxTag} characters`);
|
|
35
|
+
let target = { kind: 'global' };
|
|
36
|
+
if (scope === 'project') {
|
|
37
|
+
if (!cwd) throw new Error('project scope needs a working directory');
|
|
38
|
+
target = { kind: 'project', project: projectRoot(cwd, root) };
|
|
39
|
+
}
|
|
40
|
+
const store = createMemoryStore(root, { now });
|
|
41
|
+
const saved = await store.write({ scope: target, kind, text: body, tags: labels, key: topic, source: { member: RECALL_ROLE, at: now() } });
|
|
42
|
+
return { saved: true, id: saved.memory.id, kind, scope, merged: saved.merged, superseded: saved.superseded.length, sync: 'local-only' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Ranked memories for `query`, keeping only those sharing at least `minShared` multi-char tokens with it. */
|
|
46
|
+
export async function recall({ query, cwd, root = defaultRoot(), k = RECALL_DEFAULTS.k, minShared = RECALL_DEFAULTS.minShared }) {
|
|
47
|
+
const text = redactSecrets(String(query ?? '').replace(/\s+/g, ' ').trim().slice(0, RECALL_DEFAULTS.maxQuery));
|
|
48
|
+
if (!text) return [];
|
|
49
|
+
const project = cwd ? projectRoot(cwd, root) : null;
|
|
50
|
+
const store = createMemoryStore(root);
|
|
51
|
+
const ranked = await store.search({ project, role: RECALL_ROLE, query: text, k: k * 3, kinds: RECALL_KINDS });
|
|
52
|
+
const wanted = meaningful(text);
|
|
53
|
+
return ranked.filter(({ memory }) => {
|
|
54
|
+
if (memory.scope.kind !== 'project' && memory.scope.kind !== 'global') return false;
|
|
55
|
+
if (minShared <= 0) return true;
|
|
56
|
+
let shared = 0;
|
|
57
|
+
for (const t of meaningful(memory.text)) if (wanted.has(t) && ++shared >= minShared) return true;
|
|
58
|
+
return false;
|
|
59
|
+
}).slice(0, k);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Injection block within `maxChars`, header included; empty when nothing fits. */
|
|
63
|
+
export function formatRecall(results, maxChars = RECALL_DEFAULTS.maxChars) {
|
|
64
|
+
const lines = []; let used = RECALL_HEADER.length;
|
|
65
|
+
for (const { memory: m } of results) {
|
|
66
|
+
const line = `- [${m.kind}${m.scope.kind === 'global' ? '·global' : ''}] ${redactSecrets(m.text)}`;
|
|
67
|
+
if (used + 1 + line.length > maxChars) continue;
|
|
68
|
+
used += 1 + line.length; lines.push(line);
|
|
69
|
+
}
|
|
70
|
+
return lines.length ? [RECALL_HEADER, ...lines].join('\n') : '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Hook payload (Claude Code UserPromptSubmit JSON on stdin) → hook output JSON string, or '' to add nothing. */
|
|
74
|
+
export async function hookOutput(payload, options = {}) {
|
|
75
|
+
const prompt = payload?.prompt;
|
|
76
|
+
if (typeof prompt !== 'string' || !prompt.trim()) return '';
|
|
77
|
+
const block = formatRecall(await recall({ query: prompt, cwd: payload.cwd || process.cwd(), ...options }), options.maxChars);
|
|
78
|
+
if (!block) return '';
|
|
79
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: block } });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function readStdin() {
|
|
83
|
+
let data = '';
|
|
84
|
+
for await (const chunk of process.stdin) data += chunk;
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseArgs(argv) {
|
|
89
|
+
const out = { _: [] };
|
|
90
|
+
for (let i = 0; i < argv.length; i++) {
|
|
91
|
+
const a = argv[i];
|
|
92
|
+
if (['--cwd', '--k', '--kind', '--scope', '--key', '--tags'].includes(a)) out[a.slice(2)] = argv[++i];
|
|
93
|
+
else out._.push(a);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main(argv) {
|
|
99
|
+
const [command, ...rest] = argv;
|
|
100
|
+
if (command === 'hook') {
|
|
101
|
+
// A recall failure must never block the user's prompt: swallow everything and add no context.
|
|
102
|
+
try { const out = await hookOutput(JSON.parse(await readStdin() || '{}')); if (out) process.stdout.write(out); }
|
|
103
|
+
catch { /* degrade silently */ }
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
if (command === 'search') {
|
|
107
|
+
const args = parseArgs(rest);
|
|
108
|
+
const k = Math.min(20, Math.max(1, Number.parseInt(args.k ?? RECALL_DEFAULTS.k, 10) || RECALL_DEFAULTS.k));
|
|
109
|
+
const results = await recall({ query: args._.join(' '), cwd: args.cwd ?? process.cwd(), k });
|
|
110
|
+
process.stdout.write(JSON.stringify(results.map(({ memory: m, score }) => ({
|
|
111
|
+
id: m.id, kind: m.kind, scope: m.scope.kind, tags: m.tags, score: Number(score.toFixed(5)), text: redactSecrets(m.text),
|
|
112
|
+
})), null, 2) + '\n');
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (command === 'write') {
|
|
116
|
+
// Text comes from the arguments, or from stdin when none are given (safer for long or quoted text).
|
|
117
|
+
const args = parseArgs(rest);
|
|
118
|
+
const text = args._.length ? args._.join(' ') : await readStdin();
|
|
119
|
+
try {
|
|
120
|
+
const result = await writeMemory({ kind: args.kind, scope: args.scope ?? 'project', key: args.key, tags: args.tags ? args.tags.split(',') : [], text, cwd: args.cwd ?? process.cwd() });
|
|
121
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
122
|
+
return 0;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
process.stderr.write(JSON.stringify({ saved: false, error: error.message }) + '\n');
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
process.stderr.write([
|
|
129
|
+
'usage: memory-cli.mjs hook < payload.json',
|
|
130
|
+
' memory-cli.mjs search <query> [--cwd dir] [--k n]',
|
|
131
|
+
' memory-cli.mjs write --kind pitfall|decision|note [--scope project|global] [--key topic] [--tags a,b] [--cwd dir] [text | < text]',
|
|
132
|
+
].join('\n') + '\n');
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
137
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
138
|
+
}
|
|
@@ -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
|
+
}
|