@aragon-agent/cli 0.6.39 → 0.6.40
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 +2589 -2523
- package/README.md +3706 -3614
- package/dist/agent/controller.d.ts +103 -9
- package/dist/agent/controller.js +196 -19
- package/dist/agent/model-profile-settings.js +12 -1
- package/dist/agent/plan-prompt.d.ts +1 -3
- package/dist/agent/plan-prompt.js +13 -7
- package/dist/agent/reducer.d.ts +29 -1
- package/dist/agent/reducer.js +34 -6
- package/dist/agent/skill-triage-prompt.d.ts +42 -0
- package/dist/agent/skill-triage-prompt.js +74 -0
- package/dist/agent/system-prompt.d.ts +14 -2
- package/dist/agent/system-prompt.js +7 -4
- package/dist/cli.js +21 -5
- package/dist/commands/builtins.js +12 -3
- package/dist/commands/skills.js +86 -1
- package/dist/commands/subagent-command.d.ts +25 -0
- package/dist/commands/subagent-command.js +242 -0
- package/dist/compaction/command.js +16 -3
- package/dist/compaction/compactor.d.ts +25 -0
- package/dist/compaction/compactor.js +111 -10
- package/dist/compaction/digest.d.ts +24 -1
- package/dist/compaction/digest.js +139 -17
- package/dist/compaction/limits.d.ts +21 -0
- package/dist/compaction/limits.js +21 -0
- package/dist/compaction/reason-text.d.ts +36 -0
- package/dist/compaction/reason-text.js +78 -0
- package/dist/compaction/types.d.ts +82 -14
- package/dist/compaction/wiring.d.ts +13 -8
- package/dist/compaction/wiring.js +1 -0
- package/dist/config/load.js +50 -3
- package/dist/config/schema.d.ts +82 -6
- package/dist/config/schema.js +107 -9
- package/dist/config/store.js +7 -2
- package/dist/proc/log-mirror.d.ts +21 -4
- package/dist/proc/log-mirror.js +29 -7
- package/dist/skills/service.d.ts +41 -0
- package/dist/skills/service.js +62 -0
- package/dist/skills/triage.d.ts +194 -0
- package/dist/skills/triage.js +393 -0
- package/dist/team/forced-dispatch.d.ts +95 -0
- package/dist/team/forced-dispatch.js +208 -0
- package/dist/team/limits.d.ts +21 -1
- package/dist/team/limits.js +21 -1
- package/dist/team/normalize.d.ts +15 -0
- package/dist/team/normalize.js +21 -4
- package/dist/team/prompt.d.ts +6 -3
- package/dist/team/prompt.js +9 -5
- package/dist/team/task-tool.js +11 -7
- package/dist/tools/human-input.d.ts +35 -2
- package/dist/tools/human-input.js +38 -0
- package/dist/tools/plan-tools.d.ts +20 -12
- package/dist/tools/plan-tools.js +24 -17
- package/dist/ui/App.js +14 -1
- package/dist/ui/StatusBar.d.ts +1 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/entries/CompactionCard.d.ts +11 -1
- package/dist/ui/entries/CompactionCard.js +65 -4
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +18 -1
- package/dist/ui/overlays/SettingsScreen.js +29 -5
- package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
- package/dist/ui/overlays/TeamFanoutPage.js +78 -0
- package/dist/ui/overlays/model-profile-state.d.ts +19 -1
- package/dist/ui/overlays/model-profile-state.js +12 -0
- package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
- package/dist/ui/overlays/team-fanout-state.js +97 -0
- package/package.json +2 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic skill routing for the pre-work triage step
|
|
3
|
+
* (skill-triage-and-unbounded-ask §4.2.2).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT AN LLM CALL (D-11). The question "which installed skill, if any,
|
|
6
|
+
* covers this task?" is answered against data the process already holds: the
|
|
7
|
+
* `SkillRecord` set in `SkillRegistry`, with a name, a one-line description and a
|
|
8
|
+
* keyword list. A model call would add a round trip, latency and — the decisive
|
|
9
|
+
* point — untestability, because a router that is itself a model can only be
|
|
10
|
+
* asserted on statistically. This module is a pure function of its inputs, so the
|
|
11
|
+
* whole feature has unit tests instead of anecdotes.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT IS ALLOWED TO TOUCH. Records only. No filesystem, no `SKILL.md` read,
|
|
14
|
+
* no network, no clock (invariant I-T2, asserted by a source-scanning test). That
|
|
15
|
+
* is what makes the disclosure ladder real: "which skills exist" costs zero
|
|
16
|
+
* context bytes and zero IO, and the expensive Level 2 body is paid for only when
|
|
17
|
+
* the model actually calls `skill(name)`.
|
|
18
|
+
*
|
|
19
|
+
* BOTH SCRIPTS ARE HANDLED. The user writes tasks in Chinese and English, and
|
|
20
|
+
* skill descriptions are usually English. Space-separated tokens alone score zero
|
|
21
|
+
* on CJK text, so a run of Han/Kana/Hangul characters is tokenized into bigrams
|
|
22
|
+
* (plus the whole run when it is short enough for a phrase match to be likely).
|
|
23
|
+
*
|
|
24
|
+
* ASCII ONLY IN LITERALS: `skills/` is outside the glyph scanner's scope today,
|
|
25
|
+
* but new text keeps the same rule rather than assuming a scanner will never
|
|
26
|
+
* cover it.
|
|
27
|
+
*/
|
|
28
|
+
import type { SkillRecord, SkillScope } from '@aragon-agent/core';
|
|
29
|
+
export interface SkillTriageLimits {
|
|
30
|
+
/** Candidates presented per turn. R-1: more than three is a reading list. */
|
|
31
|
+
maxCandidates: number;
|
|
32
|
+
/** One keyword hit is enough to be worth showing. */
|
|
33
|
+
minScore: number;
|
|
34
|
+
/** The score at which "you were told and did not load it" is worth a notice. */
|
|
35
|
+
confidentScore: number;
|
|
36
|
+
/**
|
|
37
|
+
* Byte ceiling for `<skill_candidates>`. 900, NOT 700: the fixed overhead is
|
|
38
|
+
* about 114 B, so 700 leaves ~586 B for three lines that cost up to 249 B each.
|
|
39
|
+
* A ceiling that silently drops the third candidate on ordinary input is not a
|
|
40
|
+
* ceiling, it is a slow leak — see the arithmetic table in `triage.test.ts`.
|
|
41
|
+
*/
|
|
42
|
+
maxBlockBytes: number;
|
|
43
|
+
/** Byte ceiling for the `<skill_triage>` protocol block, asserted elsewhere. */
|
|
44
|
+
maxProtocolBytes: number;
|
|
45
|
+
/** Task text is truncated to this before tokenizing. */
|
|
46
|
+
maxTaskChars: number;
|
|
47
|
+
/** Tokens kept after tokenizing; bounds the scoring loop on a long task. */
|
|
48
|
+
maxTokens: number;
|
|
49
|
+
/** Latin tokens shorter than this are noise ("a", "of", "to"). */
|
|
50
|
+
latinTokenMinChars: number;
|
|
51
|
+
/** CJK runs shorter than this are noise; a single ideograph means nothing. */
|
|
52
|
+
cjkMinRunChars: number;
|
|
53
|
+
/** Bigrams taken per run. A 2,000-char run cannot be allowed to flood the set. */
|
|
54
|
+
maxCjkGramsPerRun: number;
|
|
55
|
+
/**
|
|
56
|
+
* Longest CJK run that ALSO contributes a whole-run token.
|
|
57
|
+
*
|
|
58
|
+
* Above this the whole-run token is dropped on purpose: a 2,000-character
|
|
59
|
+
* "token" cannot be a substring of any haystack, so it can only occupy one of
|
|
60
|
+
* `maxTokens` slots that a real token needed.
|
|
61
|
+
*/
|
|
62
|
+
maxWholeRunChars: number;
|
|
63
|
+
/** Skill names are unbounded upstream (a kebab directory can be long). */
|
|
64
|
+
maxNameChars: number;
|
|
65
|
+
/** One line of description per candidate. */
|
|
66
|
+
descLineChars: number;
|
|
67
|
+
/** Each match-reason tag. */
|
|
68
|
+
reasonChars: number;
|
|
69
|
+
}
|
|
70
|
+
export declare const SKILL_TRIAGE_LIMITS: SkillTriageLimits;
|
|
71
|
+
export type MatchReason = 'name' | 'keyword' | 'description';
|
|
72
|
+
export interface SkillScore {
|
|
73
|
+
score: number;
|
|
74
|
+
reasons: MatchReason[];
|
|
75
|
+
mentioned: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface SkillCandidate {
|
|
78
|
+
name: string;
|
|
79
|
+
scope: SkillScope;
|
|
80
|
+
/** Already truncated to `descLineChars`; the renderer does not re-cut it. */
|
|
81
|
+
description: string;
|
|
82
|
+
/** 0..44 — see `TRIAGE_WEIGHTS` / `TRIAGE_CAPS`. */
|
|
83
|
+
score: number;
|
|
84
|
+
/** At most three, in the order they were first hit. */
|
|
85
|
+
reasons: MatchReason[];
|
|
86
|
+
}
|
|
87
|
+
export interface SkillRoutingReport {
|
|
88
|
+
/** The task text AFTER truncation to `maxTaskChars`. */
|
|
89
|
+
task: string;
|
|
90
|
+
/** How many tokens the task produced — the explanation for a miss. */
|
|
91
|
+
tokenCount: number;
|
|
92
|
+
/** How many records were eligible to be scored. */
|
|
93
|
+
eligible: number;
|
|
94
|
+
candidates: SkillCandidate[];
|
|
95
|
+
/**
|
|
96
|
+
* The best-scoring skill that did NOT make the list, or `null` when nothing
|
|
97
|
+
* scored at all.
|
|
98
|
+
*
|
|
99
|
+
* It exists for exactly one caller: `/skills route`'s "no match" output. "No
|
|
100
|
+
* skill matched" on its own is a dead end, and the threshold is the one number a
|
|
101
|
+
* user may need to argue with. Naming the runner-up and its score is what turns
|
|
102
|
+
* that dead end into "the threshold is slightly too high" or "nothing is close".
|
|
103
|
+
*/
|
|
104
|
+
topUnqualified: {
|
|
105
|
+
name: string;
|
|
106
|
+
score: number;
|
|
107
|
+
} | null;
|
|
108
|
+
decision: 'none' | 'suggest' | 'confident';
|
|
109
|
+
/**
|
|
110
|
+
* Timestamp, for display and logging only. It never participates in ordering or
|
|
111
|
+
* scoring, which is what keeps this module a pure function (I-T2).
|
|
112
|
+
*/
|
|
113
|
+
at: number;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Task text to a token set, Latin words and CJK bigrams mixed together.
|
|
117
|
+
*
|
|
118
|
+
* DETERMINISTIC BY CONSTRUCTION: no random sampling, no clock, no iteration over a
|
|
119
|
+
* `Set` whose insertion order depends on anything but the input. AC-T3 asserts
|
|
120
|
+
* that two calls on the same input are `deepEqual`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function tokenizeTask(text: string, limits?: SkillTriageLimits): string[];
|
|
123
|
+
/**
|
|
124
|
+
* Score one record against the task (44 is the ceiling: 12 + 5*2 + 4*4 + 2*3).
|
|
125
|
+
*
|
|
126
|
+
* THE THREE HAYSTACKS ARE SEPARATE on purpose. A token found in `keywords` is a
|
|
127
|
+
* deliberate author signal and scores 4; the same token found only in the prose
|
|
128
|
+
* description scores 2. Merging them into one string would make every keyword hit
|
|
129
|
+
* look like a description hit as well, inflating the score on exactly the records
|
|
130
|
+
* whose authors were least specific.
|
|
131
|
+
*
|
|
132
|
+
* `record.frontmatter.keywords`, never `record.keywords`: `SkillRecord` has no
|
|
133
|
+
* top-level keyword field, and both keywords and `activation` hang off
|
|
134
|
+
* `frontmatter`.
|
|
135
|
+
*/
|
|
136
|
+
export declare function scoreSkill(record: SkillRecord, tokens: string[], taskText: string): SkillScore;
|
|
137
|
+
export interface SelectSkillCandidatesInput {
|
|
138
|
+
task: string;
|
|
139
|
+
records: SkillRecord[];
|
|
140
|
+
/** Skills already loaded this turn — never proposed again (I-T4). */
|
|
141
|
+
activeNames: readonly string[];
|
|
142
|
+
limits?: Partial<SkillTriageLimits>;
|
|
143
|
+
/**
|
|
144
|
+
* Reference timestamp for the report's `at` field. Absent means `0`, so the
|
|
145
|
+
* function has no clock of its own (I-T2).
|
|
146
|
+
*/
|
|
147
|
+
now?: number;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The candidate list alone — `buildRoutingReport` minus the report.
|
|
151
|
+
*
|
|
152
|
+
* ONE PIPELINE, TWO VIEWS. This used to repeat the tokenize / rank / filter / slice
|
|
153
|
+
* sequence, which meant the candidate order and the report's `topUnqualified` were
|
|
154
|
+
* produced by two pieces of code that had to be kept in agreement by hand: exactly
|
|
155
|
+
* the drift `rankEligible`'s single comparator exists to prevent. The report owns
|
|
156
|
+
* the arithmetic; this is the projection of it for a caller who does not want
|
|
157
|
+
* `at` / `eligible` / `topUnqualified`.
|
|
158
|
+
*/
|
|
159
|
+
export declare function selectSkillCandidates(input: SelectSkillCandidatesInput): SkillCandidate[];
|
|
160
|
+
/**
|
|
161
|
+
* The router's whole decision, as one pure call — and the ONE place the arithmetic
|
|
162
|
+
* lives (`selectSkillCandidates` is a projection of it).
|
|
163
|
+
*
|
|
164
|
+
* THE FILTER IS THE SAME ONE `catalogRecords` AND `skill_find` USE (I-T4): not
|
|
165
|
+
* disabled, not invalid, not `activation: manual`, and not already active. It is
|
|
166
|
+
* re-implemented here rather than imported because the report needs the
|
|
167
|
+
* candidates' scores, and `catalogRecords` returns records. `triage.test.ts` pins
|
|
168
|
+
* this filter against `catalogRecords` so the two cannot drift apart silently.
|
|
169
|
+
*
|
|
170
|
+
* ORDER IS SCOPE FIRST, then name. That is NOT the order the user sees in
|
|
171
|
+
* `<available_skills>`, where `usageTracking` re-ranks by recent use
|
|
172
|
+
* (`service.ts:catalogBlock`). Intentional: the catalog answers "what have I used
|
|
173
|
+
* lately", the candidate list answers "what should I read first".
|
|
174
|
+
*
|
|
175
|
+
* `at` is the only non-derived field and it is display-only, so a test can pass a
|
|
176
|
+
* fixed value and compare two reports with `deepEqual`.
|
|
177
|
+
*/
|
|
178
|
+
export declare function buildRoutingReport(input: SelectSkillCandidatesInput): SkillRoutingReport;
|
|
179
|
+
/**
|
|
180
|
+
* The `<skill_candidates>` block, or `''` when there is nothing to propose.
|
|
181
|
+
*
|
|
182
|
+
* `''` is load-bearing: it is what keeps `buildSystemPrompt` byte-identical to the
|
|
183
|
+
* pre-feature text when triage finds nothing, so an empty result cannot leave an
|
|
184
|
+
* empty shell behind (I-T1).
|
|
185
|
+
*
|
|
186
|
+
* BUDGET DISCIPLINE, copied from `renderSkillCatalog`: reserve the worst-case
|
|
187
|
+
* overflow line UP FRONT, add lines while they fit, and on overflow drop whole
|
|
188
|
+
* lines rather than truncating text or the closing tag. A dropped candidate is
|
|
189
|
+
* visible as `(+N more not shown)`; a truncated one reads as a corrupt skill name.
|
|
190
|
+
*/
|
|
191
|
+
export declare function renderSkillCandidates(candidates: SkillCandidate[], opts?: {
|
|
192
|
+
maxBytes?: number;
|
|
193
|
+
limits?: Partial<SkillTriageLimits>;
|
|
194
|
+
}): string;
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic skill routing for the pre-work triage step
|
|
3
|
+
* (skill-triage-and-unbounded-ask §4.2.2).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS NOT AN LLM CALL (D-11). The question "which installed skill, if any,
|
|
6
|
+
* covers this task?" is answered against data the process already holds: the
|
|
7
|
+
* `SkillRecord` set in `SkillRegistry`, with a name, a one-line description and a
|
|
8
|
+
* keyword list. A model call would add a round trip, latency and — the decisive
|
|
9
|
+
* point — untestability, because a router that is itself a model can only be
|
|
10
|
+
* asserted on statistically. This module is a pure function of its inputs, so the
|
|
11
|
+
* whole feature has unit tests instead of anecdotes.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT IS ALLOWED TO TOUCH. Records only. No filesystem, no `SKILL.md` read,
|
|
14
|
+
* no network, no clock (invariant I-T2, asserted by a source-scanning test). That
|
|
15
|
+
* is what makes the disclosure ladder real: "which skills exist" costs zero
|
|
16
|
+
* context bytes and zero IO, and the expensive Level 2 body is paid for only when
|
|
17
|
+
* the model actually calls `skill(name)`.
|
|
18
|
+
*
|
|
19
|
+
* BOTH SCRIPTS ARE HANDLED. The user writes tasks in Chinese and English, and
|
|
20
|
+
* skill descriptions are usually English. Space-separated tokens alone score zero
|
|
21
|
+
* on CJK text, so a run of Han/Kana/Hangul characters is tokenized into bigrams
|
|
22
|
+
* (plus the whole run when it is short enough for a phrase match to be likely).
|
|
23
|
+
*
|
|
24
|
+
* ASCII ONLY IN LITERALS: `skills/` is outside the glyph scanner's scope today,
|
|
25
|
+
* but new text keeps the same rule rather than assuming a scanner will never
|
|
26
|
+
* cover it.
|
|
27
|
+
*/
|
|
28
|
+
export const SKILL_TRIAGE_LIMITS = {
|
|
29
|
+
maxCandidates: 3,
|
|
30
|
+
minScore: 4,
|
|
31
|
+
confidentScore: 9,
|
|
32
|
+
maxBlockBytes: 900,
|
|
33
|
+
maxProtocolBytes: 1500,
|
|
34
|
+
maxTaskChars: 2000,
|
|
35
|
+
maxTokens: 64,
|
|
36
|
+
latinTokenMinChars: 3,
|
|
37
|
+
cjkMinRunChars: 2,
|
|
38
|
+
maxCjkGramsPerRun: 24,
|
|
39
|
+
maxWholeRunChars: 32,
|
|
40
|
+
maxNameChars: 40,
|
|
41
|
+
descLineChars: 160,
|
|
42
|
+
reasonChars: 32,
|
|
43
|
+
};
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Scoring constants
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const TRIAGE_WEIGHTS = { mentioned: 12, nameToken: 5, keyword: 4, description: 2 };
|
|
48
|
+
const TRIAGE_CAPS = { nameTokens: 2, keywords: 4, descriptions: 3 };
|
|
49
|
+
/**
|
|
50
|
+
* Scope precedence, HIGHEST first.
|
|
51
|
+
*
|
|
52
|
+
* DELIBERATELY A COPY of `SCOPE_RANK` in core's `disclosure.ts`, which is a
|
|
53
|
+
* module-private constant and not part of the published surface (D-12 forbids
|
|
54
|
+
* changing core to export it). The duplication is allowed; letting it DRIFT is
|
|
55
|
+
* not, which is why `triage.test.ts` pins this ordering against
|
|
56
|
+
* `catalogRecords()` rather than against a hand-written list.
|
|
57
|
+
*/
|
|
58
|
+
const TRIAGE_SCOPE_RANK = {
|
|
59
|
+
env: 0,
|
|
60
|
+
project: 1,
|
|
61
|
+
user: 2,
|
|
62
|
+
bundled: 3,
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Words that carry no routing signal. Kept deliberately short: a stopword list is
|
|
66
|
+
* a blunt instrument, and every entry here is a token that can no longer match a
|
|
67
|
+
* skill description.
|
|
68
|
+
*/
|
|
69
|
+
const STOPWORDS = new Set([
|
|
70
|
+
'the', 'a', 'an', 'and', 'or', 'for', 'with', 'when', 'use', 'using', 'make',
|
|
71
|
+
'please', 'this', 'that', 'from', 'into', 'all', 'can', 'you', 'your', 'our',
|
|
72
|
+
'it', 'is', 'are', 'to', 'of', 'in', 'on',
|
|
73
|
+
]);
|
|
74
|
+
/**
|
|
75
|
+
* Latin words.
|
|
76
|
+
*
|
|
77
|
+
* `-` AND `_` ARE SEPARATORS, NOT WORD CHARACTERS, and that is what makes
|
|
78
|
+
* `pdf-forms` yield `pdf` *and* `forms`. Two reasons, both structural:
|
|
79
|
+
*
|
|
80
|
+
* 1. `normalizeHaystack` folds exactly those two characters to spaces, so a token
|
|
81
|
+
* that still contained one could never be a substring of any haystack — it
|
|
82
|
+
* would be a token that can only ever score zero.
|
|
83
|
+
* 2. A kebab skill name is the most likely phrase a user types verbatim, and
|
|
84
|
+
* AC-T1 requires the parts to be individually searchable. The whole phrase is
|
|
85
|
+
* still recognised, by `mentioned`, which is worth more than a single token.
|
|
86
|
+
*
|
|
87
|
+
* `.`, `+` and `#` stay INSIDE tokens because they survive folding: `package.json`,
|
|
88
|
+
* `c++` and `c#` are what the user typed and what a description says.
|
|
89
|
+
*/
|
|
90
|
+
const LATIN_TOKEN = /[a-z0-9][a-z0-9+#.]*/g;
|
|
91
|
+
const CJK_RUN = /[\u3400-\u4dbf\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+/g;
|
|
92
|
+
/** One encoder for the whole module; `Buffer` is a Node global and this is not. */
|
|
93
|
+
const ENCODER = new TextEncoder();
|
|
94
|
+
function byteLength(text) {
|
|
95
|
+
return ENCODER.encode(text).length;
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Tokenizing
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
/**
|
|
101
|
+
* Task text to a token set, Latin words and CJK bigrams mixed together.
|
|
102
|
+
*
|
|
103
|
+
* DETERMINISTIC BY CONSTRUCTION: no random sampling, no clock, no iteration over a
|
|
104
|
+
* `Set` whose insertion order depends on anything but the input. AC-T3 asserts
|
|
105
|
+
* that two calls on the same input are `deepEqual`.
|
|
106
|
+
*/
|
|
107
|
+
export function tokenizeTask(text, limits = SKILL_TRIAGE_LIMITS) {
|
|
108
|
+
const task = text.slice(0, limits.maxTaskChars).toLowerCase();
|
|
109
|
+
const tokens = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const push = (token) => {
|
|
112
|
+
if (seen.has(token))
|
|
113
|
+
return false;
|
|
114
|
+
seen.add(token);
|
|
115
|
+
tokens.push(token);
|
|
116
|
+
return tokens.length < limits.maxTokens;
|
|
117
|
+
};
|
|
118
|
+
for (const match of task.matchAll(LATIN_TOKEN)) {
|
|
119
|
+
const token = match[0];
|
|
120
|
+
if (token.length < limits.latinTokenMinChars)
|
|
121
|
+
continue;
|
|
122
|
+
if (STOPWORDS.has(token))
|
|
123
|
+
continue;
|
|
124
|
+
if (!push(token))
|
|
125
|
+
return tokens;
|
|
126
|
+
}
|
|
127
|
+
for (const match of task.matchAll(CJK_RUN)) {
|
|
128
|
+
const run = match[0];
|
|
129
|
+
if (run.length < limits.cjkMinRunChars)
|
|
130
|
+
continue;
|
|
131
|
+
// Short runs are a single meaningful word ("表单", "重试"), so the run itself
|
|
132
|
+
// is the token. Long runs additionally yield bigrams, which is what lets
|
|
133
|
+
// "修复登录接口的超时重试逻辑" match a description containing "重试".
|
|
134
|
+
if (run.length <= 6) {
|
|
135
|
+
if (!push(run))
|
|
136
|
+
return tokens;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const gram of cjkBigrams(run, limits.maxCjkGramsPerRun)) {
|
|
140
|
+
if (!push(gram))
|
|
141
|
+
return tokens;
|
|
142
|
+
}
|
|
143
|
+
if (run.length <= limits.maxWholeRunChars) {
|
|
144
|
+
if (!push(run))
|
|
145
|
+
return tokens;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return tokens;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Bigrams of a long CJK run, capped at `max` by EVEN SAMPLING rather than by
|
|
152
|
+
* taking a prefix.
|
|
153
|
+
*
|
|
154
|
+
* A prefix would only ever describe the beginning of the sentence, and the words
|
|
155
|
+
* that matter in "帮我修复一下登录接口在超时之后的重试逻辑" are near the end. Even
|
|
156
|
+
* sampling keeps the whole run represented, and is a fixed arithmetic rule, so the
|
|
157
|
+
* result is still deterministic.
|
|
158
|
+
*/
|
|
159
|
+
function cjkBigrams(run, max) {
|
|
160
|
+
const total = run.length - 1;
|
|
161
|
+
if (total <= max) {
|
|
162
|
+
const grams = [];
|
|
163
|
+
for (let i = 0; i < total; i += 1)
|
|
164
|
+
grams.push(run.slice(i, i + 2));
|
|
165
|
+
return grams;
|
|
166
|
+
}
|
|
167
|
+
const grams = [];
|
|
168
|
+
const used = new Set();
|
|
169
|
+
for (let step = 0; step < max; step += 1) {
|
|
170
|
+
const index = Math.floor((step * total) / max);
|
|
171
|
+
if (used.has(index))
|
|
172
|
+
continue;
|
|
173
|
+
used.add(index);
|
|
174
|
+
grams.push(run.slice(index, index + 2));
|
|
175
|
+
}
|
|
176
|
+
return grams;
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Scoring
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
/**
|
|
182
|
+
* Score one record against the task (44 is the ceiling: 12 + 5*2 + 4*4 + 2*3).
|
|
183
|
+
*
|
|
184
|
+
* THE THREE HAYSTACKS ARE SEPARATE on purpose. A token found in `keywords` is a
|
|
185
|
+
* deliberate author signal and scores 4; the same token found only in the prose
|
|
186
|
+
* description scores 2. Merging them into one string would make every keyword hit
|
|
187
|
+
* look like a description hit as well, inflating the score on exactly the records
|
|
188
|
+
* whose authors were least specific.
|
|
189
|
+
*
|
|
190
|
+
* `record.frontmatter.keywords`, never `record.keywords`: `SkillRecord` has no
|
|
191
|
+
* top-level keyword field, and both keywords and `activation` hang off
|
|
192
|
+
* `frontmatter`.
|
|
193
|
+
*/
|
|
194
|
+
export function scoreSkill(record, tokens, taskText) {
|
|
195
|
+
const nameHay = normalizeHaystack(record.name);
|
|
196
|
+
const keywordHay = normalizeHaystack(record.frontmatter.keywords.join(' '));
|
|
197
|
+
const descHay = normalizeHaystack(record.description);
|
|
198
|
+
let nameHits = 0;
|
|
199
|
+
let keywordHits = 0;
|
|
200
|
+
let descHits = 0;
|
|
201
|
+
for (const token of tokens) {
|
|
202
|
+
if (nameHits < TRIAGE_CAPS.nameTokens && nameHay.includes(token)) {
|
|
203
|
+
nameHits += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (keywordHits < TRIAGE_CAPS.keywords && keywordHay.includes(token)) {
|
|
207
|
+
keywordHits += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (descHits < TRIAGE_CAPS.descriptions && descHay.includes(token))
|
|
211
|
+
descHits += 1;
|
|
212
|
+
}
|
|
213
|
+
const mentioned = isMentioned(record.name, taskText);
|
|
214
|
+
const reasons = [];
|
|
215
|
+
if (nameHits > 0 || mentioned)
|
|
216
|
+
reasons.push('name');
|
|
217
|
+
if (keywordHits > 0)
|
|
218
|
+
reasons.push('keyword');
|
|
219
|
+
if (descHits > 0)
|
|
220
|
+
reasons.push('description');
|
|
221
|
+
const score = (mentioned ? TRIAGE_WEIGHTS.mentioned : 0) +
|
|
222
|
+
nameHits * TRIAGE_WEIGHTS.nameToken +
|
|
223
|
+
keywordHits * TRIAGE_WEIGHTS.keyword +
|
|
224
|
+
descHits * TRIAGE_WEIGHTS.description;
|
|
225
|
+
return { score, reasons, mentioned };
|
|
226
|
+
}
|
|
227
|
+
/** Lowercase, with `-` / `_` folded to spaces so `pdf-forms` matches "pdf forms". */
|
|
228
|
+
function normalizeHaystack(text) {
|
|
229
|
+
return text.toLowerCase().replace(/[-_]+/g, ' ');
|
|
230
|
+
}
|
|
231
|
+
/** The skill's full name appears in the raw task, hyphenated or spaced. */
|
|
232
|
+
function isMentioned(name, taskText) {
|
|
233
|
+
const haystack = taskText.toLowerCase();
|
|
234
|
+
return haystack.includes(name.toLowerCase())
|
|
235
|
+
|| haystack.includes(name.toLowerCase().replace(/-+/g, ' '));
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The candidate list alone — `buildRoutingReport` minus the report.
|
|
239
|
+
*
|
|
240
|
+
* ONE PIPELINE, TWO VIEWS. This used to repeat the tokenize / rank / filter / slice
|
|
241
|
+
* sequence, which meant the candidate order and the report's `topUnqualified` were
|
|
242
|
+
* produced by two pieces of code that had to be kept in agreement by hand: exactly
|
|
243
|
+
* the drift `rankEligible`'s single comparator exists to prevent. The report owns
|
|
244
|
+
* the arithmetic; this is the projection of it for a caller who does not want
|
|
245
|
+
* `at` / `eligible` / `topUnqualified`.
|
|
246
|
+
*/
|
|
247
|
+
export function selectSkillCandidates(input) {
|
|
248
|
+
return buildRoutingReport(input).candidates;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Every eligible record with its score, best first — the shared middle of
|
|
252
|
+
* `selectSkillCandidates` and `buildRoutingReport`.
|
|
253
|
+
*
|
|
254
|
+
* Filters, then sorts with ONE comparator so the candidate list and the
|
|
255
|
+
* "runner-up" line can never disagree about which record is better: score
|
|
256
|
+
* descending, then scope precedence, then name. Records that scored zero are
|
|
257
|
+
* dropped here — they carry no signal and would only pad `topUnqualified` with an
|
|
258
|
+
* arbitrary name.
|
|
259
|
+
*/
|
|
260
|
+
function rankEligible(records, tokens, task, active) {
|
|
261
|
+
return records
|
|
262
|
+
.filter((record) => isEligible(record, active))
|
|
263
|
+
.map((record) => ({ record, ...scoreSkill(record, tokens, task) }))
|
|
264
|
+
.filter((entry) => entry.score > 0)
|
|
265
|
+
.sort((a, b) => {
|
|
266
|
+
if (b.score !== a.score)
|
|
267
|
+
return b.score - a.score;
|
|
268
|
+
const rank = TRIAGE_SCOPE_RANK[a.record.scope] - TRIAGE_SCOPE_RANK[b.record.scope];
|
|
269
|
+
if (rank !== 0)
|
|
270
|
+
return rank;
|
|
271
|
+
return a.record.name.localeCompare(b.record.name, 'en');
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function toCandidate(entry, limits) {
|
|
275
|
+
return {
|
|
276
|
+
name: entry.record.name,
|
|
277
|
+
scope: entry.record.scope,
|
|
278
|
+
description: truncateToChars(entry.record.description, limits.descLineChars),
|
|
279
|
+
score: entry.score,
|
|
280
|
+
reasons: entry.reasons.slice(0, 3),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function isEligible(record, active) {
|
|
284
|
+
if (record.disabled || record.invalid)
|
|
285
|
+
return false;
|
|
286
|
+
if (record.frontmatter.activation === 'manual')
|
|
287
|
+
return false;
|
|
288
|
+
return !active.has(record.name);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The router's whole decision, as one pure call — and the ONE place the arithmetic
|
|
292
|
+
* lives (`selectSkillCandidates` is a projection of it).
|
|
293
|
+
*
|
|
294
|
+
* THE FILTER IS THE SAME ONE `catalogRecords` AND `skill_find` USE (I-T4): not
|
|
295
|
+
* disabled, not invalid, not `activation: manual`, and not already active. It is
|
|
296
|
+
* re-implemented here rather than imported because the report needs the
|
|
297
|
+
* candidates' scores, and `catalogRecords` returns records. `triage.test.ts` pins
|
|
298
|
+
* this filter against `catalogRecords` so the two cannot drift apart silently.
|
|
299
|
+
*
|
|
300
|
+
* ORDER IS SCOPE FIRST, then name. That is NOT the order the user sees in
|
|
301
|
+
* `<available_skills>`, where `usageTracking` re-ranks by recent use
|
|
302
|
+
* (`service.ts:catalogBlock`). Intentional: the catalog answers "what have I used
|
|
303
|
+
* lately", the candidate list answers "what should I read first".
|
|
304
|
+
*
|
|
305
|
+
* `at` is the only non-derived field and it is display-only, so a test can pass a
|
|
306
|
+
* fixed value and compare two reports with `deepEqual`.
|
|
307
|
+
*/
|
|
308
|
+
export function buildRoutingReport(input) {
|
|
309
|
+
const limits = { ...SKILL_TRIAGE_LIMITS, ...input.limits };
|
|
310
|
+
const task = input.task.slice(0, limits.maxTaskChars);
|
|
311
|
+
const tokens = tokenizeTask(task, limits);
|
|
312
|
+
const active = new Set(input.activeNames);
|
|
313
|
+
const ranked = rankEligible(input.records, tokens, task, active);
|
|
314
|
+
const qualified = ranked.filter((entry) => entry.score >= limits.minScore);
|
|
315
|
+
const candidates = qualified.slice(0, limits.maxCandidates).map((e) => toCandidate(e, limits));
|
|
316
|
+
const runnerUp = ranked.find((entry) => entry.score < limits.minScore);
|
|
317
|
+
const best = candidates[0]?.score ?? 0;
|
|
318
|
+
return {
|
|
319
|
+
task,
|
|
320
|
+
tokenCount: tokens.length,
|
|
321
|
+
eligible: input.records.filter((record) => isEligible(record, active)).length,
|
|
322
|
+
candidates,
|
|
323
|
+
topUnqualified: runnerUp ? { name: runnerUp.record.name, score: runnerUp.score } : null,
|
|
324
|
+
decision: candidates.length === 0 ? 'none' : best >= limits.confidentScore ? 'confident' : 'suggest',
|
|
325
|
+
at: input.now ?? 0,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Rendering (§4.2.2)
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
const CANDIDATES_OPEN = '<skill_candidates note="computed from your message, not a verdict - confirm before loading">';
|
|
332
|
+
const CANDIDATES_CLOSE = '</skill_candidates>';
|
|
333
|
+
/**
|
|
334
|
+
* The `<skill_candidates>` block, or `''` when there is nothing to propose.
|
|
335
|
+
*
|
|
336
|
+
* `''` is load-bearing: it is what keeps `buildSystemPrompt` byte-identical to the
|
|
337
|
+
* pre-feature text when triage finds nothing, so an empty result cannot leave an
|
|
338
|
+
* empty shell behind (I-T1).
|
|
339
|
+
*
|
|
340
|
+
* BUDGET DISCIPLINE, copied from `renderSkillCatalog`: reserve the worst-case
|
|
341
|
+
* overflow line UP FRONT, add lines while they fit, and on overflow drop whole
|
|
342
|
+
* lines rather than truncating text or the closing tag. A dropped candidate is
|
|
343
|
+
* visible as `(+N more not shown)`; a truncated one reads as a corrupt skill name.
|
|
344
|
+
*/
|
|
345
|
+
export function renderSkillCandidates(candidates, opts = {}) {
|
|
346
|
+
if (candidates.length === 0)
|
|
347
|
+
return '';
|
|
348
|
+
const limits = { ...SKILL_TRIAGE_LIMITS, ...opts.limits };
|
|
349
|
+
const maxBytes = opts.maxBytes ?? limits.maxBlockBytes;
|
|
350
|
+
const fixed = byteLength(CANDIDATES_OPEN) + byteLength(`\n${CANDIDATES_CLOSE}`);
|
|
351
|
+
const reserve = byteLength(`\n${overflowLine(candidates.length)}`);
|
|
352
|
+
const lines = [];
|
|
353
|
+
let used = fixed;
|
|
354
|
+
for (const candidate of candidates) {
|
|
355
|
+
const line = candidateLine(candidate, limits);
|
|
356
|
+
const cost = byteLength(`\n${line}`);
|
|
357
|
+
if (used + cost + reserve > maxBytes)
|
|
358
|
+
break;
|
|
359
|
+
lines.push(line);
|
|
360
|
+
used += cost;
|
|
361
|
+
}
|
|
362
|
+
const omitted = candidates.length - lines.length;
|
|
363
|
+
const parts = [CANDIDATES_OPEN, ...lines];
|
|
364
|
+
if (omitted > 0)
|
|
365
|
+
parts.push(overflowLine(omitted));
|
|
366
|
+
parts.push(CANDIDATES_CLOSE);
|
|
367
|
+
return parts.join('\n');
|
|
368
|
+
}
|
|
369
|
+
function overflowLine(count) {
|
|
370
|
+
return `(+${count} more not shown)`;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* `- name (scope): description [reason]`
|
|
374
|
+
*
|
|
375
|
+
* THE NAME IS CUT HERE AND NOWHERE ELSE. A kebab directory name has no upstream
|
|
376
|
+
* length limit, so one 200-character name would otherwise consume the entire block
|
|
377
|
+
* and starve the candidates behind it. The cut is on the NAME only; the identity
|
|
378
|
+
* the controller tracks for "notified once" is `candidate.name`, whole.
|
|
379
|
+
*/
|
|
380
|
+
function candidateLine(candidate, limits) {
|
|
381
|
+
const name = truncateToChars(candidate.name, limits.maxNameChars);
|
|
382
|
+
const description = truncateToChars(candidate.description, limits.descLineChars);
|
|
383
|
+
const reason = truncateToChars(candidate.reasons.join(', '), limits.reasonChars);
|
|
384
|
+
const suffix = reason.length > 0 ? ` [${reason}]` : '';
|
|
385
|
+
return `- ${name} (${candidate.scope}): ${description}${suffix}`;
|
|
386
|
+
}
|
|
387
|
+
/** Collapse whitespace, then cut to `max` CHARACTERS with an ASCII ellipsis. */
|
|
388
|
+
function truncateToChars(text, max) {
|
|
389
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
390
|
+
if (clean.length <= max)
|
|
391
|
+
return clean;
|
|
392
|
+
return `${clean.slice(0, Math.max(1, max - 3)).trimEnd()}...`;
|
|
393
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/subagent` — parse the user's arguments and compile them into ONE user turn
|
|
3
|
+
* (subagent-fanout 3.5.2 / 3.5.4).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope, and this module
|
|
6
|
+
* also produces text a model reads in a `cmd.exe` terminal.
|
|
7
|
+
*
|
|
8
|
+
* WHY A PROMPT AND NOT A DIRECT `dispatch()` CALL (D-8 / 3.5.1, option C). The
|
|
9
|
+
* alternative — the command splits the brief itself and calls
|
|
10
|
+
* `TeamRuntime.dispatch()` — needs a SECOND dispatch entry point (breaking I-1),
|
|
11
|
+
* a run lifecycle the command does not own (Esc, the lead's idle watchdog, the
|
|
12
|
+
* report rendered into the transcript, spend accounting, the concurrency lock),
|
|
13
|
+
* and a planner that cannot see the conversation. Compiling the user's intent
|
|
14
|
+
* into a normal user turn keeps all of that on the one path that already has it.
|
|
15
|
+
* The cost is that forcing is INSTRUCTION-LEVEL rather than mechanical, which is
|
|
16
|
+
* why the command body prints a plan ("the next turn dispatches them") instead
|
|
17
|
+
* of claiming results (D-10).
|
|
18
|
+
*
|
|
19
|
+
* TWO PURE FUNCTIONS, NO IO: parse and compile. Both are deterministic, so the
|
|
20
|
+
* whole surface is unit-testable without a terminal, a config file or a model.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Bumped whenever the directive's wording changes; the string appears VERBATIM
|
|
24
|
+
* on the directive's first line so a behaviour report ties to one revision with
|
|
25
|
+
* a single grep (D-15). The `-YYYY-MM` suffix is part of the version, not a
|
|
26
|
+
* comment: `v1` alone is easy to confuse with the other counters in this package.
|
|
27
|
+
*/
|
|
28
|
+
export declare const FORCED_DISPATCH_VERSION = "v1-2026-10";
|
|
29
|
+
export interface SubagentCommandArgs {
|
|
30
|
+
/** The task text, with flags stripped and whitespace collapsed. */
|
|
31
|
+
brief: string;
|
|
32
|
+
/** `null` when the user did not pass `-n` (the caller applies the default). */
|
|
33
|
+
count: number | null;
|
|
34
|
+
/** Already slugged, deduped, and capped at `TEAM_LIMITS.forcedPartsMax`. */
|
|
35
|
+
parts: string[];
|
|
36
|
+
/**
|
|
37
|
+
* How many usable parts the user asked for BEFORE the `forcedPartsMax` cap.
|
|
38
|
+
*
|
|
39
|
+
* THE ONE FIELD THE OBVIOUS FOUR COULD NOT CARRY. `--parts` has two ceilings -
|
|
40
|
+
* the structural 8 and the policy `maxSubagents` - and R-P1-3 requires BOTH to
|
|
41
|
+
* be disclosed ("asked for 11 parts, the first 8 are used"). A capped array
|
|
42
|
+
* cannot name what it dropped, so the count that was dropped is kept here; the
|
|
43
|
+
* command body is the only place that can also apply the policy ceiling.
|
|
44
|
+
*/
|
|
45
|
+
partsRequested: number;
|
|
46
|
+
/** `--read-only`: every child refuses writes and shell. */
|
|
47
|
+
readOnly: boolean;
|
|
48
|
+
}
|
|
49
|
+
export type SubagentParseResult = {
|
|
50
|
+
ok: true;
|
|
51
|
+
args: SubagentCommandArgs;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
error: 'count' | 'conflict' | 'flag' | 'brief';
|
|
55
|
+
token?: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Parse `/subagent` arguments. ONE parameter (R-P1-1): the structural bounds are
|
|
59
|
+
* constants this module imports, not a caller's opinion.
|
|
60
|
+
*
|
|
61
|
+
* NEVER THROWS and never guesses. Every malformed input maps to one of the four
|
|
62
|
+
* errors the command body has a sentence for, because "we did something
|
|
63
|
+
* reasonable with your typo" is indistinguishable from a bug at the terminal.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseSubagentArgs(raw: string): SubagentParseResult;
|
|
66
|
+
export interface ForcedDispatchRequest {
|
|
67
|
+
brief: string;
|
|
68
|
+
/** Already narrowed to `[1, maxSubagents]` by the command body. */
|
|
69
|
+
count: number;
|
|
70
|
+
readOnly: boolean;
|
|
71
|
+
/** Already narrowed to `maxSubagents`; may be empty. */
|
|
72
|
+
parts: string[];
|
|
73
|
+
/** The LIVE `team.maxSubagents`, quoted in the disclosure lines. */
|
|
74
|
+
maxSubagents: number;
|
|
75
|
+
/** True when `requested` exceeded what the config allows. */
|
|
76
|
+
clamped: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* What the user ASKED for before narrowing - `-n 12` is 12, five parts are 5.
|
|
79
|
+
* Quoting the narrowed number instead would produce "you asked for 8; the
|
|
80
|
+
* configured maximum is 8", which says nothing (R-4).
|
|
81
|
+
*/
|
|
82
|
+
requested: number;
|
|
83
|
+
/** Disclosed, never a reason to refuse (R-P2-6): plan mode may hide `task`. */
|
|
84
|
+
planMode: boolean;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The ONE user turn a `/subagent` invocation submits. Same input, same bytes -
|
|
88
|
+
* which is what makes the snapshot tests meaningful.
|
|
89
|
+
*
|
|
90
|
+
* THE ORDER OF THE SENTENCES IS THE ORDER OF THE MODEL'S DECISIONS: what to do
|
|
91
|
+
* (call `task` ONCE), what it must not do instead (work alone), then the lucky
|
|
92
|
+
* case (the brief does not split), then the brief itself, then the disclosure
|
|
93
|
+
* lines, then the per-spec rules.
|
|
94
|
+
*/
|
|
95
|
+
export declare function buildForcedDirective(request: ForcedDispatchRequest): string;
|