@aiwuk/team 0.0.0-stage → 5.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/README.zh-CN.md +150 -0
- package/access.js +93 -0
- package/activity-summary.js +36 -0
- package/allocation.js +34 -0
- package/board-dispatch.js +141 -0
- package/brand.js +41 -0
- package/client/00-core-zh.js +284 -0
- package/client/01-i18n-en.js +265 -0
- package/client/02-styles.js +336 -0
- package/client/02b-stage-styles.js +65 -0
- package/client/03-shared.js +82 -0
- package/client/03b-collapsible-list.js +34 -0
- package/client/03c-config-panel.js +605 -0
- package/client/04-tool-cards.js +110 -0
- package/client/04a-dispatch-confirm.js +162 -0
- package/client/04b-more-cards.js +78 -0
- package/client/04c-model-picker.js +68 -0
- package/client/04d-confirm-dock.js +40 -0
- package/client/05-policy.js +217 -0
- package/client/06-usage.js +195 -0
- package/client/06b-model-source.js +67 -0
- package/client/07-board-parts.js +312 -0
- package/client/07a-role-theme.js +25 -0
- package/client/08-artifacts-report.js +375 -0
- package/client/08a-task-control.js +126 -0
- package/client/08b-stage-sprites.js +97 -0
- package/client/08c-stage-colors.js +55 -0
- package/client/08c-stage-scene.js +238 -0
- package/client/08d-stage-adapter.js +65 -0
- package/client/08e-stage-activity.js +83 -0
- package/client/09-team-board.js +181 -0
- package/client/09b-role-view.js +95 -0
- package/client/09c-model-tabs.js +20 -0
- package/client/10-overview.js +364 -0
- package/client/10b-onboarding.js +121 -0
- package/client/11-register.js +249 -0
- package/client.js +5053 -0
- package/config-projection.js +40 -0
- package/config.js +411 -0
- package/cordis.patch.yml +110 -0
- package/defaults.js +222 -0
- package/dispatch-confirm.js +293 -0
- package/dispatch-routes-store.js +31 -0
- package/failover.js +85 -0
- package/file-limits.js +119 -0
- package/free-worker.mjs +125 -0
- package/http-guard.js +14 -0
- package/i18n/roles.en.json +23 -0
- package/i18n/roles.zh.json +23 -0
- package/i18n/server.en.json +659 -0
- package/i18n/server.zh.json +659 -0
- package/i18n.js +34 -0
- package/icon.svg +7 -0
- package/index.js +111 -0
- package/integrations-config.js +301 -0
- package/integrations-messages.js +62 -0
- package/integrations.js +17 -0
- package/lead-route.js +30 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/long-context.js +30 -0
- package/managed-config-route.js +49 -0
- package/managed-ref.js +38 -0
- package/managed-resolve.js +47 -0
- package/member-task-control.js +242 -0
- package/memory-backends.js +89 -0
- package/memory-embed.js +70 -0
- package/memory-hub.js +150 -0
- package/memory-openviking.js +101 -0
- package/memory-redact.js +34 -0
- package/memory-store.js +219 -0
- package/memory-sync-queue.js +28 -0
- package/model-policy.js +85 -0
- package/model-prefs.js +104 -0
- package/observability-api.js +76 -0
- package/observability-projection.js +141 -0
- package/onboarding.js +47 -0
- package/opencode.js +74 -0
- package/package.json +153 -4
- package/plan-gate.js +92 -0
- package/preset-guard.js +47 -0
- package/request-evidence.js +41 -0
- package/roles.js +69 -0
- package/roster-defaults.js +70 -0
- package/route-health.js +39 -0
- package/runtime-observability.js +78 -0
- package/runtime-view.js +21 -0
- package/scripts/memory-cli.mjs +138 -0
- package/search-engines.js +119 -0
- package/search.js +164 -0
- package/session-policy.js +59 -0
- package/session-rotation.js +206 -0
- package/settings-schema.js +40 -0
- package/shared/managed-config.js +84 -0
- package/single-model.js +44 -0
- package/skills/project-role-theme/SKILL.md +46 -0
- package/task-bridge.js +32 -0
- package/task-control-api.js +36 -0
- package/task-control-audit.js +60 -0
- package/team.js +101 -0
- package/teammate-context.js +120 -0
- package/teammate-wait.js +18 -0
- package/template.js +46 -0
- package/theme-plugin.js +172 -0
- package/theme.js +115 -0
- package/token-discipline.js +116 -0
- package/token-metrics.js +37 -0
- package/tool-names.js +10 -0
- package/tool-schema.js +23 -0
- package/transcribe.js +104 -0
- package/ui.js +113 -0
- package/usage.js +92 -0
- package/workflow.js +246 -0
package/plan-gate.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Plan-first teammates: a capability with `planFirst` submits a plan to the Lead and waits for approval before it may
|
|
3
|
+
// run execution tools. A new dispatch plan that assigns the role asks for a fresh plan, so one approval covers one task.
|
|
4
|
+
import { params } from './tool-schema.js';
|
|
5
|
+
import { ownsCapabilityTeam } from './preset-guard.js';
|
|
6
|
+
import { resolveRole } from './config.js';
|
|
7
|
+
import { toolClass } from './workflow.js';
|
|
8
|
+
export const SUBMIT_TOOL = 'capability_submit_plan';
|
|
9
|
+
export const REVIEW_TOOL = 'capability_review_plan';
|
|
10
|
+
const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] };
|
|
11
|
+
const STATUS = { pending: modelText('plan-gate.324'), approved: modelText('plan-gate.325'), rejected: modelText('plan-gate.326') };
|
|
12
|
+
function text(value, max, label) {
|
|
13
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(modelText('plan-gate.327', { 0: label, 1: max }));
|
|
14
|
+
return value.trim();
|
|
15
|
+
}
|
|
16
|
+
/** Pure decision for one teammate call under plan-first; returns a denial reason or undefined. */
|
|
17
|
+
export function planDenial(exec, role, plan, workflow) {
|
|
18
|
+
if (!role.planFirst || exec.name === SUBMIT_TOOL || plan?.status === 'approved') return undefined;
|
|
19
|
+
const kind = toolClass(exec.name, workflow);
|
|
20
|
+
if (kind === 'analysis' || kind === 'coordination') return undefined;
|
|
21
|
+
const state = plan ? modelText('plan-gate.328', { 0: STATUS[plan.status], 1: plan.feedback ? modelText('plan-gate.329', { 0: plan.feedback }) : '' }) : modelText('plan-gate.330');
|
|
22
|
+
return modelText('plan-gate.331', { 0: role.displayName, 1: role.capability, 2: SUBMIT_TOOL, 3: exec.name, 4: state });
|
|
23
|
+
}
|
|
24
|
+
export function createPlanGate(ctx, team) {
|
|
25
|
+
const plans = new Map(); // `${teamId}:${role}` → { status, plan, feedback?, at }
|
|
26
|
+
const keyOf = (membership, role = membership.name) => `${membership.id}:${role}`;
|
|
27
|
+
const roleOf = (agent, membership) => {
|
|
28
|
+
const project = team.pinned(agent);
|
|
29
|
+
const effective = project ? team.loadSync(project, agent).effective : team.base;
|
|
30
|
+
return { role: resolveRole(membership, effective), effective };
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
plans,
|
|
34
|
+
/** Model-facing plan state for a teammate's own context, or undefined when its capability is not plan-first. */
|
|
35
|
+
status(agent, membership, role) {
|
|
36
|
+
if (!role?.planFirst || membership?.role !== 'teammate') return undefined;
|
|
37
|
+
const plan = plans.get(keyOf(membership));
|
|
38
|
+
return plan ? `${STATUS[plan.status]}${plan.feedback ? modelText('plan-gate.332', { 0: plan.feedback }) : ''}` : modelText('plan-gate.333', { 0: SUBMIT_TOOL });
|
|
39
|
+
},
|
|
40
|
+
/** The Lead assigned these roles in a new dispatch plan: earlier approvals do not cover the new task. */
|
|
41
|
+
reset(leadMembership, roles) {
|
|
42
|
+
for (const role of roles) plans.delete(keyOf(leadMembership, role));
|
|
43
|
+
},
|
|
44
|
+
guard(exec) {
|
|
45
|
+
const membership = ctx.agentTeams.tryMembership(exec.agent);
|
|
46
|
+
if (!ownsCapabilityTeam(ctx, exec.agent) || membership?.role !== 'teammate') return undefined;
|
|
47
|
+
let resolved;
|
|
48
|
+
try { resolved = roleOf(exec.agent, membership); } catch { return undefined; } // Routing and the access guard report config errors.
|
|
49
|
+
return planDenial(exec, resolved.role, plans.get(keyOf(membership)), resolved.effective.workflow);
|
|
50
|
+
},
|
|
51
|
+
register() {
|
|
52
|
+
ctx.effect(() => ctx.tools.guard(exec => this.guard(exec)));
|
|
53
|
+
ctx.effect(() => ctx.tools.register({ name: SUBMIT_TOOL, output,
|
|
54
|
+
description: modelText('plan-gate.334'),
|
|
55
|
+
parameters: params({ plan: { type: 'string', description: modelText('plan-gate.335') } }, ['plan']),
|
|
56
|
+
async execute(args, exec) {
|
|
57
|
+
const membership = ctx.agentTeams.membership(exec.agent);
|
|
58
|
+
if (membership.role !== 'teammate') throw new Error(modelText('plan-gate.336'));
|
|
59
|
+
const { role } = roleOf(exec.agent, membership);
|
|
60
|
+
if (!role.planFirst) throw new Error(modelText('plan-gate.337', { 0: role.displayName }));
|
|
61
|
+
const plan = text(args.plan, 8000, modelText('plan-gate.338'));
|
|
62
|
+
plans.set(keyOf(membership), { status: 'pending', plan, at: Date.now() });
|
|
63
|
+
await ctx.agentTeams.sendMessage(exec.agent, { target: 'lead', signal: exec.signal, content: [{ type: 'text',
|
|
64
|
+
text: modelText('plan-gate.339', { 0: role.displayName, 1: membership.name, 2: REVIEW_TOOL, 3: membership.name, 4: plan }) }] });
|
|
65
|
+
return modelText('plan-gate.340', { 0: STATUS.pending });
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
ctx.effect(() => ctx.tools.register({ name: REVIEW_TOOL, output,
|
|
69
|
+
description: modelText('plan-gate.341'),
|
|
70
|
+
parameters: params({
|
|
71
|
+
role: { type: 'string', description: modelText('plan-gate.342') },
|
|
72
|
+
approve: { type: 'boolean', description: modelText('plan-gate.343') },
|
|
73
|
+
feedback: { type: 'string', description: modelText('plan-gate.344') },
|
|
74
|
+
}, ['role', 'approve']),
|
|
75
|
+
async execute(args, exec) {
|
|
76
|
+
const membership = ctx.agentTeams.membership(exec.agent);
|
|
77
|
+
if (membership.role !== 'lead') throw new Error(modelText('plan-gate.345'));
|
|
78
|
+
const role = text(args.role, 64, modelText('plan-gate.346'));
|
|
79
|
+
const plan = plans.get(keyOf(membership, role));
|
|
80
|
+
if (!plan || plan.status !== 'pending') throw new Error(modelText('plan-gate.347', { 0: role }));
|
|
81
|
+
if (args.approve !== true && args.approve !== false) throw new Error(modelText('plan-gate.348'));
|
|
82
|
+
const feedback = args.feedback === undefined || args.feedback === '' ? undefined : text(args.feedback, 2000, modelText('plan-gate.349'));
|
|
83
|
+
if (!args.approve && !feedback) throw new Error(modelText('plan-gate.350'));
|
|
84
|
+
plans.set(keyOf(membership, role), { ...plan, status: args.approve ? 'approved' : 'rejected', feedback, at: Date.now() });
|
|
85
|
+
await ctx.agentTeams.sendMessage(exec.agent, { target: role, signal: exec.signal, content: [{ type: 'text',
|
|
86
|
+
text: args.approve ? modelText('plan-gate.351', { 0: feedback ? modelText('plan-gate.352', { 0: feedback }) : '' }) : modelText('plan-gate.353', { 0: SUBMIT_TOOL, 1: feedback }) }] });
|
|
87
|
+
return modelText('plan-gate.354', { 0: args.approve ? modelText('plan-gate.355') : modelText('plan-gate.356'), 1: role });
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/preset-guard.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Fail closed: native team membership alone does not identify this preset.
|
|
2
|
+
export const CAPABILITY_PRESET = 'capability-team';
|
|
3
|
+
// A service missing from the caller's `inject` throws on direct property access in cordis; ctx.get() does not.
|
|
4
|
+
const service = (ctx, name) => { try { const direct = ctx[name]; if (direct) return direct; } catch { /* not injected */ } try { return ctx.get?.(name); } catch { return undefined; } };
|
|
5
|
+
export function ownsCapabilityTeam(ctx, agent) {
|
|
6
|
+
if (!agent) return false;
|
|
7
|
+
try {
|
|
8
|
+
const membership = service(ctx, 'agentTeams')?.tryMembership(agent);
|
|
9
|
+
const owner = membership?.root ?? agent;
|
|
10
|
+
const presets = service(ctx, 'agentPresets');
|
|
11
|
+
return presets?.composedPreset(owner.ctx) === CAPABILITY_PRESET;
|
|
12
|
+
} catch { return false; }
|
|
13
|
+
}
|
|
14
|
+
export function capabilitySession(ctx, session) {
|
|
15
|
+
try { return ownsCapabilityTeam(ctx, ctx.agents?.get(session?.id)); }
|
|
16
|
+
catch { return false; }
|
|
17
|
+
}
|
|
18
|
+
export function isolatedProjection(ctx, definition) {
|
|
19
|
+
const owns = header => {
|
|
20
|
+
// DSH's own SessionHeader.agentPreset decides first. The live Agent is only a fallback for headers without it:
|
|
21
|
+
// a projection can initialise before the Agent's preset is composed, and that early "not ours" used to stick
|
|
22
|
+
// for the whole session, leaving usage, activity and report empty for real capability-team sessions.
|
|
23
|
+
const recorded = header?.agentPreset ?? header?.preset;
|
|
24
|
+
if (recorded !== undefined) return recorded === CAPABILITY_PRESET;
|
|
25
|
+
try {
|
|
26
|
+
const agent = ctx.agents?.get(header?.id ?? header?.sessionId);
|
|
27
|
+
return agent ? ownsCapabilityTeam(ctx, agent) : false;
|
|
28
|
+
} catch { return false; }
|
|
29
|
+
};
|
|
30
|
+
// +2: checkpoints written by the earlier decision are discarded and refolded from the session log.
|
|
31
|
+
return { ...definition, stateVersion: definition.stateVersion + 2,
|
|
32
|
+
init(header, inherited) {
|
|
33
|
+
const state = definition.init(header, inherited);
|
|
34
|
+
return { ...state, capabilityPreset: owns(header) };
|
|
35
|
+
},
|
|
36
|
+
apply(state, event) {
|
|
37
|
+
// A blank session may switch preset before its first turn: re-decide from the committed selection.
|
|
38
|
+
if (event?.type === 'agent-preset/selected') {
|
|
39
|
+
const owned = event.data?.agentPreset === CAPABILITY_PRESET;
|
|
40
|
+
if (owned !== state.capabilityPreset) return { ...state, capabilityPreset: owned };
|
|
41
|
+
}
|
|
42
|
+
if (!state.capabilityPreset) return state;
|
|
43
|
+
return { ...definition.apply(state, event), capabilityPreset: true };
|
|
44
|
+
},
|
|
45
|
+
wire: { ...definition.wire, view: state => definition.wire.view(state.capabilityPreset ? state : definition.init()) },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { m as modelText } from './i18n.js';
|
|
2
|
+
// Extract only recorded model identities, never prompt/content/replayState.
|
|
3
|
+
// Request headers prove configuration admission, not successful provider completion.
|
|
4
|
+
import { foldUsage } from './usage.js';
|
|
5
|
+
export function requestEvidence(snapshot, limit = 30) {
|
|
6
|
+
const inherited = snapshot.inheritedEventCount ?? 0;
|
|
7
|
+
const result = [];
|
|
8
|
+
for (let i = snapshot.events.length - 1; i >= inherited && result.length < limit; i--) {
|
|
9
|
+
const event = snapshot.events[i];
|
|
10
|
+
let source, evidence;
|
|
11
|
+
if (event.type === 'request/header') { source = event.data?.header?.config; evidence = 'request-header'; }
|
|
12
|
+
else if (event.type === 'assistant/message') { source = event.data?.message?.source; evidence = 'assistant-message'; }
|
|
13
|
+
if (typeof source?.provider !== 'string' || typeof source?.model !== 'string') continue;
|
|
14
|
+
result.push({ seq: event.seq, time: event.time, provider: source.provider, model: source.model, evidence,
|
|
15
|
+
...(event.data?.interrupted ? { interrupted: true } : {}) });
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
export async function readTeamEvidence(query, teams, agent) {
|
|
20
|
+
let members;
|
|
21
|
+
try { members = teams ? teams.listMembers(agent) : [{ id: agent.id, name: 'lead' }]; }
|
|
22
|
+
catch (error) {
|
|
23
|
+
return [{ sessionId: agent.id, name: 'lead', ...await readRequestEvidence(query, agent.id),
|
|
24
|
+
unavailable: modelText('request-evidence.357', { 0: error.message ?? error }) }];
|
|
25
|
+
}
|
|
26
|
+
const result = [];
|
|
27
|
+
// Deliberately sequential: bound peak log memory to one member at a time.
|
|
28
|
+
for (const member of members) {
|
|
29
|
+
result.push({ sessionId: member.id, name: member.name, inheritedModel: member.model ?? null,
|
|
30
|
+
...await readRequestEvidence(query, member.id) });
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
export async function readRequestEvidence(query, sessionId) {
|
|
35
|
+
if (!query) return { records: [], unavailable: modelText('request-evidence.358') };
|
|
36
|
+
try {
|
|
37
|
+
const snapshot = await query.readSession(sessionId);
|
|
38
|
+
return { records: requestEvidence(snapshot), usage: foldUsage(snapshot) };
|
|
39
|
+
}
|
|
40
|
+
catch (error) { return { records: [], unavailable: String(error.message ?? error) }; }
|
|
41
|
+
}
|
package/roles.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { m as modelText, roleText } from './i18n.js';
|
|
2
|
+
import { allocatedRequest } from './allocation.js';
|
|
3
|
+
import { followUserChoice, leadRequest } from './lead-route.js';
|
|
4
|
+
import { budgetDenial, sessionUsage } from './usage.js';
|
|
5
|
+
import { createLongContext, promptTokens } from './long-context.js';
|
|
6
|
+
import { ensurePreset } from './access.js';
|
|
7
|
+
import { bindRuntime } from './runtime-observability.js';
|
|
8
|
+
import { FILE_LIMITS, fileLimitRules } from './file-limits.js';
|
|
9
|
+
import { TOKEN_RULES, tokenRuleText } from './token-discipline.js';
|
|
10
|
+
// `allowed` is the free list resolved from OpenCode's catalog (opencode.js); nothing is fixed here.
|
|
11
|
+
export function freeArgs(prompt, model, allowed = []) {
|
|
12
|
+
if (typeof model !== 'string' || !allowed.includes(model)) throw new Error(modelText('roles.359'));
|
|
13
|
+
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 16000) throw new Error(modelText('roles.360'));
|
|
14
|
+
return ['run', '--format', 'json', '--model', model, '--', prompt];
|
|
15
|
+
}
|
|
16
|
+
// Static rules hold no role names, models or tiers: those come from the per-Agent team context.
|
|
17
|
+
export const policy = roleText('roles.361', { 0: fileLimitRules(FILE_LIMITS), 1: tokenRuleText(TOKEN_RULES) });
|
|
18
|
+
/**
|
|
19
|
+
* Route every model request by the native Team identity.
|
|
20
|
+
* The loop re-runs agent/request for each retry, so the failover state picks the attempt's route.
|
|
21
|
+
*/
|
|
22
|
+
export function apply(ctx, team, failover, observability, taskControl) {
|
|
23
|
+
const installed = new WeakSet();
|
|
24
|
+
const installPolicy = agent => {
|
|
25
|
+
if (!team.owns(agent) || installed.has(agent)) return;
|
|
26
|
+
installed.add(agent);
|
|
27
|
+
agent.ctx.systemPrompt.context({ name: 'capability-team:policy', order: 95, text: () => team.owns(agent) ? policy : '' });
|
|
28
|
+
};
|
|
29
|
+
ctx.on('agent/created', ({ agent }) => installPolicy(agent));
|
|
30
|
+
for (const agent of ctx.agents?.list?.() ?? []) installPolicy(agent);
|
|
31
|
+
const longContext = createLongContext();
|
|
32
|
+
const hadOverride = new WeakSet();
|
|
33
|
+
ctx.on('agent/request', async ({ agent, turn, step }, next) => {
|
|
34
|
+
const previous = await next();
|
|
35
|
+
if (!team.owns(agent)) return previous;
|
|
36
|
+
const resolved = await team.resolve(agent);
|
|
37
|
+
const lead = ctx.agentTeams?.tryMembership?.(agent)?.role === 'lead';
|
|
38
|
+
const { leadContext, ...configuredRole } = resolved;
|
|
39
|
+
// The Lead uses the model the user picked in the conversation; teammates are routed strictly by role.
|
|
40
|
+
const role = lead ? followUserChoice(configuredRole, previous, leadContext) : configuredRole;
|
|
41
|
+
if (role.userChoiceRejected) ctx.logger?.warn?.(modelText('roles.362', { 0: role.userChoiceRejected }));
|
|
42
|
+
// Fail closed: silently inheriting the Lead model defeats capability tiers and hides misconfiguration.
|
|
43
|
+
if (role.unassigned) throw new Error(modelText('roles.363', { 0: role.displayName ?? role.id, 1: role.capability }));
|
|
44
|
+
// DSH's own sandbox preset for read-only roles; re-checked every request so config edits take effect.
|
|
45
|
+
ensurePreset(ctx.get?.('permissionPresets'), agent.session, role.access, message => ctx.logger?.warn?.(`capability-team: ${message}`));
|
|
46
|
+
const projections = ctx.get?.('sessionProjections');
|
|
47
|
+
const overBudget = budgetDenial(role, sessionUsage(projections, agent.session));
|
|
48
|
+
if (overBudget) throw new Error(overBudget);
|
|
49
|
+
const controlled = taskControl ? await taskControl.route(agent, role) : role;
|
|
50
|
+
if (hadOverride.has(agent) && !controlled.taskLeaseSignature) { longContext.reset(agent.session); hadOverride.delete(agent); }
|
|
51
|
+
if (controlled.taskLeaseSignature) hadOverride.add(agent);
|
|
52
|
+
const routed = controlled.taskLeaseSignature ? controlled : longContext.apply(agent.session, controlled, promptTokens(projections, agent.session));
|
|
53
|
+
// Always explicit: the loop pins the last logged header, so the primary must be restored by name.
|
|
54
|
+
const selected = failover.route(agent, turn, step, routed);
|
|
55
|
+
if (observability) {
|
|
56
|
+
try {
|
|
57
|
+
bindRuntime(agent, observability, ctx.agentTeams);
|
|
58
|
+
observability.request(agent, { turn, step, role, routed, selected });
|
|
59
|
+
} catch { /* telemetry must never change model selection */ }
|
|
60
|
+
}
|
|
61
|
+
return (lead && leadRequest(previous, selected)) || allocatedRequest(previous, selected);
|
|
62
|
+
});
|
|
63
|
+
ctx.on('agent/request-error', async ({ agent, failure, signal }, next) => {
|
|
64
|
+
const downstream = await next(); // DSH provider retry policy keeps priority.
|
|
65
|
+
if (team.owns(agent)) { try { observability?.failure(agent, failure); } catch { /* read-only telemetry */ } }
|
|
66
|
+
if (downstream?.kind === 'retry' || !team.owns(agent)) return downstream;
|
|
67
|
+
return (await failover.recover(agent, failure, signal)) ? { kind: 'retry' } : undefined;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { roleText, m as modelText } from './i18n.js';
|
|
2
|
+
// Built-in role rosters. The neutral roster is the default; "three-kingdoms" is an optional skin applied
|
|
3
|
+
// per project through capability_team_import { theme }. Projects saved before 4.0 keep their hero roster.
|
|
4
|
+
const isMap = v => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const frozen = roles => Object.freeze(Object.fromEntries(Object.entries(roles).map(([id, r]) => [id, Object.freeze(r)])));
|
|
6
|
+
/** Neutral default roster (English display names; the Web client localizes untouched defaults). */
|
|
7
|
+
export const DEFAULT_ROLES = frozen({
|
|
8
|
+
lead: { displayName: 'Lead', capability: 'orchestrator' },
|
|
9
|
+
architect: { displayName: 'Architect', capability: 'technical-director' },
|
|
10
|
+
backend: { displayName: 'Backend', capability: 'backend' },
|
|
11
|
+
frontend: { displayName: 'Frontend', capability: 'frontend' },
|
|
12
|
+
researcher: { displayName: 'Researcher', capability: 'research' },
|
|
13
|
+
writer: { displayName: 'Writer', capability: 'writer' },
|
|
14
|
+
qa: { displayName: 'QA', capability: 'qa' },
|
|
15
|
+
});
|
|
16
|
+
/** The pre-4.0 default roster, kept as the optional "three-kingdoms" theme. */
|
|
17
|
+
export const THREE_KINGDOMS_ROLES = frozen({
|
|
18
|
+
lead: { displayName: roleText('roster-defaults.364'), capability: 'orchestrator' },
|
|
19
|
+
'zhuge-liang': { displayName: roleText('roster-defaults.365'), capability: 'technical-director' },
|
|
20
|
+
'lv-bu': { displayName: roleText('roster-defaults.366'), capability: 'backend' },
|
|
21
|
+
'diao-chan': { displayName: roleText('roster-defaults.367'), capability: 'frontend' },
|
|
22
|
+
'zhao-yun': { displayName: roleText('roster-defaults.368'), capability: 'research' },
|
|
23
|
+
'chen-lin': { displayName: roleText('roster-defaults.369'), capability: 'writer' },
|
|
24
|
+
'zhou-yu': { displayName: roleText('roster-defaults.370'), capability: 'qa' },
|
|
25
|
+
});
|
|
26
|
+
export const ROLE_THEMES = Object.freeze({ default: DEFAULT_ROLES, 'three-kingdoms': THREE_KINGDOMS_ROLES });
|
|
27
|
+
/** Hero id → neutral id with the same capability; lets members spawned under the old roster keep routing. */
|
|
28
|
+
export const LEGACY_ROLE_ALIASES = Object.freeze({ 'zhuge-liang': 'architect', 'lv-bu': 'backend', 'diao-chan': 'frontend', 'zhao-yun': 'researcher', 'chen-lin': 'writer', 'zhou-yu': 'qa' });
|
|
29
|
+
const sameRole = (a, b) => isMap(a) && isMap(b) && a.displayName === b.displayName && a.capability === b.capability && a.description === b.description;
|
|
30
|
+
/** Stored-override marker written by 4.0+: such files are never read against the old hero default. */
|
|
31
|
+
export const ROSTER_MARK = 2;
|
|
32
|
+
/**
|
|
33
|
+
* True for overrides saved before 4.0 against the hero default: no marker (every 4.0+ save writes one), and a hero id
|
|
34
|
+
* the base lacks is stored, whether as a partial rename, a deletion (null) or a complete role from a 3.x template.
|
|
35
|
+
* Such a file was written on top of the hero roster, so mixing it into the neutral default would duplicate roles.
|
|
36
|
+
*/
|
|
37
|
+
export function isLegacyRoster(baseRoles = {}, overrides = {}) {
|
|
38
|
+
if (overrides.roster === ROSTER_MARK) return false;
|
|
39
|
+
return Object.keys(overrides.roles ?? {}).some(id => Object.hasOwn(LEGACY_ROLE_ALIASES, id) && !Object.hasOwn(baseRoles, id));
|
|
40
|
+
}
|
|
41
|
+
/** Base roles for one project's stored overrides: a pre-4.0 save keeps the hero roster (neutral defaults it never saw are dropped). */
|
|
42
|
+
export function rosterBase(baseRoles = {}, overrides = {}) {
|
|
43
|
+
if (!isLegacyRoster(baseRoles, overrides)) return baseRoles;
|
|
44
|
+
const kept = Object.entries(baseRoles).filter(([id, r]) => !(Object.hasOwn(DEFAULT_ROLES, id) && sameRole(r, DEFAULT_ROLES[id])));
|
|
45
|
+
return { ...Object.fromEntries(kept), ...THREE_KINGDOMS_ROLES };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Overrides to store after a save: marked as 4.0+. A legacy project's roster is written out explicitly on its first
|
|
49
|
+
* save (base-only roles stored as null), so later loads no longer depend on guessing.
|
|
50
|
+
*/
|
|
51
|
+
export function pinRoster(next, previous, baseRoles = {}) {
|
|
52
|
+
if (!isLegacyRoster(baseRoles, previous)) return { ...next, roster: ROSTER_MARK };
|
|
53
|
+
const roles = Object.fromEntries(Object.keys(baseRoles).map(id => [id, null]));
|
|
54
|
+
for (const [id, r] of Object.entries(rosterBase(baseRoles, previous))) roles[id] = { ...r };
|
|
55
|
+
for (const [id, r] of Object.entries(next.roles ?? {})) roles[id] = r === null ? null : isMap(r) && isMap(roles[id]) ? { ...roles[id], ...r } : r;
|
|
56
|
+
return { ...next, roles, roster: ROSTER_MARK };
|
|
57
|
+
}
|
|
58
|
+
/** Role id a member name resolves to when the roster lacks it: a legacy hero name maps to its neutral role. */
|
|
59
|
+
export function legacyRoleId(name, roles) {
|
|
60
|
+
const alias = LEGACY_ROLE_ALIASES[name];
|
|
61
|
+
return alias && !Object.hasOwn(roles, name) && Object.hasOwn(roles, alias) ? alias : undefined;
|
|
62
|
+
}
|
|
63
|
+
/** Save patch that replaces the project's roster with a built-in theme; other roles are removed, per-role descriptions kept. */
|
|
64
|
+
export function themePatch(theme, current) {
|
|
65
|
+
const roles = Object.hasOwn(ROLE_THEMES, theme) ? ROLE_THEMES[theme] : undefined;
|
|
66
|
+
if (!roles) throw new Error(modelText('roster-defaults.371', { 0: theme, 1: Object.keys(ROLE_THEMES).join(' / ') }));
|
|
67
|
+
const patch = Object.fromEntries(Object.entries(roles).map(([id, r]) => [id, { ...r }]));
|
|
68
|
+
for (const id of Object.keys(current.roles ?? {})) if (!Object.hasOwn(patch, id)) patch[id] = false;
|
|
69
|
+
return { roles: patch };
|
|
70
|
+
}
|
package/route-health.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Route failures that outlive a DSH restart. The failover layer keeps cooldowns in memory, so after a restart
|
|
2
|
+
// a route with a dead key looked healthy again and could even be preselected as a habit. Failures are kept
|
|
3
|
+
// here: credential errors (the key is wrong until someone fixes it) for a day, other failures for an hour.
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
export const CREDENTIAL_CODES = new Set(['AUTH', 'INVALID_CREDENTIAL', 'MISSING_CREDENTIAL']);
|
|
7
|
+
export const KEEP_MS = { credential: 24 * 3600_000, other: 3600_000 };
|
|
8
|
+
/** Whether a failure means the route's key is wrong or missing (it stays bad until someone fixes it). */
|
|
9
|
+
export const isCredential = (code, status) => CREDENTIAL_CODES.has(code) || status === 401 || status === 403;
|
|
10
|
+
export function createRouteHealth(root, { now = Date.now } = {}) {
|
|
11
|
+
const path = join(root, 'route-health.json');
|
|
12
|
+
let data;
|
|
13
|
+
const load = () => {
|
|
14
|
+
if (data) return data;
|
|
15
|
+
try { data = JSON.parse(readFileSync(path, 'utf8')); } catch { data = {}; }
|
|
16
|
+
return data;
|
|
17
|
+
};
|
|
18
|
+
const save = () => {
|
|
19
|
+
try { mkdirSync(root, { recursive: true }); const temp = `${path}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(data)); renameSync(temp, path); } catch { /* best effort */ }
|
|
20
|
+
};
|
|
21
|
+
const alive = (entry, at) => at - entry.lastFailureAt < (entry.credential ? KEEP_MS.credential : KEEP_MS.other);
|
|
22
|
+
return {
|
|
23
|
+
/** Failover telemetry: remember each cooldown with its error class. */
|
|
24
|
+
record(event) {
|
|
25
|
+
if (event?.kind !== 'cooldown' || !event.route) return;
|
|
26
|
+
const key = `${event.route.provider}/${event.route.model}`;
|
|
27
|
+
load()[key] = { strikes: event.strikes ?? 1, lastFailureAt: now(), code: event.code ?? null, credential: isCredential(event.code, event.status) };
|
|
28
|
+
for (const [k, v] of Object.entries(data)) if (!alive(v, now())) delete data[k];
|
|
29
|
+
save();
|
|
30
|
+
},
|
|
31
|
+
/** Recent failures, in the same shape as failover.health(). */
|
|
32
|
+
recent() {
|
|
33
|
+
const at = now();
|
|
34
|
+
return Object.fromEntries(Object.entries(load()).filter(([, v]) => alive(v, at)).map(([k, v]) => [k, { strikes: v.strikes, lastFailureAt: v.lastFailureAt, cooling: false, credential: v.credential }]));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Persisted failures merged with the live failover state (live state wins). */
|
|
39
|
+
export const mergeHealth = (persisted, live) => ({ ...persisted, ...live });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Ephemeral routing telemetry: no prompt/error text and no persisted session events.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { safeId, safeTime } from './activity-summary.js';
|
|
4
|
+
import { taskControlSnapshot } from './member-task-control.js';
|
|
5
|
+
const routeOf = r => r && typeof r.provider === 'string' && r.provider.length <= 128 && typeof r.model === 'string' && r.model.length <= 512 ? { provider: r.provider, model: r.model } : null;
|
|
6
|
+
const same = (a, b) => a && b && a.provider === b.provider && a.model === b.model;
|
|
7
|
+
const codeOf = c => safeId(c, 80);
|
|
8
|
+
const statusOf = s => Number.isInteger(s) && s >= 100 && s <= 599 ? s : null;
|
|
9
|
+
export function createRuntimeObservability({ now = Date.now, epoch = randomUUID() } = {}) {
|
|
10
|
+
const since = safeTime(now()) ?? 0;
|
|
11
|
+
const members = new Map(), listeners = new Set(), sharedCooling = new Map(); let revision = 0;
|
|
12
|
+
const changed = () => { revision += 1; for (const listener of listeners) { try { listener(); } catch { /* observation must not affect routing */ } } };
|
|
13
|
+
const get = agent => {
|
|
14
|
+
const id = safeId(agent?.id); if (!id) return null;
|
|
15
|
+
if (!members.has(id)) {
|
|
16
|
+
if (members.size >= 256) members.delete(members.keys().next().value);
|
|
17
|
+
members.set(id, { sessionId: id, candidates: [], cooling: [], switches: [], counters: { requestFailures: 0, pluginRetries: 0, failoverSwitches: 0, longContextSwitches: 0 }, attempt: 0, turn: null, step: null, active: null, lc: false, truncated: false });
|
|
18
|
+
}
|
|
19
|
+
return members.get(id);
|
|
20
|
+
};
|
|
21
|
+
const record = (m, from, to, reason, detail = {}) => {
|
|
22
|
+
if (!from || !to || same(from, to)) return;
|
|
23
|
+
const at = safeTime(now()) ?? 0;
|
|
24
|
+
m.switches.unshift({ id: `${epoch}:${revision + 1}:${m.sessionId}`, at, turn: m.turn, step: m.step, attempt: m.attempt, from, to, reason, code: codeOf(detail.code), status: statusOf(detail.status) });
|
|
25
|
+
if (m.switches.length > 16) { m.switches.length = 16; m.truncated = true; }
|
|
26
|
+
if (reason === 'failover' || reason === 'long-context') m.counters[reason === 'failover' ? 'failoverSwitches' : 'longContextSwitches'] += 1;
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
epoch,
|
|
30
|
+
notifyTaskControl() { changed(); },
|
|
31
|
+
onChanged(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
32
|
+
request(agent, { turn, step, role, routed, selected }) {
|
|
33
|
+
const m = get(agent); if (!m) return;
|
|
34
|
+
const newStep = m.turn !== turn || m.step !== step;
|
|
35
|
+
m.attempt = !newStep ? m.attempt + 1 : 1;
|
|
36
|
+
m.turn = Number.isSafeInteger(turn) ? turn : null; m.step = Number.isSafeInteger(step) ? step : null;
|
|
37
|
+
m.candidates = [role, ...(role?.fallbacks ?? []), ...(role?.longContext ? [role.longContext] : [])].map(routeOf).filter(Boolean).slice(0, 32);
|
|
38
|
+
const active = routeOf(selected), lc = !!routed.longContextActive;
|
|
39
|
+
if (m.active && m.override !== (routed.taskLeaseSignature ?? null)) record(m, m.active, active, routed.taskLeaseSignature ? 'override' : 'override-expired');
|
|
40
|
+
m.override = routed.taskLeaseSignature ?? null;
|
|
41
|
+
if (m.active && !routed.taskLeaseSignature && lc !== m.lc) record(m, m.active, routeOf(routed), 'long-context');
|
|
42
|
+
const primary = routeOf(routed);
|
|
43
|
+
if (primary && active && !same(primary, active)) {
|
|
44
|
+
// recover already recorded a switch in this attempt; a new step skipping a cooled route has not.
|
|
45
|
+
if (m.active && newStep && (!m.pendingSwitch || !same(m.pendingSwitch, active))) record(m, primary, active, 'failover', [...sharedCooling.values()].find(c => same(c.route, primary)));
|
|
46
|
+
}
|
|
47
|
+
m.pendingSwitch = null; m.active = active; m.lc = lc; changed();
|
|
48
|
+
},
|
|
49
|
+
failure(agent, failure) { const m = get(agent); if (!m) return; m.counters.requestFailures += 1; changed(); },
|
|
50
|
+
telemetry(event) {
|
|
51
|
+
const m = get(event.agent); if (!m) return;
|
|
52
|
+
if (event.kind === 'retry') m.counters.pluginRetries += 1;
|
|
53
|
+
if (event.kind === 'cooldown') {
|
|
54
|
+
const route = routeOf(event.route); if (!route) return;
|
|
55
|
+
const cooldown = { route, code: codeOf(event.code), status: statusOf(event.status), until: safeTime(event.until), attempt: m.attempt };
|
|
56
|
+
// Shared cooldown is filtered by each member's confirmed candidates at snapshot time.
|
|
57
|
+
sharedCooling.set(JSON.stringify(route), cooldown);
|
|
58
|
+
if (sharedCooling.size > 256) sharedCooling.delete(sharedCooling.keys().next().value);
|
|
59
|
+
}
|
|
60
|
+
if (event.kind === 'switch') { const to = routeOf(event.to); record(m, routeOf(event.from), to, 'failover', event); m.pendingSwitch = to; }
|
|
61
|
+
changed();
|
|
62
|
+
},
|
|
63
|
+
snapshot(leadSessionId, roster) {
|
|
64
|
+
const result = [];
|
|
65
|
+
for (const member of roster.slice(0, 64)) {
|
|
66
|
+
const m = members.get(member.id);
|
|
67
|
+
const candidates = m?.candidates ?? [];
|
|
68
|
+
const cooling = [...sharedCooling.values()].filter(c => c.until > now() && candidates.some(r => same(c.route, r)));
|
|
69
|
+
result.push({ sessionId: member.id, cooling: cooling.slice(0, 16).map(c => ({ ...c, route: { ...c.route } })), switches: (m?.switches ?? []).map(s => ({ ...s, from: { ...s.from }, to: { ...s.to } })), counters: { ...(m?.counters ?? { requestFailures: 0, pluginRetries: 0, failoverSwitches: 0, longContextSwitches: 0 }) }, truncated: !!m?.truncated || cooling.length > 16 });
|
|
70
|
+
}
|
|
71
|
+
return { schemaVersion: 1, leadSessionId, epoch, revision, generatedAt: safeTime(now()) ?? 0, coverage: { mode: 'runtime', since }, members: result, taskControl: taskControlSnapshot(leadSessionId) };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Kept outside session checkpoints. Bound to the exact live Agent identity.
|
|
76
|
+
const runtimeBindings = new WeakMap();
|
|
77
|
+
export function bindRuntime(agent, runtime, teams) { runtimeBindings.set(agent, { runtime, teams }); }
|
|
78
|
+
export function runtimeBinding(agent) { return runtimeBindings.get(agent); }
|
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
|
+
}
|