@lmzhen/dsh-evolution-core 0.26.26 → 0.27.1
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/README.md +16 -1
- package/lib/composition/index.js +3 -0
- package/lib/composition-D8Y3-ROd.js +604 -0
- package/lib/ensure-MEYMj9jn.js +222 -0
- package/lib/errors-Br2-uUSK.js +57 -0
- package/lib/http-routes-BP-S3KV-.js +149 -0
- package/lib/http-routes.js +2 -0
- package/lib/index.js +66 -850
- package/lib/restrictor.js +37 -0
- package/lib/subagent/index.js +186 -0
- package/lib/types/citations.js +192 -0
- package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
- package/lib/types/composition/composer.js +681 -0
- package/lib/types/composition/ensure.d.ts +10 -0
- package/lib/types/composition/ensure.js +95 -0
- package/lib/types/composition/index.d.ts +5 -0
- package/lib/types/composition/index.js +7 -0
- package/lib/types/composition/preset-ids.d.ts +17 -0
- package/lib/types/composition/preset-ids.js +21 -0
- package/lib/types/composition/restricted-preset.d.ts +36 -0
- package/lib/types/composition/restricted-preset.js +42 -0
- package/lib/types/constants.js +223 -0
- package/lib/types/cost.js +67 -0
- package/lib/types/curator.js +346 -0
- package/lib/types/diagnostics.d.ts +26 -0
- package/lib/types/diagnostics.js +23 -0
- package/lib/types/document-body.js +91 -0
- package/lib/types/drift-signals.js +319 -0
- package/lib/types/env.js +25 -0
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +59 -0
- package/lib/types/events.js +16 -0
- package/lib/types/evidence.js +65 -0
- package/lib/types/evolution-events.js +479 -0
- package/lib/types/fold-untrusted.js +44 -0
- package/lib/types/frontmatter.js +623 -0
- package/lib/types/fuzzy-match.js +135 -0
- package/lib/types/gates.js +51 -0
- package/lib/types/guards.js +27 -0
- package/lib/types/health-baseline.js +63 -0
- package/lib/types/http-routes.d.ts +67 -35
- package/lib/types/http-routes.js +135 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +101 -0
- package/lib/types/instance-scope.js +69 -0
- package/lib/types/io.js +675 -0
- package/lib/types/learn-prompt.js +42 -0
- package/lib/types/limits.js +63 -0
- package/lib/types/live.js +107 -0
- package/lib/types/long-runs.js +96 -0
- package/lib/types/memory-store.d.ts +0 -16
- package/lib/types/memory-store.js +606 -0
- package/lib/types/mutations.js +95 -0
- package/lib/types/namespace-migration.js +156 -0
- package/lib/types/numeric.js +47 -0
- package/lib/types/opt-in.js +101 -0
- package/lib/types/package-identity.js +47 -0
- package/lib/types/params.js +328 -0
- package/lib/types/payload.js +36 -0
- package/lib/types/platform-line.generated.d.ts +10 -0
- package/lib/types/platform-line.generated.js +12 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/platform-services.js +52 -0
- package/lib/types/probe.js +122 -0
- package/lib/types/prompts.js +409 -0
- package/lib/types/quality.js +308 -0
- package/lib/types/reachability.js +122 -0
- package/lib/types/redact.js +257 -0
- package/lib/types/reference-rewrite.js +144 -0
- package/lib/types/reports.js +110 -0
- package/lib/types/restrictor.d.ts +11 -0
- package/lib/types/restrictor.js +48 -0
- package/lib/types/review-channel.js +59 -0
- package/lib/types/run-registry.js +345 -0
- package/lib/types/scope.js +7 -0
- package/lib/types/serial.js +17 -0
- package/lib/types/session-fold.d.ts +15 -0
- package/lib/types/session-fold.js +28 -0
- package/lib/types/session-projection.js +295 -0
- package/lib/types/signals.js +209 -0
- package/lib/types/skill-discovery.js +66 -0
- package/lib/types/skill-health.js +134 -0
- package/lib/types/skill-history.js +745 -0
- package/lib/types/skill-lint.js +112 -0
- package/lib/types/skill-reads.js +67 -0
- package/lib/types/skill-row-cells.js +123 -0
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.js +117 -0
- package/lib/types/subagent/channel.d.ts +4 -0
- package/lib/types/subagent/channel.js +131 -0
- package/lib/types/subagent/index.d.ts +4 -0
- package/lib/types/subagent/index.js +5 -0
- package/lib/types/subagent/readiness.d.ts +6 -0
- package/lib/types/subagent/readiness.js +35 -0
- package/lib/types/subagent/types.d.ts +58 -0
- package/lib/types/subagent/types.js +2 -0
- package/lib/types/threats.js +338 -0
- package/lib/types/tool-dispatch.d.ts +5 -0
- package/lib/types/tool-dispatch.js +535 -0
- package/lib/types/tree-write.js +40 -0
- package/lib/types/unit-members.js +121 -0
- package/lib/types/usage.js +474 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.js +344 -0
- package/lib/types/write-inventory.js +158 -0
- package/package.json +39 -11
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential-shaped text redaction for cross-boundary model inputs (011 §8).
|
|
3
|
+
*
|
|
4
|
+
* The maintenance facts block and probe output — like the review snapshot
|
|
5
|
+
* before them — leave the owning session's context, so secret-shaped text is
|
|
6
|
+
* masked before it is sent. Best-effort and conservative: targets well-known
|
|
7
|
+
* secret shapes and inline assignment patterns, never wholesale content.
|
|
8
|
+
* Migrated from `evolution-review` so both channels share one implementation.
|
|
9
|
+
*/
|
|
10
|
+
const SECRET_PATTERNS = [
|
|
11
|
+
// V8-12 (0.3.47): `\b` left boundary — `sk-` alone matched inside
|
|
12
|
+
// `task-…`/`risk-…` words. V9-10 (0.3.51): the "other patterns carry their
|
|
13
|
+
// own anchors" claim was overstated — AKIA/ghp_/glpat-/xox do carry a
|
|
14
|
+
// fixed prefix, but the JWT (`eyJ…`) and bearer rows have no left boundary
|
|
15
|
+
// at all (a `…xeyJ…` substring would redact mid-word). Deliberate: JWT
|
|
16
|
+
// body shapes never legitimately appear inside another word, and the
|
|
17
|
+
// bearer row's initial-cap `Bearer` form makes mid-word hits effectively
|
|
18
|
+
// nonexistent. Revisit only if a real false positive shows up.
|
|
19
|
+
['openai-style key', /\bsk-[A-Za-z0-9_-]{16,}/g],
|
|
20
|
+
['aws access key', /AKIA[0-9A-Z]{16}/g],
|
|
21
|
+
['github token', /gh[pousr]_[A-Za-z0-9]{20,}/g],
|
|
22
|
+
['gitlab token', /glpat-[A-Za-z0-9_-]{16,}/g],
|
|
23
|
+
['slack token', /xox[baprs]-[A-Za-z0-9-]{10,}/g],
|
|
24
|
+
['jwt', /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
25
|
+
// v22 (SEC-4): provider token shapes the original set missed (each verified
|
|
26
|
+
// to pass redactSecrets verbatim before this change).
|
|
27
|
+
['npm token', /npm_[A-Za-z0-9]{20,}/g],
|
|
28
|
+
['stripe key', /[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g],
|
|
29
|
+
['github fine-grained token', /github_pat_[A-Za-z0-9_]{20,}/g],
|
|
30
|
+
['google api key', /AIza[0-9A-Za-z_-]{30,}/g],
|
|
31
|
+
// F-335 (0.3.23): case-insensitive (`bearer`), and `\s+` so a tab or run of
|
|
32
|
+
// spaces between `Bearer` and the token is still masked (`Bearer\t...`).
|
|
33
|
+
['bearer credential', /Bearer[\s]+[a-z0-9._~+/=\-]{16,}/gi],
|
|
34
|
+
// R3 (round-2 audit): HTTP Basic — `Authorization: Basic <base64>` crossed
|
|
35
|
+
// every model boundary: `Basic` was in no value-shape row and
|
|
36
|
+
// `authorization` in no keyword set, so the base64 user:password blob
|
|
37
|
+
// shipped verbatim while the README claimed credential masking.
|
|
38
|
+
['basic credential', /\bBasic[\s]+[a-z0-9._~+/=]{16,}/gi],
|
|
39
|
+
];
|
|
40
|
+
// F-335 (0.3.23): the older `\b(?:token|...)\b` missed connected keys such as
|
|
41
|
+
// `auth_token=`, `client_secret=`, `access_token=`. The v18 audit (A2-5)
|
|
42
|
+
// found the replacement's value class too narrow: a value containing
|
|
43
|
+
// `@ ! # $ %` or spaces did not match at all and leaked verbatim. The pattern
|
|
44
|
+
// now matches the key + separator, then ANY quoted or unquoted value up to
|
|
45
|
+
// the line end, and masks the value wholesale.
|
|
46
|
+
//
|
|
47
|
+
// The unquoted fallback must NOT exclude the quote characters: a value whose
|
|
48
|
+
// opening quote has no closing quote on this line (a truncated log line, a
|
|
49
|
+
// multi-line JSON value, an escaped quote inside the value) otherwise matched
|
|
50
|
+
// no branch at all, and the regex fell back to "value = one space" — masking
|
|
51
|
+
// the separator and leaking the secret verbatim. The quoted branches still win
|
|
52
|
+
// for well-formed values because they are tried first.
|
|
53
|
+
//
|
|
54
|
+
// A2-6 (v18): the old `(?:\b|[\w-]+[_\-])` prefix was O(n²) on a long word
|
|
55
|
+
// with no separator (100k chars measured ~23-26s, synchronously). P2-2 (v37):
|
|
56
|
+
// the `{0,64}` bound added then is GONE — a 65-char key prefix crossed
|
|
57
|
+
// verbatim. Linearity now rests on the mandatory left anchor `(^|[^\w-])`:
|
|
58
|
+
// only a key run behind `^`/a non-word char is attempted, and once per run.
|
|
59
|
+
// P2-7/P2-8 (v19): the value is the WHOLE rest of the line — one branch, no
|
|
60
|
+
// quoted/unquoted alternation. Two defects forced this: the separator's `[\s]*`
|
|
61
|
+
// matched a newline, so `api_key:` with no same-line value swallowed the NEXT
|
|
62
|
+
// line; and the quoted branch short-circuited at the first inner quote, leaving
|
|
63
|
+
// `token="a"b"c" tail` as `token=<redacted>b"c" tail`. Over-masking a line is
|
|
64
|
+
// acceptable for a redactor; leaking or eating an unrelated line is not.
|
|
65
|
+
// Capture groups: 1 = leading boundary (kept), 2 = connected prefix (kept),
|
|
66
|
+
// 3 = key (kept), 4 = separator (kept), 5 = value (masked).
|
|
67
|
+
// V27 G0.5 (CB-1): group 4 tolerates a CLOSING QUOTE after the key. In JSON —
|
|
68
|
+
// the most common carrier of credentials in tool results and session text —
|
|
69
|
+
// the key is quoted (`"password": "hunter2"`), and the quote between the key
|
|
70
|
+
// and the `:` made the separator miss entirely, so the whole pattern failed and
|
|
71
|
+
// the value crossed the boundary verbatim. Value-shape patterns only masked
|
|
72
|
+
// well-known token prefixes; an arbitrary passphrase like `hunter2` had no
|
|
73
|
+
// layer left to catch it.
|
|
74
|
+
const INLINE_ASSIGNMENT_PATTERN = new RegExp('(^|[^\\w-])([\\w-]*[_\\-])?((?:token|api[_-]?key|secret|password|passwd|authorization)' +
|
|
75
|
+
'(?:[_\\-][\\w-]*)?)\\b(["\\\']?[\\t ]*[:=][\\t ]*)' +
|
|
76
|
+
'([^\\r\\n]+)', 'gi');
|
|
77
|
+
// v22 (SEC-4): a scheme://user:password@host URL — only the password segment
|
|
78
|
+
// is masked; the group-preserving replace keeps the scheme/user readable so a
|
|
79
|
+
// connection string stays diagnosable.
|
|
80
|
+
// P2-28 (v39): the scheme run is capped at 64 characters — unbounded, a
|
|
81
|
+
// scheme-free 60k blob cost 2.1-4.4s per call (the engine re-walks the run at
|
|
82
|
+
// every offset, synchronously, on the outbound redaction path); 25ms now.
|
|
83
|
+
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]{0,63}:\/\/[^\s:/@]+:)([^\s/@]+)@/gi;
|
|
84
|
+
// v28 G5.1 (REDACT-02): a PEM private key block is masked WHOLE (header to
|
|
85
|
+
// footer). threats.ts blocks the same shape on the WRITE path
|
|
86
|
+
// (`private_key_block`, SEC-3) — but the review/maintenance OUTBOUND channels
|
|
87
|
+
// pass through redact only, never the threat scan, so a session that pasted a
|
|
88
|
+
// key used to ship it verbatim to the second model context. The header
|
|
89
|
+
// alternation mirrors threats.ts's pattern (keep the two in sync).
|
|
90
|
+
// The `\\s` doubles are required: these are STRING literals feeding
|
|
91
|
+
// `new RegExp`, so each backslash must survive JS string-escape processing.
|
|
92
|
+
const PEM_HEAD = '-----BEGIN\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----';
|
|
93
|
+
const PEM_TAIL = '-----END\\s+(?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP)\\s+)?PRIVATE\\s+KEY(?:\\s+BLOCK)?-----';
|
|
94
|
+
const PEM_PRIVATE_KEY_PATTERN = new RegExp(`${PEM_HEAD}[\\s\\S]*?${PEM_TAIL}`, 'g');
|
|
95
|
+
// v28 G5.1 (REDACT-01): the block-style form the inline pattern cannot reach —
|
|
96
|
+
// its separator `[\t ]*[:=]` and value class are same-line-only, so YAML /
|
|
97
|
+
// docker-compose / kubectl-secret style
|
|
98
|
+
// api_key:
|
|
99
|
+
// wJalrXUtnFEMI…
|
|
100
|
+
// crossed verbatim. This predicate matches a line that is ONLY a credential
|
|
101
|
+
// key plus its mapping separator; the following INDENTED line is its value.
|
|
102
|
+
// v31 REDACT-03: the key carries the same connected-prefix group as the
|
|
103
|
+
// inline pattern (`AWS_SECRET_ACCESS_KEY`, `CLIENT_SECRET`, `DB_PASSWORD` —
|
|
104
|
+
// the inline pattern's connected-prefix form, unbounded since P2-2 (v37)).
|
|
105
|
+
// Without it, every prefixed block key
|
|
106
|
+
// missed the pass AND the AWS residual sweep (the underscore blocks every
|
|
107
|
+
// `\b`), so the secret shipped verbatim (executed-verified leak).
|
|
108
|
+
// v31 REDACT-03: the key carries the same connected-prefix group as the
|
|
109
|
+
// inline pattern (`AWS_SECRET_ACCESS_KEY`, `CLIENT_SECRET`, `DB_PASSWORD` —
|
|
110
|
+
// the inline pattern's connected-prefix form, unbounded since P2-2 (v37)).
|
|
111
|
+
// Without it, every prefixed block key
|
|
112
|
+
// missed the pass AND the AWS residual sweep (the underscore blocks every
|
|
113
|
+
// `\b`), so the secret shipped verbatim (executed-verified leak).
|
|
114
|
+
// OPT-03 (2026-09): the trailing `\s*$` already absorbed a `\r` on the KEY
|
|
115
|
+
// line, but the VALUE-line pattern below still anchored `[ \t]*$` — on CRLF
|
|
116
|
+
// text the key line matched, the value line silently `continue`d, and the
|
|
117
|
+
// secret crossed verbatim. Both anchors now tolerate an optional `\r` so the
|
|
118
|
+
// line-paired pass behaves identically on LF and CRLF input (Windows-authored
|
|
119
|
+
// .env / compose / kubectl YAML is the realistic carrier).
|
|
120
|
+
const BLOCK_KEY_ONLY_LINE = /(?:^|\s)([\w-]*[_\-])?((?:token|api[_-]?key|secret|password|passwd)(?:[_\-][\w-]*)?)\s*:(?:\r)?$/i;
|
|
121
|
+
// S0.3 (v37 P0-2): the two patterns above cannot express a camelCase hump —
|
|
122
|
+
// they are case-INSENSITIVE, so [A-Z] would match lowercase too. This pair
|
|
123
|
+
// decides by a case-insensitive KEYWORD test that rejects a lowercase
|
|
124
|
+
// continuation: clientSecret= / accessToken= / SecretAccessKey= are masked,
|
|
125
|
+
// while tokenizer= / secretary: / passwordless: stay untouched.
|
|
126
|
+
const CREDENTIAL_KEY_RE = /(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]asswd|[Aa]pi[_-]?[Kk]ey)(?![a-z])/;
|
|
127
|
+
// P2-2 (v37): the candidate key carried the same defect class in `{1,80}` form —
|
|
128
|
+
// a longer camelCase key (this pass is the ONLY layer that can reach one) was
|
|
129
|
+
// skipped wholesale. The cap is gone; the scan stays linear (see A2-6 above).
|
|
130
|
+
// Review B-P1 (2026-09-16): the former fourth group was a CONSUMING
|
|
131
|
+
// `([^\r\n]+)` value. Every match then cost O(distance to end of line), so one
|
|
132
|
+
// long single-line input (minified JSON / a dense assignment chain) went
|
|
133
|
+
// quadratic in the line length — 2.1s for 160k chars and 7.0s for 320k against
|
|
134
|
+
// ~0.1ms for the replace form this loop replaced. The zero-width lookahead
|
|
135
|
+
// keeps the old "a value must exist" requirement (an empty `token=` still does
|
|
136
|
+
// not match) and the loop locates the value end by hand, which stays linear.
|
|
137
|
+
const CANDIDATE_ASSIGNMENT_PATTERN = /(^|[^\w-])([\w-]+)(["']?[\t ]*[:=][\t ]*)(?=[^\r\n])/g;
|
|
138
|
+
/**
|
|
139
|
+
* Mask credential-shaped text before it crosses a session boundary.
|
|
140
|
+
* @param text - the text about to be sent to a model outside this session.
|
|
141
|
+
* @returns the text with matched secrets replaced by `<redacted>`.
|
|
142
|
+
*/
|
|
143
|
+
export function redactSecrets(text) {
|
|
144
|
+
// 0.3.16 (E-1): the generic replacer once keyed on `p1 === undefined` to
|
|
145
|
+
// distinguish "no capture group" — but for a capture-group-free regex the
|
|
146
|
+
// second callback argument is the match OFFSET (a number), so the output
|
|
147
|
+
// carried the offset (e.g. 'use 4<redacted> tomorrow'). The plain patterns
|
|
148
|
+
// now take a literal replacement; only the patterns with a real capture
|
|
149
|
+
// group (the inline-assignment label prefix, the URL scheme/user) keep
|
|
150
|
+
// their parts.
|
|
151
|
+
let out = text;
|
|
152
|
+
// v28 G5.1: PEM blocks first — the value-shape passes below only mask the
|
|
153
|
+
// well-known token prefixes, and a key body carries no marker to anchor on.
|
|
154
|
+
out = out.replace(PEM_PRIVATE_KEY_PATTERN, '<redacted-private-key>');
|
|
155
|
+
for (const [, pattern] of SECRET_PATTERNS) {
|
|
156
|
+
out = out.replace(pattern, '<redacted>');
|
|
157
|
+
}
|
|
158
|
+
out = out.replace(URL_CREDENTIALS_PATTERN, (_match, lead) => `${lead ?? ''}<redacted>@`);
|
|
159
|
+
out = out.replace(INLINE_ASSIGNMENT_PATTERN, (_match, lead, prefix, key, separator) => `${lead ?? ''}${prefix ?? ''}${key ?? ''}${separator ?? ''}<redacted>`);
|
|
160
|
+
// S0.3: camelCase keys ride this second, case-aware pass (see CREDENTIAL_KEY_RE).
|
|
161
|
+
// PLAN S1.2 (2026-09-16): exec loop instead of String.replace. The replace
|
|
162
|
+
// callback returned a rejected (non-credential-key) match untouched, but the
|
|
163
|
+
// greedy value group had already consumed the rest of the line, so every
|
|
164
|
+
// LATER key on that line was never scanned and leaked
|
|
165
|
+
// (`{"name": "x", "clientSecret": "supersecret123"}` — the candidate pass is
|
|
166
|
+
// the ONLY layer that reaches a camelCase key, see the comment on the
|
|
167
|
+
// pattern above). A rejected match now advances only past lead+key+separator
|
|
168
|
+
// so the line remainder — including later credential keys — stays scannable.
|
|
169
|
+
// Termination: key is >= 1 char and the separator always carries `[:=]`, so
|
|
170
|
+
// lastIndex strictly grows every round. Trade-off: the resume point sits
|
|
171
|
+
// inside the rejected value, so a value-shaped `k=v` tail can be re-matched
|
|
172
|
+
// too — same over-mask-don't-leak policy as P2-7 (a rejected value's
|
|
173
|
+
// non-credential `a:b` shapes, e.g. `http://…`, still pass: their "key"
|
|
174
|
+
// fails the same predicate). A fresh `g` instance per call keeps the shared
|
|
175
|
+
// const's lastIndex state out of the loop.
|
|
176
|
+
const candidatePattern = new RegExp(CANDIDATE_ASSIGNMENT_PATTERN.source, 'g');
|
|
177
|
+
let candidateOut = '';
|
|
178
|
+
let consumed = 0;
|
|
179
|
+
for (let m = candidatePattern.exec(out); m !== null; m = candidatePattern.exec(out)) {
|
|
180
|
+
const lead = m[1] ?? '';
|
|
181
|
+
const key = m[2] ?? '';
|
|
182
|
+
const separator = m[3] ?? '';
|
|
183
|
+
if (!CREDENTIAL_KEY_RE.test(key))
|
|
184
|
+
continue;
|
|
185
|
+
// Same output shape as the previous replace callback: the value (the rest
|
|
186
|
+
// of the line) is masked wholesale. Its END is located by hand instead of
|
|
187
|
+
// by a consuming group, so neither the accepted nor the rejected path ever
|
|
188
|
+
// re-scans the line remainder (review B-P1, see the pattern note). A
|
|
189
|
+
// rejection resumes exactly where it used to — just past the separator,
|
|
190
|
+
// inside the value — which keeps the documented over-mask-don't-leak
|
|
191
|
+
// trade-off unchanged.
|
|
192
|
+
let valueEnd = m.index + m[0].length;
|
|
193
|
+
while (valueEnd < out.length && out[valueEnd] !== '\n' && out[valueEnd] !== '\r')
|
|
194
|
+
valueEnd += 1;
|
|
195
|
+
candidateOut += out.slice(consumed, m.index) + lead + key + separator + '<redacted>';
|
|
196
|
+
consumed = valueEnd;
|
|
197
|
+
candidatePattern.lastIndex = valueEnd;
|
|
198
|
+
}
|
|
199
|
+
out = candidateOut + out.slice(consumed);
|
|
200
|
+
// Line-paired passes on the split lines. Order matters: the block-style pass
|
|
201
|
+
// runs first so the `<redacted>` it plants still enables the AWS residual
|
|
202
|
+
// pass below (a 40-char secret on the block value line is already gone, but
|
|
203
|
+
// a same-line AWS pair id+secret benefits from both).
|
|
204
|
+
const lines = out.split('\n');
|
|
205
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
206
|
+
const line = lines[i];
|
|
207
|
+
if (line === undefined)
|
|
208
|
+
continue;
|
|
209
|
+
// S0.3: a bare camelCase key line (`clientSecret:` / `SecretAccessKey:`) pairs
|
|
210
|
+
// with its indented value through the same case-aware predicate as the inline pass.
|
|
211
|
+
const camelKey = /^([\w-]+)\s*:(?:\r)?$/.exec(line)?.[1];
|
|
212
|
+
if (!(BLOCK_KEY_ONLY_LINE.test(line) || (camelKey !== undefined && CREDENTIAL_KEY_RE.test(camelKey))))
|
|
213
|
+
continue;
|
|
214
|
+
// PLAN S2.3 (2026-09-16): the value is the first NON-EMPTY line after the
|
|
215
|
+
// bare key. The pass used to look at `lines[i + 1]` ONLY, so one blank
|
|
216
|
+
// line between the key and its value (a wrapped YAML entry, a paste with
|
|
217
|
+
// a stray newline — `api_key:\n\n wJalr…`) abandoned the pairing and the
|
|
218
|
+
// secret crossed verbatim. Blank lines are skipped; the first non-empty
|
|
219
|
+
// line decides: indented → it is the value (mask, with the existing
|
|
220
|
+
// already-`<redacted>` skip and the CRLF-tolerant anchors); dedented → it
|
|
221
|
+
// is the NEXT frontmatter key, not a value (stop, never mask it).
|
|
222
|
+
let valueLine = -1;
|
|
223
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
224
|
+
const candidate = lines[j] ?? '';
|
|
225
|
+
if (candidate.trim() === '')
|
|
226
|
+
continue;
|
|
227
|
+
valueLine = j;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
if (valueLine < 0)
|
|
231
|
+
continue;
|
|
232
|
+
const next = lines[valueLine] ?? '';
|
|
233
|
+
// Over-masking an indented line under a credential key is acceptable for a
|
|
234
|
+
// redactor (same policy as P2-7); an unindented line is NOT the value.
|
|
235
|
+
// OPT-03: `(?:\r)?$` — see BLOCK_KEY_ONLY_LINE above; without it a CRLF
|
|
236
|
+
// value line never matched and the secret crossed unredacted.
|
|
237
|
+
const [, indent, value, tail] = /^([ \t]+)(\S.*?)([ \t]*)(?:\r)?$/.exec(next) ?? [];
|
|
238
|
+
if (indent === undefined || value === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
if (value.includes('<redacted>'))
|
|
241
|
+
continue;
|
|
242
|
+
lines[valueLine] = `${indent}<redacted>${tail ?? ''}`;
|
|
243
|
+
}
|
|
244
|
+
out = lines.join('\n');
|
|
245
|
+
// v22 (SEC-4): an AWS SECRET access key is a bare 40-char base64ish run —
|
|
246
|
+
// far too common in legitimate text to mask unconditionally. It is masked
|
|
247
|
+
// only on a line that already shows a redaction or an aws/secret keyword
|
|
248
|
+
// (the paired access key id was just masked there), closing the
|
|
249
|
+
// id-redacted-but-secret-residual reassembly gap.
|
|
250
|
+
out = out.split('\n').map((line) => {
|
|
251
|
+
if (!/<redacted>/.test(line) && !/\baws\b|\bAKIA\b|\bsecret\b/i.test(line))
|
|
252
|
+
return line;
|
|
253
|
+
return line.replace(/(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g, '<redacted>');
|
|
254
|
+
}).join('\n');
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference re-homing plans (0.5.0 V1, design §16.7).
|
|
3
|
+
*
|
|
4
|
+
* A consolidation moves one skill's body under ANOTHER skill root, where every
|
|
5
|
+
* support-dir reference resolves against that other root (design §2.1). This
|
|
6
|
+
* module plans what has to happen to the support files and to the body's
|
|
7
|
+
* references for the move to leave no dangling link. Pure: the caller owns IO,
|
|
8
|
+
* decides the destination names, and decides whether the plan is applied (V2)
|
|
9
|
+
* or only reported (V1). Cross-skill rewriting is out of scope by design (§14) —
|
|
10
|
+
* a plan covers ONE body.
|
|
11
|
+
*/
|
|
12
|
+
import { resolveCitations } from "./citations.js";
|
|
13
|
+
/**
|
|
14
|
+
* Provisional re-homing rule. The naming style for re-homed files is still an
|
|
15
|
+
* open decision (design §16.3 B5); this rule only fires on a COLLISION, where
|
|
16
|
+
* something has to give: a path that the target already occupies gets the source
|
|
17
|
+
* name as a prefix (`references/a.md` -> `references/<source>-a.md`). Nested
|
|
18
|
+
* destinations are deliberately avoided: the support listing is one level deep,
|
|
19
|
+
* so a file moved into a subdirectory would drop out of the listing entirely.
|
|
20
|
+
* @param sourceFiles - the moving skill's support files.
|
|
21
|
+
* @param targetFiles - the destination skill's support files.
|
|
22
|
+
* @param sourceName - the moving skill's name, used as the collision prefix.
|
|
23
|
+
* @returns the moves plus the paths that collided (for the report).
|
|
24
|
+
*/
|
|
25
|
+
export function planRehoming(sourceFiles, targetFiles, sourceName) {
|
|
26
|
+
const taken = new Set(targetFiles);
|
|
27
|
+
const collisions = [];
|
|
28
|
+
const moves = [];
|
|
29
|
+
for (const path of sourceFiles) {
|
|
30
|
+
if (!taken.has(path)) {
|
|
31
|
+
moves.push({ from: path, to: path });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
collisions.push(path);
|
|
35
|
+
const cut = path.lastIndexOf('/');
|
|
36
|
+
const dir = cut < 0 ? '' : path.slice(0, cut + 1);
|
|
37
|
+
const name = path.slice(cut + 1);
|
|
38
|
+
let candidate = `${dir}${sourceName}-${name}`;
|
|
39
|
+
let counter = 2;
|
|
40
|
+
while (taken.has(candidate)) {
|
|
41
|
+
candidate = `${dir}${sourceName}-${counter}-${name}`;
|
|
42
|
+
counter += 1;
|
|
43
|
+
}
|
|
44
|
+
taken.add(candidate);
|
|
45
|
+
moves.push({ from: path, to: candidate });
|
|
46
|
+
}
|
|
47
|
+
return { moves, collisions };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Plan the reference rewrites for one body that is moving into another root.
|
|
51
|
+
* @param input - the body, the MOVING skill's file list, the re-homing moves and
|
|
52
|
+
* the destination's file list (with the moved paths already included).
|
|
53
|
+
* @returns the edits, the cited files without a destination, and any target that
|
|
54
|
+
* the destination file list cannot prove (applying such a plan would dangle).
|
|
55
|
+
*/
|
|
56
|
+
export function planReferenceRewrite(input) {
|
|
57
|
+
const report = resolveCitations({ content: input.content, file: 'SKILL.md', files: input.files });
|
|
58
|
+
const byFrom = new Map(input.moves.map(move => [move.from, move.to]));
|
|
59
|
+
const available = new Set(input.targetFiles);
|
|
60
|
+
const edits = [];
|
|
61
|
+
const unresolved = [];
|
|
62
|
+
const residual = new Set();
|
|
63
|
+
const used = new Set();
|
|
64
|
+
for (const ref of report.refs) {
|
|
65
|
+
if (ref.kind !== 'citation' || ref.target === null)
|
|
66
|
+
continue;
|
|
67
|
+
const move = byFrom.get(ref.target);
|
|
68
|
+
const to = move ?? ref.target;
|
|
69
|
+
if (move !== undefined) {
|
|
70
|
+
used.add(ref.target);
|
|
71
|
+
if (move !== ref.target)
|
|
72
|
+
edits.push({ line: ref.line, from: ref.target, to: move, raw: ref.raw });
|
|
73
|
+
}
|
|
74
|
+
else if (!report.dangling.some(entry => entry.target === ref.target)) {
|
|
75
|
+
// Already resolvable in the MOVING skill: it only stays valid if the
|
|
76
|
+
// destination happens to carry the same path, which the caller proves by
|
|
77
|
+
// listing it under `targetFiles`.
|
|
78
|
+
unresolved.push(ref.target);
|
|
79
|
+
}
|
|
80
|
+
if (!available.has(to))
|
|
81
|
+
residual.add(to);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
edits,
|
|
85
|
+
unresolved: [...new Set(unresolved)],
|
|
86
|
+
residualDangling: [...residual],
|
|
87
|
+
moves: input.moves.filter(move => used.has(move.from)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Apply a plan's edits to the body (V2). Line-wise and literal: line N gets its
|
|
92
|
+
* FIRST occurrence of the recorded token replaced, everything else is copied
|
|
93
|
+
* byte for byte — no regex, so a token that happens to contain regex
|
|
94
|
+
* metacharacters cannot rewrite more than it matched.
|
|
95
|
+
* @param content - the body the plan was computed against.
|
|
96
|
+
* @param edits - the plan's edits.
|
|
97
|
+
* @returns the rewritten body (identical to the input when there is nothing to do).
|
|
98
|
+
*/
|
|
99
|
+
export function applyReferenceRewrite(content, edits) {
|
|
100
|
+
if (edits.length === 0)
|
|
101
|
+
return content;
|
|
102
|
+
const byLine = new Map();
|
|
103
|
+
for (const edit of edits) {
|
|
104
|
+
const list = byLine.get(edit.line);
|
|
105
|
+
if (list === undefined)
|
|
106
|
+
byLine.set(edit.line, [edit]);
|
|
107
|
+
else
|
|
108
|
+
list.push(edit);
|
|
109
|
+
}
|
|
110
|
+
const lines = content.split('\n');
|
|
111
|
+
for (const [lineNo, lineEdits] of byLine) {
|
|
112
|
+
const index = lineNo - 1;
|
|
113
|
+
const original = lines[index];
|
|
114
|
+
if (original === undefined)
|
|
115
|
+
continue;
|
|
116
|
+
let next = original;
|
|
117
|
+
for (const edit of lineEdits) {
|
|
118
|
+
const at = next.indexOf(edit.raw);
|
|
119
|
+
if (at < 0)
|
|
120
|
+
continue;
|
|
121
|
+
next = next.slice(0, at) + edit.to + next.slice(at + edit.raw.length);
|
|
122
|
+
}
|
|
123
|
+
lines[index] = next;
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* One-line, model-facing summary of a plan, for the consolidation refusal and
|
|
129
|
+
* for the V2 apply path's audit text.
|
|
130
|
+
* @param plan - the plan to describe.
|
|
131
|
+
* @returns a compact sentence naming counts, the moves and any residual risk.
|
|
132
|
+
*/
|
|
133
|
+
export function describeReferenceRewrite(plan) {
|
|
134
|
+
const renames = plan.moves.filter(move => move.from !== move.to);
|
|
135
|
+
const parts = [
|
|
136
|
+
`re-home ${plan.moves.length} file(s)`,
|
|
137
|
+
renames.length === 0 ? '' : `renamed: ${renames.map(move => `${move.from}->${move.to}`).join(', ')}`,
|
|
138
|
+
`rewrite ${plan.edits.length} reference(s)`,
|
|
139
|
+
`residual dangling ${plan.residualDangling.length}`,
|
|
140
|
+
plan.unresolved.length === 0 ? '' : `no destination: ${plan.unresolved.slice(0, 5).join(', ')}`,
|
|
141
|
+
].filter(part => part.length > 0);
|
|
142
|
+
return `plan: ${parts.join('; ')}`;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=reference-rewrite.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.19.0 (S2): the family's ONE report-retention sweep.
|
|
3
|
+
*
|
|
4
|
+
* Reports accumulate by design (every run writes one), so each kind needs a
|
|
5
|
+
* window or the reports home becomes an unbounded directory. The logic lived
|
|
6
|
+
* inside the curator only; the maintenance run reports added in 0.19.0 needed
|
|
7
|
+
* the same thing, and a second copy of "list, order, delete beyond the window"
|
|
8
|
+
* is exactly the kind of near-duplicate this family treats as a defect.
|
|
9
|
+
*
|
|
10
|
+
* Three behaviours are the curator's, kept verbatim because they are the reason
|
|
11
|
+
* the sweep is safe to run at all:
|
|
12
|
+
*
|
|
13
|
+
* - An UNLISTABLE directory is not "no reports" — nothing is deleted and the
|
|
14
|
+
* caller is told (the same three-state rule the probe files use).
|
|
15
|
+
* - A report with no usable timestamp is KEPT (never delete what cannot be
|
|
16
|
+
* ordered) and the fact is said out loud once.
|
|
17
|
+
* - The order is TOTAL: declared time, then mtime, then name. A tie falling
|
|
18
|
+
* through to readdir order evicted a different set per platform (CI-caught).
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { isPresent, isUnknown, probeList, probeMtime, reportTime } from "./probe.js";
|
|
23
|
+
// `evolutionHome` is owned by state-store.ts (its ONLY definition) — importing it
|
|
24
|
+
// from env.ts yields an undefined binding that only throws at call time.
|
|
25
|
+
import { evolutionHome } from "./state-store.js";
|
|
26
|
+
/**
|
|
27
|
+
* The lock target a reports sweep holds for one home.
|
|
28
|
+
*
|
|
29
|
+
* `transactIo` takes the IO write lock on THIS path (minting `<path>.lock` with
|
|
30
|
+
* the io protocol's `pid:token` body) and holds it for the whole task; nothing
|
|
31
|
+
* ever writes the target, because the sweep's exclusion is the point. It lives
|
|
32
|
+
* inside `<home>/reports` next to the directory it protects (the sweeps' own name
|
|
33
|
+
* filters ignore it, and the lock file is removed on release).
|
|
34
|
+
*
|
|
35
|
+
* Moved here from evolution-curator in 0.19.0 (S2): the maintenance reports'
|
|
36
|
+
* sweep needs the SAME exclusion, and a second lock would protect nothing.
|
|
37
|
+
* @returns the lock target path.
|
|
38
|
+
*/
|
|
39
|
+
export function reportsSweepLockTarget() {
|
|
40
|
+
return join(evolutionHome(), 'reports', '.retention');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Delete the reports beyond each bucket's window, each with its `.md` digest.
|
|
44
|
+
*
|
|
45
|
+
* Best-effort by construction: pruning never fails the run that wrote a report.
|
|
46
|
+
* @param options - io seam, directory, windows, warn channel and owner name.
|
|
47
|
+
* @returns nothing; deletions and skips are reported through `warn`.
|
|
48
|
+
*/
|
|
49
|
+
export async function sweepReports(options) {
|
|
50
|
+
const warn = options.warn ?? (() => { });
|
|
51
|
+
const listed = await probeList(options.io, options.dir);
|
|
52
|
+
if (isUnknown(listed)) {
|
|
53
|
+
warn(`${options.owner}: report retention skipped — the reports directory could not be listed (${listed.reason})`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const entries = isPresent(listed) ? listed.value : [];
|
|
57
|
+
// Longest prefix first: `curator-error-` must win over `curator-`.
|
|
58
|
+
const buckets = [...options.buckets].sort((a, b) => b.prefix.length - a.prefix.length);
|
|
59
|
+
const dated = new Map(buckets.map(bucket => [bucket.prefix, []]));
|
|
60
|
+
let unorderableWarned = false;
|
|
61
|
+
for (const name of entries) {
|
|
62
|
+
if (!name.endsWith('.json'))
|
|
63
|
+
continue;
|
|
64
|
+
const bucket = buckets.find(item => name.startsWith(item.prefix));
|
|
65
|
+
if (!bucket)
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const path = join(options.dir, name);
|
|
69
|
+
const raw = await options.io.readText(path);
|
|
70
|
+
if (raw === null)
|
|
71
|
+
continue;
|
|
72
|
+
// The ordering time comes from ONE place (`reportTime`): the declared
|
|
73
|
+
// `startedAt`, then the declared `at`, then the optional mtime probe.
|
|
74
|
+
const startedAt = await reportTime(options.io, path, JSON.parse(raw));
|
|
75
|
+
const probed = await probeMtime(options.io, path);
|
|
76
|
+
const mtime = isPresent(probed) ? probed.value : null;
|
|
77
|
+
if (startedAt !== null)
|
|
78
|
+
dated.get(bucket.prefix)?.push({ name, startedAt, mtime });
|
|
79
|
+
else if (!unorderableWarned) {
|
|
80
|
+
unorderableWarned = true;
|
|
81
|
+
warn(`${options.owner}: report "${name}" carries no usable timestamp and this backend has no mtime probe - it is kept outside the retention window`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Unclassifiable report: keep it — never delete what we cannot order.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// TOTAL order (v46 review, CI-caught): declared time, then mtime, then name.
|
|
89
|
+
const newestFirst = (a, b) => b.startedAt - a.startedAt || (b.mtime ?? 0) - (a.mtime ?? 0) || b.name.localeCompare(a.name);
|
|
90
|
+
for (const bucket of buckets) {
|
|
91
|
+
const rows = dated.get(bucket.prefix) ?? [];
|
|
92
|
+
rows.sort(newestFirst);
|
|
93
|
+
for (const old of rows.slice(bucket.keep)) {
|
|
94
|
+
const stem = old.name.replace(/\.json$/, '');
|
|
95
|
+
try {
|
|
96
|
+
await options.io.remove(join(options.dir, old.name));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Best-effort pruning.
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await options.io.remove(join(options.dir, `${stem}.md`));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// The digest may already be gone (or never existed); keep going.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=reports.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
export declare const name = "evolution-restrictor";
|
|
3
|
+
export declare const inject: readonly ["agentPresets"];
|
|
4
|
+
export interface Config {
|
|
5
|
+
readonly allow?: readonly string[];
|
|
6
|
+
readonly deny?: readonly string[];
|
|
7
|
+
/** Restricted preset id this row belongs to; only agents composed with it are restricted. */
|
|
8
|
+
readonly presetId?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
11
|
+
//# sourceMappingURL=restrictor.d.ts.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { errorText } from "./errors.js";
|
|
2
|
+
export const name = 'evolution-restrictor';
|
|
3
|
+
export const inject = ['agentPresets'];
|
|
4
|
+
function normalize(names) {
|
|
5
|
+
return [...new Set((names ?? []).map(value => value.trim()).filter(value => value !== ''))].sort();
|
|
6
|
+
}
|
|
7
|
+
function failure(detail) {
|
|
8
|
+
return new Error(errorText('e-324-restricted-preset-unavailable', { a1: 'evolution restrictor ' + detail }));
|
|
9
|
+
}
|
|
10
|
+
export function apply(ctx, config = {}) {
|
|
11
|
+
const allow = normalize(config.allow);
|
|
12
|
+
const deny = normalize(config.deny);
|
|
13
|
+
if (allow.length === 0 && deny.length === 0)
|
|
14
|
+
throw failure('needs a non-empty allow or deny set');
|
|
15
|
+
const filter = {
|
|
16
|
+
...(allow.length > 0 ? { allow } : {}),
|
|
17
|
+
...(deny.length > 0 ? { deny } : {}),
|
|
18
|
+
};
|
|
19
|
+
const presetId = typeof config.presetId === 'string' && config.presetId.trim() !== '' ? config.presetId : undefined;
|
|
20
|
+
if (presetId === undefined)
|
|
21
|
+
throw failure('requires the restricted presetId in its row config');
|
|
22
|
+
const on = ctx.on;
|
|
23
|
+
if (typeof on !== 'function')
|
|
24
|
+
throw failure('requires a context that owns on()');
|
|
25
|
+
const presets = ctx.agentPresets;
|
|
26
|
+
if (presets === undefined || typeof presets.composedPreset !== 'function') {
|
|
27
|
+
throw failure('requires the agentPresets service (inject: agentPresets)');
|
|
28
|
+
}
|
|
29
|
+
// The listener lives in the preset MOUNT scope: scope routing delivers every descendant
|
|
30
|
+
// child's events here (a listener owned by an enclosing scope sees descendant events), and
|
|
31
|
+
// `composedPreset` narrows it to the agents actually composed with this restricted preset.
|
|
32
|
+
on.call(ctx, 'agent/created', (payload) => {
|
|
33
|
+
const agent = payload.agent;
|
|
34
|
+
if (agent === undefined)
|
|
35
|
+
return;
|
|
36
|
+
if (presets.composedPreset(agent.ctx) !== presetId)
|
|
37
|
+
return;
|
|
38
|
+
const tools = agent.ctx.tools;
|
|
39
|
+
if (tools === undefined || typeof tools.restrict !== 'function') {
|
|
40
|
+
throw failure('child scope exposes no tools.restrict()');
|
|
41
|
+
}
|
|
42
|
+
const effect = agent.ctx.effect;
|
|
43
|
+
if (effect === undefined)
|
|
44
|
+
throw failure('child scope owns no effect() to release the restriction');
|
|
45
|
+
effect.call(agent.ctx, () => tools.restrict(filter), 'evolution-restrictor:child');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=restrictor.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Family-internal mark for the review channel's INJECT delivery (v37 S2.2).
|
|
3
|
+
*
|
|
4
|
+
* With the default `reviewMode: 'inject'` the review prompt runs in the PARENT
|
|
5
|
+
* session, so the parent model's own `skill_manage` calls carry no origin the
|
|
6
|
+
* platform could attribute: `.pinned` and the `.hermes-managed` authorship mark
|
|
7
|
+
* were skipped for exactly the autonomous writes they exist for. The delivery
|
|
8
|
+
* marks the session here and the write path reads the mark.
|
|
9
|
+
*
|
|
10
|
+
* Window: after the review prompt, until the next REAL user message (a
|
|
11
|
+
* `user/message` with `source.kind === 'user'` clears it; plugin-sourced notices
|
|
12
|
+
* never do). Process-wide and keyed by session on purpose — the same
|
|
13
|
+
* "restart is a fresh conversation boundary" discipline as the review plugin's
|
|
14
|
+
* own per-session counters. This module is the ONE owner of the marker.
|
|
15
|
+
* @module @lmzhen/dsh-evolution-core/review-channel
|
|
16
|
+
*/
|
|
17
|
+
/** Session ids currently executing a review prompt, oldest delivery first. */
|
|
18
|
+
const markedSessions = new Set();
|
|
19
|
+
/**
|
|
20
|
+
* Mark one session as running the review channel's prompt; idempotent, and
|
|
21
|
+
* keyed by session so a mark can never leak into another session.
|
|
22
|
+
* @param sessionId - the session the review prompt was delivered to.
|
|
23
|
+
*/
|
|
24
|
+
export function markReviewChannel(sessionId) {
|
|
25
|
+
markedSessions.delete(sessionId);
|
|
26
|
+
markedSessions.add(sessionId);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Clear the mark: the session's next prompt is human input again.
|
|
30
|
+
* @param sessionId - the session whose mark is dropped.
|
|
31
|
+
*/
|
|
32
|
+
export function clearReviewChannel(sessionId) {
|
|
33
|
+
markedSessions.delete(sessionId);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether this session's current prompt came from the review channel. An
|
|
37
|
+
* execution without a session is never the review channel.
|
|
38
|
+
* @param sessionId - the executing session id, when the caller has one.
|
|
39
|
+
* @returns true only for a marked session.
|
|
40
|
+
*/
|
|
41
|
+
export function isReviewChannelSession(sessionId) {
|
|
42
|
+
return sessionId !== undefined && markedSessions.has(sessionId);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Drop marks whose session is gone — a dead session cannot execute a write.
|
|
46
|
+
* @param isAlive - liveness probe for one session id.
|
|
47
|
+
* @returns the number of removed marks.
|
|
48
|
+
*/
|
|
49
|
+
export function sweepReviewChannelSessions(isAlive) {
|
|
50
|
+
let removed = 0;
|
|
51
|
+
for (const sessionId of [...markedSessions]) {
|
|
52
|
+
if (isAlive(sessionId))
|
|
53
|
+
continue;
|
|
54
|
+
markedSessions.delete(sessionId);
|
|
55
|
+
removed += 1;
|
|
56
|
+
}
|
|
57
|
+
return removed;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=review-channel.js.map
|