@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,49 @@
|
|
|
1
|
+
// Managed-config manifest for the browser config panels:
|
|
2
|
+
// GET /api/aiwuk.managed-config.<ext> → { revision, entries: [{ ref, locked, secret, value? }] }
|
|
3
|
+
// `value` is present only for NON-secret refs (URLs, models, engine lists) so the panel can show what the Mothership
|
|
4
|
+
// configured; secret refs never carry a value, only their flags.
|
|
5
|
+
// The host rejects a second registration of one exact path, and team / memory / search can be installed together,
|
|
6
|
+
// so every package registers its own path (<ext> = team | memory | search | autocomplete). The route sits behind the
|
|
7
|
+
// Connection fence (authenticated owner peer, Host and Origin checks); crossSiteRequest() repeats the Origin-vs-Host
|
|
8
|
+
// comparison (never request.url, which the host rebuilds on a fixed internal base).
|
|
9
|
+
import { crossSiteRequest } from './http-guard.js';
|
|
10
|
+
import { managedValues, manifestToWire, readManagedManifest } from './shared/managed-config.js';
|
|
11
|
+
|
|
12
|
+
export const MANAGED_CONFIG_ROUTE_PREFIX = '/api/aiwuk.managed-config';
|
|
13
|
+
export const MANAGED_CONFIG_EXTENSIONS = Object.freeze(['team', 'memory', 'search', 'autocomplete']);
|
|
14
|
+
/** The route path of one extension; unknown extension ids are refused instead of building arbitrary paths. */
|
|
15
|
+
export function managedConfigPath(extension) {
|
|
16
|
+
if (!MANAGED_CONFIG_EXTENSIONS.includes(extension)) throw new Error(`unknown extension "${extension}"`);
|
|
17
|
+
return `${MANAGED_CONFIG_ROUTE_PREFIX}.${extension}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Wire view plus the non-secret values (`managedValues`); secret entries keep only ref / locked / secret. */
|
|
21
|
+
export async function wireWithValues(ctx, manifest) {
|
|
22
|
+
const wire = manifestToWire(manifest);
|
|
23
|
+
const values = await managedValues(ctx?.credentials, manifest);
|
|
24
|
+
for (const entry of wire.entries) if (!entry.secret && Object.hasOwn(values, entry.ref)) entry.value = values[entry.ref];
|
|
25
|
+
return wire;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Request handler: GET only, same-origin, no-store; secrets never carry a value. */
|
|
29
|
+
export async function handleManagedConfig(ctx, request, options) {
|
|
30
|
+
const fail = (status, code) => Response.json({ code }, { status, headers: { 'cache-control': 'no-store' } });
|
|
31
|
+
if (request.method !== 'GET') return fail(405, 'METHOD_NOT_ALLOWED');
|
|
32
|
+
if (crossSiteRequest(request)) return fail(403, 'FORBIDDEN');
|
|
33
|
+
try {
|
|
34
|
+
return Response.json(await wireWithValues(ctx, await readManagedManifest(ctx, options)), { headers: { 'cache-control': 'no-store' } });
|
|
35
|
+
} catch { return fail(503, 'UNAVAILABLE'); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register the route of `extension` on the plugin context. Needs `connection`; `credentials` is optional (without
|
|
40
|
+
* it the file fallback still answers). `options` is for tests (env / home / read).
|
|
41
|
+
*/
|
|
42
|
+
export function registerManagedConfigRoute(ctx, extension, options) {
|
|
43
|
+
const path = managedConfigPath(extension);
|
|
44
|
+
ctx.inject?.(['connection'], scoped => scoped.connection.fetch.register({
|
|
45
|
+
path, methods: ['GET'], requestBody: 'buffered', fetch: request => handleManagedConfig(readerOf(ctx, scoped), request, options),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
// `credentials` is an optional service: look it up lazily on every request so a late mount is picked up.
|
|
49
|
+
const readerOf = (ctx, scoped) => ({ get credentials() { try { return scoped.get?.('credentials') ?? ctx.get?.('credentials'); } catch { return undefined; } } });
|
package/managed-ref.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Resolve ONE named setting (an env-style ref such as AUTOCOMPLETE_MODEL) for consumers that have no integrations
|
|
2
|
+
// section. Same rule as the integrations resolver (contract F.3): a locked managed ref wins over everything local;
|
|
3
|
+
// otherwise credential > env > unlocked managed value. Values are returned to the server-side caller only.
|
|
4
|
+
import { pickLayer } from './managed-resolve.js';
|
|
5
|
+
import { readManagedManifest } from './shared/managed-config.js';
|
|
6
|
+
export { readManagedManifest };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} ref env-style name
|
|
10
|
+
* @param {object} [lookups]
|
|
11
|
+
* @param {object|null} [lookups.manifest] a readManagedManifest() result
|
|
12
|
+
* @param {(name:string)=>unknown} [lookups.credential] stored value by name
|
|
13
|
+
* @param {object} [lookups.env] environment (defaults to process.env)
|
|
14
|
+
* @returns {{value: (string|undefined), source: string, locked: boolean}}
|
|
15
|
+
*/
|
|
16
|
+
export function resolveRef(ref, { manifest = null, credential = () => undefined, env = process.env } = {}) {
|
|
17
|
+
const layer = pickLayer({ names: [ref], entryOf: name => manifest?.entries?.get(name), settings: () => undefined, credential,
|
|
18
|
+
env: name => env[name], file: undefined, validate: raw => String(raw), fallback: undefined });
|
|
19
|
+
return { value: layer.value, source: layer.kind, locked: layer.locked };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read the values of `refs` from a DSH credential service (`resolve(name)` → string | {value}) and resolve each against
|
|
24
|
+
* the managed manifest. Never throws; refs that cannot be read resolve as unset. `env` defaults to {} because the
|
|
25
|
+
* credential service already falls back to the environment.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveRefs(refs, credentials, { env = {}, manifest } = {}) {
|
|
28
|
+
const managed = manifest === undefined ? await readManagedManifest({ credentials }) : manifest;
|
|
29
|
+
const stored = new Map();
|
|
30
|
+
for (const ref of refs) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = typeof credentials?.resolve === 'function' ? await credentials.resolve(ref) : undefined;
|
|
33
|
+
const value = typeof raw === 'string' ? raw : raw?.value;
|
|
34
|
+
if (value) stored.set(ref, value);
|
|
35
|
+
} catch { /* unreadable: unset */ }
|
|
36
|
+
}
|
|
37
|
+
return Object.fromEntries(refs.map(ref => [ref, resolveRef(ref, { manifest: managed, credential: name => stored.get(name), env })]));
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Precedence core of the integrations resolver (contract F.3), pure and value-agnostic.
|
|
2
|
+
// 1. A LOCKED managed ref wins over everything local, settings included; local values for it are ignored.
|
|
3
|
+
// 2. Otherwise: settings > credential > env > integrations.json file > unlocked managed value > default.
|
|
4
|
+
// A managed value is stored by the connector as a credential under the ref's own name, so for a managed ref the
|
|
5
|
+
// credential slot IS the managed value: it is reported as 'managed-locked' / 'managed-default', never 'credential'.
|
|
6
|
+
/** Every source kind a resolved field can report. */
|
|
7
|
+
export const SOURCE_KINDS = Object.freeze(['managed-locked', 'settings', 'credential', 'env', 'file', 'managed-default', 'default']);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pick the effective value of one field.
|
|
11
|
+
* @param {object} input
|
|
12
|
+
* @param {string[]} input.names override names, primary first (a later name is a legacy fallback)
|
|
13
|
+
* @param {(name:string)=>({locked:boolean}|undefined)} input.entryOf managed manifest entry of a name
|
|
14
|
+
* @param {()=>unknown} input.settings raw settings-form value (undefined when unset)
|
|
15
|
+
* @param {(name:string)=>unknown} input.credential raw credential-store value of a name
|
|
16
|
+
* @param {(name:string)=>unknown} input.env raw environment value of a name
|
|
17
|
+
* @param {unknown} input.file raw integrations.json value
|
|
18
|
+
* @param {(raw:unknown, kind:string)=>unknown} input.validate returns the normalized value or throws; an invalid layer is skipped
|
|
19
|
+
* @param {unknown} input.fallback built-in default
|
|
20
|
+
* @returns {{value:unknown, kind:string, name?:string, locked:boolean, managed:boolean}}
|
|
21
|
+
*/
|
|
22
|
+
export function pickLayer({ names, entryOf, settings, credential, env, file, validate, fallback }) {
|
|
23
|
+
const managedNames = names.filter(name => entryOf(name));
|
|
24
|
+
const lockedName = managedNames.find(name => entryOf(name).locked);
|
|
25
|
+
const tryLayer = (kind, raw, name) => {
|
|
26
|
+
// An explicit null in integrations.json is a deliberate value; for every other layer it means "unset".
|
|
27
|
+
if (kind === 'file' ? raw === undefined : (raw === undefined || raw === null || raw === '')) return undefined;
|
|
28
|
+
try { return { value: validate(raw, kind), kind, ...(name ? { name } : {}), locked: kind === 'managed-locked', managed: managedNames.length > 0 }; } catch { return undefined; }
|
|
29
|
+
};
|
|
30
|
+
const pick = (...layers) => { for (const make of layers) { const hit = make(); if (hit) return hit; } return undefined; };
|
|
31
|
+
if (lockedName) {
|
|
32
|
+
return pick(() => tryLayer('managed-locked', credential(lockedName), lockedName))
|
|
33
|
+
?? { value: fallback, kind: 'default', locked: true, managed: true };
|
|
34
|
+
}
|
|
35
|
+
return pick(
|
|
36
|
+
() => tryLayer('settings', settings()),
|
|
37
|
+
// Primary name first; a legacy name only when every earlier one is unset.
|
|
38
|
+
...names.flatMap(name => entryOf(name)
|
|
39
|
+
? [() => tryLayer('env', env(name), name)]
|
|
40
|
+
: [() => tryLayer('credential', credential(name), name), () => tryLayer('env', env(name), name)]),
|
|
41
|
+
() => tryLayer('file', file),
|
|
42
|
+
...managedNames.map(name => () => tryLayer('managed-default', credential(name), name)),
|
|
43
|
+
) ?? { value: fallback, kind: 'default', locked: false, managed: managedNames.length > 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The legacy `sources` string: `credential:NAME` / `env:NAME`; every other kind is reported bare. */
|
|
47
|
+
export const sourceLabel = layer => (layer.name && (layer.kind === 'credential' || layer.kind === 'env') ? `${layer.kind}:${layer.name}` : layer.kind);
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
3
|
+
import { TIER_ORDER } from './defaults.js';
|
|
4
|
+
import { routeMatcher, routeKey } from './model-policy.js';
|
|
5
|
+
import { params } from './tool-schema.js';
|
|
6
|
+
import { ownsCapabilityTeam } from './preset-guard.js';
|
|
7
|
+
import { redactReason } from './task-control-audit.js';
|
|
8
|
+
import { baseName } from './session-rotation.js';
|
|
9
|
+
import { legacyRoleId } from './roster-defaults.js';
|
|
10
|
+
export const ESTIMATES = { simple: 15, medium: 40, complex: 80 };
|
|
11
|
+
export const judgment = (used, estimated) => used > estimated * 2 ? 'unqualified' : used > estimated * 1.5 ? 'warning' : 'within';
|
|
12
|
+
const rank = tier => TIER_ORDER.indexOf(tier);
|
|
13
|
+
const bindings = new WeakMap();
|
|
14
|
+
const leadBindings = new Map();
|
|
15
|
+
export const taskControlSnapshot = id => leadBindings.get(id)?.snapshot({id}) ?? null;
|
|
16
|
+
export const taskControlBinding = agent => bindings.get(agent);
|
|
17
|
+
export function taskError(code, message, status = 422) { return Object.assign(new Error(message), { code, status }); }
|
|
18
|
+
export function createTaskControl(ctx, team, { audit, now = Date.now, onChanged = () => {}, predecessorOf } = {}) {
|
|
19
|
+
const groups = new Map(); let workflow;
|
|
20
|
+
const changed = lead => { group(lead).revision++; try { onChanged(lead.id); } catch { /* notification never changes control */ } };
|
|
21
|
+
let disposed = false;
|
|
22
|
+
const group = lead => { if (!groups.has(lead.id)) groups.set(lead.id, { revision: 0, generation: 0, leases: [], auditTail: [], pending: [], tombstones: new Map(), timer: null, queue: Promise.resolve() }); return groups.get(lead.id); };
|
|
23
|
+
const schedule = lead => {
|
|
24
|
+
const g = group(lead); if (g.timer) clearTimeout(g.timer); g.timer = null;
|
|
25
|
+
if (disposed) return;
|
|
26
|
+
const expiry = Math.min(...g.leases.filter(l => l.status !== 'ended').map(l => l.expiresAt));
|
|
27
|
+
if (!Number.isFinite(expiry)) return;
|
|
28
|
+
g.timer = setTimeout(() => { g.timer = null; void sweep(lead).finally(() => schedule(lead)).catch(() => {}); }, Math.max(0, expiry - now()));
|
|
29
|
+
g.timer.unref?.();
|
|
30
|
+
};
|
|
31
|
+
const validate = (args, method) => {
|
|
32
|
+
const allowed = method === 'budget' ? ['action','role','lease_id','expected_revision','task_id','assignment_id','complexity','timeout_seconds','reason'] : ['action','role','lease_id','expected_revision','provider','model','catalog_version','reason'];
|
|
33
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.getPrototypeOf(args) !== Object.prototype || Object.keys(args).some(k => !allowed.includes(k))) throw taskError('BAD_REQUEST','Unknown fields',400);
|
|
34
|
+
if (!(method === 'budget' ? ['start','finish','inspect'] : ['set','clear','inspect']).includes(args.action)) throw taskError('BAD_REQUEST','Invalid action',400);
|
|
35
|
+
for (const key of allowed.filter(k => !['expected_revision','timeout_seconds'].includes(k))) if (args[key] !== undefined && (typeof args[key] !== 'string' || args[key].length > (key === 'model' ? 512 : 256))) throw taskError('BAD_REQUEST',`Invalid ${key}`,400);
|
|
36
|
+
for (const key of ['expected_revision','timeout_seconds']) if (args[key] !== undefined && (!Number.isSafeInteger(args[key]) || args[key] < 0)) throw taskError('BAD_REQUEST',`Invalid ${key}`,400);
|
|
37
|
+
if (typeof args.role !== 'string' || !args.role || args.role.length > 80) throw taskError('BAD_REQUEST','Invalid role',400);
|
|
38
|
+
if (args.complexity !== undefined && (!Object.hasOwn(ESTIMATES,args.complexity) || !['simple','medium','complex'].includes(args.complexity))) throw taskError('INVALID','Invalid complexity');
|
|
39
|
+
if (args.timeout_seconds !== undefined && (args.timeout_seconds < 60 || args.timeout_seconds > 28800)) throw taskError('INVALID','Invalid timeout');
|
|
40
|
+
};
|
|
41
|
+
const member = (lead, role) => {
|
|
42
|
+
if (!ownsCapabilityTeam(ctx, lead) || ctx.agents.get(lead?.id) !== lead || ctx.agentTeams.tryMembership(lead)?.role !== 'lead') throw taskError('FORBIDDEN', 'Lead only', 403);
|
|
43
|
+
if (typeof role !== 'string' || role.length > 80) throw taskError('BAD_REQUEST','Invalid role',400);
|
|
44
|
+
if (role === 'lead') throw taskError('FORBIDDEN', 'Cannot target Lead', 403);
|
|
45
|
+
const found = ctx.agentTeams.listMembers(lead).find(m => m.name === role && m.role !== 'lead');
|
|
46
|
+
if (!found) throw taskError('NOT_FOUND', 'Member unavailable', 404);
|
|
47
|
+
return found;
|
|
48
|
+
};
|
|
49
|
+
const append = async (lead, action, lease, reason = '', strict = false, intentEventId = null) => {
|
|
50
|
+
const g = group(lead), record = { intentEventId, eventId: randomUUID(), at: now(), action, leadSessionId: lead.id, memberSessionId: lease?.memberSessionId ?? null, leaseId: lease?.leaseId ?? null, roleId: lease?.roleId ?? null, revision: lease?.revision ?? null, actor: lease?._actor ?? {kind:'system',id:lead.id}, from: lease?.override?.from ?? null, to: lease?.override ? {provider:lease.override.provider,model:lease.override.model,tier:lease.override.tier} : null, reasonCode: action, reason: redactReason(reason), source: ['lease-end','threshold-warning','threshold-unqualified'].includes(action) ? 'lifecycle' : lease?._source ?? 'lifecycle', startsUsed: lease?.startsUsed ?? null, estimatedSteps: lease?.estimatedSteps ?? null, complexity: lease?.complexity ?? null, taskId: lease?.taskId ?? null, assignmentId: lease?.assignmentId ?? null };
|
|
51
|
+
try { await audit.append(lead.id, record); } catch { if (strict) throw taskError('AUDIT_UNAVAILABLE', 'Audit unavailable', 503); record.auditWarning = true; }
|
|
52
|
+
record.reason=redactReason(record.reason);record.reasonCode=action;
|
|
53
|
+
g.auditTail = [record, ...g.auditTail].slice(0, 100); changed(lead);
|
|
54
|
+
return record.eventId;
|
|
55
|
+
};
|
|
56
|
+
const end = async (lead, lease, reason) => {
|
|
57
|
+
if (!lease || lease.status === 'ended') return;
|
|
58
|
+
group(lead).tombstones.set(`${lease._generation}:${lease.roleId}`,{assignmentId:lease.assignmentId,judgment:lease.judgment,startsUsed:lease.startsUsed});
|
|
59
|
+
lease._routable=false;
|
|
60
|
+
lease._lastOverride = lease.override; lease.status = 'ended'; lease.override = null; lease.endedAt = now(); lease.endReason = reason; lease.revision++;
|
|
61
|
+
changed(lead); schedule(lead);
|
|
62
|
+
await append(lead, 'lease-end', lease, reason);
|
|
63
|
+
};
|
|
64
|
+
const sweep = async lead => { const endings=[];for (const l of group(lead).leases) if (l.status !== 'ended' && now() >= l.expiresAt) endings.push(end(lead,l,'timeout'));await Promise.all(endings); };
|
|
65
|
+
const serial = (lead, action) => { const g = group(lead), result = g.queue.catch(() => {}).then(action); g.queue = result; return result; };
|
|
66
|
+
const active = (lead, role) => group(lead).leases.find(l => l.roleId === role && l.status !== 'ended');
|
|
67
|
+
// A rotation successor (<role>-rN) works on its predecessor's assignment.
|
|
68
|
+
const currentAssignment = (lead, role) => {const plan=workflow?.state(lead).plan;return plan?.mode==='delegate'?plan.assignments?.find(a=>a.role===role||a.role===baseName(role)):undefined;};
|
|
69
|
+
const requireAssignment = (lead, role) => { if (!currentAssignment(lead, role)) throw taskError('FORBIDDEN', 'Role not in current delegate assignment', 403); };
|
|
70
|
+
const checkCAS = (lease, args) => { if (!lease || args.lease_id !== lease.leaseId || args.expected_revision !== lease.revision) throw taskError('CONFLICT', 'CONFLICT', 409); };
|
|
71
|
+
const steps = id => { const agent = ctx.agents.get(id); try { return ctx.get?.('sessionProjections')?.stateOf(agent.session, 'capabilityTeamReport')?.view?.stepsStarted ?? 0; } catch { return 0; } };
|
|
72
|
+
async function candidates(lead, roleId) {
|
|
73
|
+
member(lead, roleId); const { effective, policy } = await team.load(lead); const entry = effective.roles[roleId] ?? effective.roles[baseName(roleId)] ?? effective.roles[legacyRoleId(baseName(roleId), effective.roles)], capability = effective.capabilities[entry?.capability];
|
|
74
|
+
if (!capability) throw taskError('NOT_FOUND', 'Unknown role', 404);
|
|
75
|
+
const rows = [];
|
|
76
|
+
for (const provider of ctx.llm.listProviders()) {
|
|
77
|
+
let models; try { models = await ctx.llm.listModels(provider.id); } catch { continue; }
|
|
78
|
+
for (const model of models) {
|
|
79
|
+
const tier = TIER_ORDER.findLast(t => effective.tiers[t]?.includes(model.id));
|
|
80
|
+
if (tier && rank(tier) >= rank(capability.tier) && !routeMatcher(policy.disabledRoutes)(routeKey({ provider: provider.id, model: model.id }))) rows.push({ provider: provider.id, model: model.id, tier });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const catalogVersion = createHash('sha256').update(JSON.stringify(rows)).digest('hex');
|
|
84
|
+
return { schemaVersion: 1, role: roleId, catalogVersion, candidates: rows, capability, effective };
|
|
85
|
+
}
|
|
86
|
+
const api = {
|
|
87
|
+
setWorkflow(value) { workflow = value; },
|
|
88
|
+
candidates: async (lead, role) => { const { capability, effective, ...view } = await candidates(lead, role); return view; },
|
|
89
|
+
async dispatch(lead, assignments) {
|
|
90
|
+
const g=group(lead), generation=++g.generation;
|
|
91
|
+
const pending=assignments.map(a=>({role:a.role,complexity:a.complexity??'medium',assignmentId:randomUUID(),generation,consumed:false}));
|
|
92
|
+
const valid=()=>{if(g.generation!==generation)throw taskError('CONFLICT','Dispatch superseded',409);};
|
|
93
|
+
for(const lease of g.leases)lease._routable=false;
|
|
94
|
+
changed(lead);
|
|
95
|
+
await serial(lead, async () => {
|
|
96
|
+
valid();bindings.set(lead,api);leadBindings.set(lead.id,api);
|
|
97
|
+
for(const lease of g.leases){valid();await end(lead,lease,'replaced');valid();}
|
|
98
|
+
g.pending=pending;
|
|
99
|
+
});
|
|
100
|
+
for(const assignment of pending){
|
|
101
|
+
valid();try{member(lead,assignment.role);await api.budget(lead,{action:'start',role:assignment.role,complexity:assignment.complexity,assignment_id:assignment.assignmentId},{kind:'system',id:lead.id});valid();}catch(error){valid();if(error.status!==404)throw error;}
|
|
102
|
+
}
|
|
103
|
+
valid();
|
|
104
|
+
},
|
|
105
|
+
async budget(lead, args, actor = { kind: 'lead', id: lead.id }) {
|
|
106
|
+
validate(args,'budget');
|
|
107
|
+
const generation=group(lead).generation;
|
|
108
|
+
return serial(lead, async () => {
|
|
109
|
+
if(args.action==='start'&&generation!==group(lead).generation)throw taskError('CONFLICT','Dispatch superseded',409);
|
|
110
|
+
const m = member(lead, args.role); bindings.set(lead, api); leadBindings.set(lead.id, api); await sweep(lead);
|
|
111
|
+
if (args.action === 'inspect') return api.snapshot(lead);
|
|
112
|
+
let lease = active(lead, args.role);
|
|
113
|
+
if (args.action === 'finish') { checkCAS(lease, args); await end(lead, lease, 'explicit-finish'); return structuredClone(lease); }
|
|
114
|
+
if (args.action !== 'start') throw taskError('BAD_REQUEST', 'Invalid action', 400);
|
|
115
|
+
requireAssignment(lead, args.role);
|
|
116
|
+
const complexity = args.complexity ?? 'medium', timeout = args.timeout_seconds ?? 7200;
|
|
117
|
+
if (!Object.hasOwn(ESTIMATES,complexity) || !['simple','medium','complex'].includes(complexity)) throw taskError('INVALID', 'Invalid complexity');
|
|
118
|
+
const pending=group(lead).pending.find(a=>a.role===args.role&&a.generation===group(lead).generation);
|
|
119
|
+
if(group(lead).tombstones.has(`${generation}:${args.role}`) || pending?.consumed)throw taskError('CONFLICT','Assignment already consumed; dispatch a new task',409);
|
|
120
|
+
const assignmentId=args.assignment_id ?? pending?.assignmentId ?? lease?.assignmentId ?? `generation:${group(lead).generation}:${args.role}`;
|
|
121
|
+
if(args.task_id){const task=ctx.agentTeams.listTasks(lead).find(t=>t.id===args.task_id);if(!task || (task.ownerName && task.ownerName!==args.role) || (task.owner && ![args.role,m.id].includes(task.owner)))throw taskError('INVALID','Task must exist and belong to member or be unassigned');}
|
|
122
|
+
if(lease){checkCAS(lease,args);throw taskError('CONFLICT','Dispatch a new assignment before starting a new task',409);}
|
|
123
|
+
if(!lease && (args.lease_id!==undefined || args.expected_revision!==undefined))throw taskError('CONFLICT','CONFLICT',409);
|
|
124
|
+
if (args.assignment_id && !group(lead).pending.some(a=>a.role===args.role&&a.assignmentId===args.assignment_id)) throw taskError('FORBIDDEN','Assignment mismatch',403);
|
|
125
|
+
if (typeof args.task_id !== 'undefined' && (typeof args.task_id !== 'string' || args.task_id.length>256)) throw taskError('BAD_REQUEST','Invalid task id',400);
|
|
126
|
+
const newLease = { schemaVersion: 1, leadSessionId: lead.id, memberSessionId: m.id, leaseId: randomUUID(), revision: 1, roleId: args.role, taskId: args.task_id ?? null, assignmentId, status: 'armed', createdAt: now(), startedAt: null, endedAt: null, expiresAt: now() + timeout * 1000, endReason: null, complexity, estimatedSteps: ESTIMATES[complexity], baselineStarts: steps(m.id), startsUsed: 0, judgment: 'within', override: null };
|
|
127
|
+
newLease._generation=generation;newLease._routable=true;newLease._actor=actor;newLease._source=actor.kind==='owner'?'panel':'tool';
|
|
128
|
+
await append(lead, 'budget-start', newLease, args.reason, true);
|
|
129
|
+
requireAssignment(lead,args.role);if(group(lead).generation!==generation)throw taskError('CONFLICT','Assignment generation changed',409);if(lease)checkCAS(lease,args);
|
|
130
|
+
if (lease) await end(lead, lease, 'task-replaced');
|
|
131
|
+
const g = group(lead); if (g.leases.filter(l => l.status !== 'ended').length >= 64) throw taskError('LIMIT', 'Active lease limit');
|
|
132
|
+
g.leases = [newLease, ...g.leases].filter((l, i, all) => l.status !== 'ended' || all.slice(0,i).filter(x=>x.status==='ended').length < 100);
|
|
133
|
+
if(pending)pending.consumed=true;
|
|
134
|
+
changed(lead);schedule(lead);
|
|
135
|
+
const live=ctx.agents.get(m.id); if(live)bindings.set(live, api); return structuredClone(newLease);
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
/**
|
|
139
|
+
* Rotation: the successor `to` continues the predecessor's lease of the current dispatch — same assignment,
|
|
140
|
+
* task, complexity, deadline and per-task override, with the steps already used carried over — so the budget
|
|
141
|
+
* keeps counting without a manual start. Returns the new lease, or null when there is nothing to continue.
|
|
142
|
+
*/
|
|
143
|
+
async inherit(lead, from, to) {
|
|
144
|
+
return serial(lead, async () => {
|
|
145
|
+
const g = group(lead), m = member(lead, to); await sweep(lead);
|
|
146
|
+
if (active(lead, to)) return null;
|
|
147
|
+
const prev = g.leases.find(l => l.roleId === from && l._generation === g.generation);
|
|
148
|
+
if (!prev || !currentAssignment(lead, to)) return null;
|
|
149
|
+
const override = prev.override ?? prev._lastOverride ?? null;
|
|
150
|
+
if (prev.status !== 'ended') await end(lead, prev, 'rotated');
|
|
151
|
+
const used = prev.startsUsed;
|
|
152
|
+
const { _actor, _source, _generation, _routable, _lastOverride, ...plain } = prev;
|
|
153
|
+
const lease = { ...structuredClone(plain), leaseId: randomUUID(), revision: 1, roleId: to, memberSessionId: m.id, status: 'armed',
|
|
154
|
+
createdAt: now(), startedAt: null, endedAt: null, endReason: null, expiresAt: Math.max(prev.expiresAt, now() + 30 * 60000),
|
|
155
|
+
baselineStarts: steps(m.id) - used, startsUsed: used, override };
|
|
156
|
+
Object.assign(lease, { _generation: g.generation, _routable: true, _actor: { kind: 'system', id: lead.id }, _source: 'lifecycle' });
|
|
157
|
+
await append(lead, 'budget-inherit', lease, `rotated from ${from}`);
|
|
158
|
+
g.leases = [lease, ...g.leases]; changed(lead); schedule(lead);
|
|
159
|
+
const live = ctx.agents.get(m.id); if (live) bindings.set(live, api);
|
|
160
|
+
return structuredClone(lease);
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
async override(lead, args, actor = { kind: 'lead', id: lead.id }) {
|
|
164
|
+
validate(args,'override');
|
|
165
|
+
return serial(lead, async () => {
|
|
166
|
+
member(lead, args.role); await sweep(lead); const lease = active(lead,args.role);
|
|
167
|
+
if (args.action === 'inspect') return structuredClone(lease??null);
|
|
168
|
+
checkCAS(lease,args);
|
|
169
|
+
if (args.action === 'clear') { lease._actor=actor;lease._source=actor.kind==='owner'?'panel':'tool';await append(lead,'override-clear',lease,args.reason,true);checkCAS(lease,args);await sweep(lead);checkCAS(lease,args);lease.override=null; lease.revision++;changed(lead);return structuredClone(lease); }
|
|
170
|
+
if (args.action !== 'set') throw taskError('BAD_REQUEST','Invalid action',400);
|
|
171
|
+
requireAssignment(lead,args.role); const catalog = await candidates(lead,args.role);
|
|
172
|
+
if (args.catalog_version && args.catalog_version !== catalog.catalogVersion) throw taskError('CONFLICT','CONFLICT',409);
|
|
173
|
+
const selected = catalog.candidates.find(r=>r.provider===args.provider&&r.model===args.model); if(!selected) throw taskError('INVALID_MODEL','Model unavailable or below capability tier');
|
|
174
|
+
requireAssignment(lead,args.role);await sweep(lead);checkCAS(lease,args);
|
|
175
|
+
lease._actor=actor;lease._source=actor.kind==='owner'?'panel':'tool';
|
|
176
|
+
const intent=await append(lead,'override-set',{...lease,override:{...selected,from:lease.override??catalog.capability}},args.reason,true);
|
|
177
|
+
try{requireAssignment(lead,args.role);await sweep(lead);checkCAS(lease,args);
|
|
178
|
+
if(lease.status==='ended'||lease._routable===false||lease._generation!==group(lead).generation)throw taskError('CONFLICT','Lease invalidated while auditing',409);
|
|
179
|
+
}catch(error){await append(lead,'override-rejected',lease,'',false,intent);throw error;}
|
|
180
|
+
lease.override={...selected,setAt:now(),from:lease.override?{provider:lease.override.provider,model:lease.override.model}:{provider:catalog.capability.provider,model:catalog.capability.model},reason:redactReason(args.reason),actor}; lease.revision++; changed(lead);
|
|
181
|
+
return structuredClone(lease);
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
async route(agent, role) {
|
|
185
|
+
const ownIdentity=ctx.agentTeams.tryMembership(agent);
|
|
186
|
+
if(ownIdentity?.role==='lead') {await api.update(agent);return role;}
|
|
187
|
+
const identity=ctx.agentTeams.tryMembership(agent); if(!identity || identity.role==='lead') return role;
|
|
188
|
+
const lead=identity.root ?? ctx.agents.get(identity.leadSessionId ?? identity.rootId); if(!lead) return role;
|
|
189
|
+
const before=active(lead,identity.name);if(before&&(before._routable===false||before._generation!==group(lead).generation||now()>=before.expiresAt))return role;
|
|
190
|
+
await api.update(lead); const lease=active(lead,identity.name); if(!lease?.override || lease._routable===false || lease._generation!==group(lead).generation || now()>=lease.expiresAt) return role;
|
|
191
|
+
const revision=lease.revision, override=lease.override;
|
|
192
|
+
const catalog=await candidates(lead,identity.name);
|
|
193
|
+
if(lease._routable===false || lease._generation!==group(lead).generation || active(lead,identity.name)!==lease || lease.status==='ended' || lease.revision!==revision || lease.override!==override || now()>=lease.expiresAt)return role;
|
|
194
|
+
const chosen=catalog.candidates.find(r=>r.provider===override.provider&&r.model===override.model);
|
|
195
|
+
if(!chosen) { lease.override=null; lease.revision++; await append(lead,'override-clear',lease,'override-invalid'); return role; }
|
|
196
|
+
return {...role,provider:chosen.provider,model:chosen.model,fallbacks:(role.fallbacks??[]).filter(r=>catalog.candidates.some(c=>c.provider===r.provider&&c.model===r.model&&rank(c.tier)>=rank(chosen.tier))),longContext:undefined,taskLeaseSignature:`${lease.leaseId}:${lease.revision}`};
|
|
197
|
+
},
|
|
198
|
+
async status(agent,status) {
|
|
199
|
+
for(const [id,g] of groups) { const lead=ctx.agents.get(id); if(!lead) continue;
|
|
200
|
+
for(const l of g.leases.filter(l=>l.memberSessionId===agent.id&&l.status!=='ended')) {
|
|
201
|
+
if(status==='disposed') await end(lead,l,'disposed');
|
|
202
|
+
else if(status==='running'&&l.status==='armed') {l.status='running';l.startedAt=now();l.revision++;changed(lead);}
|
|
203
|
+
else if(status==='idle'&&l.status==='running'&&agent.status==='idle') {await api.update(lead);const revision=l.revision;await Promise.resolve();if(agent.status==='idle'&&l.revision===revision&&active(lead,l.roleId)===l)await end(lead,l,'idle');}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
async update(lead) {
|
|
208
|
+
await sweep(lead); for(const l of group(lead).leases.filter(l=>l.status!=='ended')) {
|
|
209
|
+
const task=l.taskId ? ctx.agentTeams.listTasks(lead).find(t=>t.id===l.taskId):null;
|
|
210
|
+
if(task?.status==='completed') {await end(lead,l,'task-completed');continue;}
|
|
211
|
+
const used=Math.max(l.startsUsed,steps(l.memberSessionId)-l.baselineStarts);if(used!==l.startsUsed){l.startsUsed=used;l.revision++;changed(lead);}
|
|
212
|
+
const next=judgment(l.startsUsed,l.estimatedSteps);
|
|
213
|
+
if(['within','warning','unqualified'].indexOf(next)>['within','warning','unqualified'].indexOf(l.judgment)){l.judgment=next;l.revision++;await append(lead,`threshold-${next}`,l);}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
snapshot(lead) {const g=group(lead);for(const l of g.leases)if(l.status!=='ended'&&now()>=l.expiresAt){void end(lead,l,'timeout').catch(()=>{});}return structuredClone({schemaVersion:1,revision:g.revision,leases:g.leases.map(({_actor,_source,_generation,_routable,_lastOverride,...lease})=>lease),auditTail:g.auditTail});},
|
|
217
|
+
summary(lead) {for(const l of group(lead).leases.filter(l=>l.status!=='ended')){const used=Math.max(l.startsUsed,steps(l.memberSessionId)-l.baselineStarts);const next=judgment(used,l.estimatedSteps);if(used!==l.startsUsed){l.startsUsed=used;l.revision++;changed(lead);}if(['within','warning','unqualified'].indexOf(next)>['within','warning','unqualified'].indexOf(l.judgment)){l.judgment=next;l.revision++;void append(lead,`threshold-${next}`,l);}}return JSON.stringify(api.snapshot(lead).leases.filter(l=>l.status!=='ended'&&l.judgment!=='within').map(l=>({role:l.roleId,used:l.startsUsed,estimated:l.estimatedSteps,judgment:l.judgment}))).slice(0,2000);},
|
|
218
|
+
dispose() {disposed=true;for(const [id,g] of groups){if(g.timer)clearTimeout(g.timer);leadBindings.delete(id);}groups.clear();},
|
|
219
|
+
register() {
|
|
220
|
+
ctx.effect(()=>()=>api.dispose());
|
|
221
|
+
ctx.inject?.(['sessionProjections'], scoped => scoped.effect(() => scoped.sessionProjections.onChanged((session,key) => {
|
|
222
|
+
if (!['capabilityTeamReport','agentTeam'].includes(key)) return;
|
|
223
|
+
for (const [id,g] of groups) { const lead=ctx.agents.get(id); if(!lead)continue;
|
|
224
|
+
for(const l of g.leases) if(l.memberSessionId===session.header.id && l.status==='armed' && steps(l.memberSessionId)>l.baselineStarts) {l.status='running';l.startedAt=now();l.revision++;changed(lead);}
|
|
225
|
+
void api.update(lead).catch(()=>{});
|
|
226
|
+
}
|
|
227
|
+
})));
|
|
228
|
+
ctx.on('agent/status',({agent,status})=>{if(ownsCapabilityTeam(ctx,agent))void api.status(agent,status).catch(()=>{});});
|
|
229
|
+
ctx.on('agent/created',({agent})=>{if(!ownsCapabilityTeam(ctx,agent))return;const identity=ctx.agentTeams.tryMembership(agent);if(identity?.role==='teammate'){const lead=identity.root;const pending=group(lead).pending.find(a=>a.role===identity.name&&!a.consumed&&!a.claimed&&a.generation===group(lead).generation);if(pending){pending.claimed=true;void api.budget(lead,{action:'start',role:pending.role,complexity:pending.complexity,assignment_id:pending.assignmentId},{kind:'system',id:lead.id}).catch(()=>{});}else{const from=predecessorOf?.(lead.id,identity.name);if(from)void api.inherit(lead,from,identity.name).catch(()=>{});}}});
|
|
230
|
+
ctx.on('agent/disposed',({agent})=>{if(ownsCapabilityTeam(ctx,agent))void api.status(agent,'disposed').catch(()=>{});});
|
|
231
|
+
for(const [name,method] of [['capability_task_budget','budget'],['capability_member_override','override']]) ctx.effect(()=>ctx.tools.register({name,description:modelText('member-task-control.265'),parameters:params(Object.fromEntries((method==='budget'?['action','role','lease_id','expected_revision','task_id','assignment_id','complexity','timeout_seconds','reason']:['action','role','lease_id','expected_revision','provider','model','catalog_version','reason']).map(k=>[k,{type:['expected_revision','timeout_seconds'].includes(k)?'number':'string',...(k==='action'?{enum:method==='budget'?['start','finish','inspect']:['set','clear','inspect']}:k==='complexity'?{enum:['simple','medium','complex']}:{})}])),['action','role']),output:{schema:{type:'string'},render:(_a,v)=>[{type:'text',text:v}]},execute:async(args,exec)=>JSON.stringify(await api[method](exec.agent,args))}));
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
for (const method of ['budget','override']) {
|
|
235
|
+
const original=api[method];
|
|
236
|
+
api[method]=async (lead,args,actor)=>{try{return await original(lead,args,actor);}catch(error){
|
|
237
|
+
if(ctx.agentTeams.tryMembership(lead)?.role==='lead') await append(lead,'rejected',null,error.code??'rejected');
|
|
238
|
+
throw error;
|
|
239
|
+
}};
|
|
240
|
+
}
|
|
241
|
+
return api;
|
|
242
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Pluggable shared long-term memory backends for the memory hub (team plugin) and the global-memory package.
|
|
3
|
+
// The local store (memory-store.js) is always the source of truth; a backend only mirrors memories to a
|
|
4
|
+
// shared service and merges that service's recall into local results. `integrations.memory.backend` picks one.
|
|
5
|
+
//
|
|
6
|
+
// Backend interface (every backend factory returns an object with exactly these members):
|
|
7
|
+
// name registry id, e.g. 'openviking'
|
|
8
|
+
// enabled (getter) true only when the backend is selected and configured
|
|
9
|
+
// lastError last failure message, or null; never contains key values
|
|
10
|
+
// enqueue(memory, who) queue one redacted memory for sync; returns true when queued, false when disabled
|
|
11
|
+
// revoke(memories) mark superseded memories withdrawn (filtered from recall, withdrawal sent upstream)
|
|
12
|
+
// recall(query, limit) Promise<[{ score, via: [name], memory: { id, kind, scope: { kind: name }, text } }]>,
|
|
13
|
+
// [] when disabled or unreachable (never rejects for availability failures)
|
|
14
|
+
// flush() Promise<{ sent }>: send queued memories now
|
|
15
|
+
// status() Promise<{ enabled, ... }>: health for diagnostics
|
|
16
|
+
// pending() number of queued, unsent memories
|
|
17
|
+
// A factory receives the options given to createMemoryBackend: { settings, queuePath, label, fetcher, now,
|
|
18
|
+
// schedule, warn }; `queuePath` may be a function of the backend id so each backend keeps its own queue file
|
|
19
|
+
// (when undefined, the backend picks its default). `label` names the caller (e.g. the team or global-memory) in text sent upstream.
|
|
20
|
+
//
|
|
21
|
+
// Adding a backend (e.g. mem0): create memory-mem0.js exporting createMem0(options) that implements the
|
|
22
|
+
// interface above, then add one line `mem0: createMem0,` (plus its import) to MEMORY_BACKEND_REGISTRY.
|
|
23
|
+
// The config validator's MEMORY_BACKENDS list (integrations-config.js) must accept the same id.
|
|
24
|
+
import { createOpenViking, memorySettings } from './memory-openviking.js';
|
|
25
|
+
|
|
26
|
+
/** Backend used when `integrations.memory.backend` is 'local': memories stay in the local store only. */
|
|
27
|
+
export function createLocalBackend() {
|
|
28
|
+
return {
|
|
29
|
+
name: 'local',
|
|
30
|
+
enabled: false,
|
|
31
|
+
lastError: null,
|
|
32
|
+
enqueue: () => false,
|
|
33
|
+
revoke() {},
|
|
34
|
+
recall: async () => [],
|
|
35
|
+
flush: async () => ({ sent: 0 }),
|
|
36
|
+
status: async () => ({ enabled: false }),
|
|
37
|
+
pending: () => 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Backend id → factory. One line per backend. */
|
|
42
|
+
export const MEMORY_BACKEND_REGISTRY = Object.freeze({
|
|
43
|
+
local: createLocalBackend,
|
|
44
|
+
openviking: createOpenViking,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The backend selected by `integrations.memory.backend`, re-read on every call so config edits apply without a
|
|
49
|
+
* reload. Backends are created lazily on first use (no queue recovery or timers for an unselected backend) and
|
|
50
|
+
* then reused. `options.backend` pins an id (tests); `options.settings` fixes { config, key } like memorySettings().
|
|
51
|
+
* `options.lazy` false (default) creates the selected backend at once, so a queue left by a previous run is
|
|
52
|
+
* recovered and flushed on start; true defers that to first use (profiles that may never use memory sync).
|
|
53
|
+
*/
|
|
54
|
+
export function createMemoryBackend(options = {}) {
|
|
55
|
+
const instances = new Map();
|
|
56
|
+
let pickError = null;
|
|
57
|
+
const pick = () => {
|
|
58
|
+
let name = options.backend;
|
|
59
|
+
if (!name) {
|
|
60
|
+
try { name = (options.settings ?? memorySettings()).config.backend; pickError = null; }
|
|
61
|
+
catch (error) { pickError = error.message; name = 'local'; }
|
|
62
|
+
}
|
|
63
|
+
if (!Object.hasOwn(MEMORY_BACKEND_REGISTRY, name)) name = 'local';
|
|
64
|
+
if (!instances.has(name)) {
|
|
65
|
+
const queuePath = typeof options.queuePath === 'function' ? options.queuePath(name) : options.queuePath;
|
|
66
|
+
try { instances.set(name, MEMORY_BACKEND_REGISTRY[name]({ ...options, queuePath })); }
|
|
67
|
+
catch (error) {
|
|
68
|
+
// A corrupt queue file or bad backend settings must not fail every memory search/write; fall back to local.
|
|
69
|
+
pickError = modelText('memory-backends.266', { 0: name, 1: error?.message ?? error });
|
|
70
|
+
if (name === 'local') throw error;
|
|
71
|
+
if (!instances.has('local')) instances.set('local', MEMORY_BACKEND_REGISTRY.local({ ...options, queuePath: typeof options.queuePath === 'function' ? options.queuePath('local') : options.queuePath }));
|
|
72
|
+
return instances.get('local');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return instances.get(name);
|
|
76
|
+
};
|
|
77
|
+
if (!options.lazy) pick();
|
|
78
|
+
return {
|
|
79
|
+
get name() { return pick().name; },
|
|
80
|
+
get enabled() { return pick().enabled; },
|
|
81
|
+
get lastError() { const b = pick(); return pickError ?? b.lastError; },
|
|
82
|
+
enqueue: (memory, who) => pick().enqueue(memory, who),
|
|
83
|
+
revoke: memories => pick().revoke(memories),
|
|
84
|
+
recall: (query, limit) => pick().recall(query, limit),
|
|
85
|
+
flush: () => pick().flush(),
|
|
86
|
+
status: () => pick().status(),
|
|
87
|
+
pending: () => pick().pending(),
|
|
88
|
+
};
|
|
89
|
+
}
|
package/memory-embed.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Embeddings for the memory hub over an OpenAI-compatible embeddings endpoint. Everything about the
|
|
3
|
+
// endpoint is configuration (integrations.embeddings): base URL, path, extra headers, model, and the optional
|
|
4
|
+
// provider / dimensions / task fields some gateways (e.g. a Jina-backed one) accept. Nothing is assumed.
|
|
5
|
+
// Environment: EMBEDDING_BASE_URL / EMBEDDING_API_KEY / EMBEDDING_MODEL / EMBEDDING_DIMENSIONS (legacy
|
|
6
|
+
// CPA_EMBED_* names are still read when the standard ones are unset; see integrations-config.js).
|
|
7
|
+
// Any failure returns undefined: the hub then uses full-text search and embeds missing vectors later.
|
|
8
|
+
import { keyOf, refreshCredentials, resolveIntegrations } from './integrations-config.js';
|
|
9
|
+
const STATUS_HINT = { 400: modelText('memory-embed.267'), 401: modelText('memory-embed.268'), 403: modelText('memory-embed.269'), 404: modelText('memory-embed.270'), 429: modelText('memory-embed.271'), 502: modelText('memory-embed.272'), 504: modelText('memory-embed.273') };
|
|
10
|
+
/** Embeddings config and key as resolved now. */
|
|
11
|
+
export function embedSettings(resolved = resolveIntegrations()) {
|
|
12
|
+
return { config: resolved.effective.embeddings, key: keyOf('embeddings', resolved), keyEnv: resolved.keys.embeddings.env };
|
|
13
|
+
}
|
|
14
|
+
/** Vectors are only comparable within one model and dimension count; this tags them. */
|
|
15
|
+
export const vectorKey = config => `${config.model}@${config.dimensions ?? 'default'}`;
|
|
16
|
+
function missing(settings) {
|
|
17
|
+
const { config, key, keyEnv } = settings;
|
|
18
|
+
const gaps = [!config.baseUrl && 'embeddings.baseUrl', !config.model && 'embeddings.model', !key && modelText('memory-embed.274', { 0: keyEnv })].filter(Boolean);
|
|
19
|
+
return gaps.length ? modelText('memory-embed.275', { 0: gaps.join('、') }) : null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `settings` is a function returning { config, key, keyEnv } (re-read on every call, so edits apply at once)
|
|
23
|
+
* or a fixed object. `embed(texts, purpose)` takes purpose 'passage' (stored) or 'query' (searching).
|
|
24
|
+
*/
|
|
25
|
+
export function createEmbedder({ settings = embedSettings, fetcher = fetch } = {}) {
|
|
26
|
+
const current = () => { try { return typeof settings === 'function' ? settings() : settings; } catch (error) { return { error: error.message }; } };
|
|
27
|
+
let lastError = null;
|
|
28
|
+
return {
|
|
29
|
+
get available() { const s = current(); return !s.error && !missing(s); },
|
|
30
|
+
get lastError() { const s = current(); return s.error ?? missing(s) ?? lastError; },
|
|
31
|
+
get vectorKey() { const s = current(); return s.error || missing(s) ? null : vectorKey(s.config); },
|
|
32
|
+
async embed(texts, purpose = 'passage') {
|
|
33
|
+
if (settings === embedSettings) await refreshCredentials().catch(() => {});
|
|
34
|
+
const s = current();
|
|
35
|
+
if (s.error || missing(s) || !texts.length) return undefined;
|
|
36
|
+
const { config, key } = s;
|
|
37
|
+
const task = purpose === 'query' ? config.queryTask : config.passageTask;
|
|
38
|
+
const vectors = [];
|
|
39
|
+
for (let i = 0; i < texts.length; i += config.batch) {
|
|
40
|
+
const input = texts.slice(i, i + config.batch);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetcher(`${config.baseUrl}${config.path}`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(config.timeoutMs),
|
|
44
|
+
headers: { ...config.headers, authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ model: config.model, input, ...(config.provider ? { provider: config.provider } : {}),
|
|
46
|
+
...(config.dimensions ? { dimensions: config.dimensions } : {}), ...(task ? { task } : {}) }) });
|
|
47
|
+
} catch (error) { lastError = modelText('memory-embed.276', { 0: error.message ?? error }); return undefined; }
|
|
48
|
+
if (!response.ok) { lastError = modelText('memory-embed.277', { 0: response.status, 1: STATUS_HINT[response.status] ? `(${STATUS_HINT[response.status]})` : '' }); return undefined; }
|
|
49
|
+
let body;
|
|
50
|
+
try { body = await response.json(); } catch { lastError = modelText('memory-embed.278'); return undefined; }
|
|
51
|
+
const rows = Array.isArray(body?.data) ? [...body.data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) : [];
|
|
52
|
+
// A 200 with a status page or route list instead of vectors is a failure, not a result.
|
|
53
|
+
if (rows.length !== input.length || rows.some(r => !Array.isArray(r.embedding) || !r.embedding.length)) { lastError = modelText('memory-embed.279'); return undefined; }
|
|
54
|
+
vectors.push(...rows.map(r => normalize(r.embedding)));
|
|
55
|
+
}
|
|
56
|
+
lastError = null;
|
|
57
|
+
return { model: vectorKey(config), dimensions: vectors[0]?.length, vectors };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function normalize(v) {
|
|
62
|
+
let n = 0; for (const x of v) n += x * x;
|
|
63
|
+
n = Math.sqrt(n) || 1;
|
|
64
|
+
return v.map(x => x / n);
|
|
65
|
+
}
|
|
66
|
+
export function cosine(a, b) {
|
|
67
|
+
if (!a || !b || a.length !== b.length) return undefined;
|
|
68
|
+
let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i];
|
|
69
|
+
return s;
|
|
70
|
+
}
|