@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,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-evolution event log (rc.68): an append-only sidecar under
|
|
3
|
+
* `$DSH_HOME/evolution/events.json` that is the single source of truth for
|
|
4
|
+
* the self-improvement loop. Feedback increments and learn actions share one
|
|
5
|
+
* ordered timeline (`seq` is the ordering key), so "feedback before/after a
|
|
6
|
+
* learn on target X" is answerable. The aggregate `feedback.json` is a
|
|
7
|
+
* rebuildable boot cache, never the truth.
|
|
8
|
+
*
|
|
9
|
+
* Usage events (C semantics, rc.73+): `type:'usage'` records are the
|
|
10
|
+
* OBSERVATION WINDOW ANCHOR — written once, when the library's first observed
|
|
11
|
+
* read (`view_count` 0 -> 1) happens. The anchor is the durable timeline record
|
|
12
|
+
* of that moment: the churn-suppression gate itself (`usageObserved()`) reads
|
|
13
|
+
* the usage SIDECAR's own first-view evidence, since reads were invisible to it
|
|
14
|
+
* pre-A2 and no sidecar record can reach `view_count > 0` without the same
|
|
15
|
+
* 0 -> 1 transition. `counts` on the event is a cumulative library-wide
|
|
16
|
+
* snapshot (skills/views/use/patches) at that moment, and `window.opened` pins
|
|
17
|
+
* the window start for the timeline. (V27 G3.3: `verify-event-pairing` requires
|
|
18
|
+
* every persisted type to have a production reader or a declared external
|
|
19
|
+
* contract — this one is the latter.)
|
|
20
|
+
*
|
|
21
|
+
* Rotation (rc.71, 007 design): when the active log reaches
|
|
22
|
+
* `EVENT_LOG_ROTATE_AT` the older half is split into an archive
|
|
23
|
+
* (`events-<lastArchivedSeq>.json`); the boot timeline merges active +
|
|
24
|
+
* archives and dedupes by seq (active copy wins), so the rotation crash window
|
|
25
|
+
* yields the identical timeline. Archival naming is STRICTLY numeric
|
|
26
|
+
* (`/^events-\d+\.json$/`) — user files under the same directory are never
|
|
27
|
+
* read as archives and never pruned (rc.72 G-2).
|
|
28
|
+
*/
|
|
29
|
+
import { transactIo } from "./io.js";
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
export const EVENT_LOG_VERSION = 1;
|
|
32
|
+
/** Active-log split point (rc.71): when the active log reaches this many events
|
|
33
|
+
* the older half is rotated into an archive; the active stays bounded so a
|
|
34
|
+
* single append stays O(active) instead of O(total-history). Tunable default —
|
|
35
|
+
* callers may override per append (the tests use small values). */
|
|
36
|
+
export const EVENT_LOG_ROTATE_AT = 4000;
|
|
37
|
+
/** Number of archives retained (rc.71): older archives are pruned at rotation,
|
|
38
|
+
* mirroring retainReports. The horizon covers the loop-analysis window. */
|
|
39
|
+
export const EVENT_LOG_RETAIN_ARCHIVES = 10;
|
|
40
|
+
/** Archive file prefix: `events-<lastArchivedSeq>.json`. The active file is
|
|
41
|
+
* `events.json` and never matches this glob. */
|
|
42
|
+
export const EVENT_ARCHIVE_PREFIX = 'events-';
|
|
43
|
+
/** Archive naming is strictly numeric: a user file such as `events-backup.json`
|
|
44
|
+
* under the same directory is neither read into the timeline nor pruned. */
|
|
45
|
+
const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
|
|
46
|
+
export function eventsFile(home) {
|
|
47
|
+
return join(home, 'evolution', 'events.json');
|
|
48
|
+
}
|
|
49
|
+
/** I-5 (v18): one lightweight description of the durable event payload
|
|
50
|
+
* contract. The log is a FILE boundary (a host, a script or an older version
|
|
51
|
+
* can write it), so `appendEvolutionEvent` refuses a record no consumer can
|
|
52
|
+
* fold instead of persisting it and failing silently later. The process event
|
|
53
|
+
* bus stays unvalidated — that is a typed same-process boundary.
|
|
54
|
+
* @param event - the candidate event record.
|
|
55
|
+
* @returns a human-readable issue, or null when the record is well-formed.
|
|
56
|
+
*/
|
|
57
|
+
export function evolutionEventPayloadIssue(event) {
|
|
58
|
+
const type = event.type;
|
|
59
|
+
// `type` is `unknown` on purpose: at this FILE boundary the static union is
|
|
60
|
+
// not a guarantee, so a non-member (or non-string) is reported.
|
|
61
|
+
if (typeof type !== 'string')
|
|
62
|
+
return `unknown event type "${String(type)}"`;
|
|
63
|
+
switch (type) {
|
|
64
|
+
case 'feedback':
|
|
65
|
+
if (event.kind !== 'skill' && event.kind !== 'session')
|
|
66
|
+
return 'feedback event requires kind skill|session';
|
|
67
|
+
if (event.rating !== 'positive' && event.rating !== 'negative')
|
|
68
|
+
return 'feedback event requires rating positive|negative';
|
|
69
|
+
// P2-10 (v37): target is the fold key — the only folder skips a
|
|
70
|
+
// target-less feedback silently and `target: ''` folds a phantom key, so
|
|
71
|
+
// neither may pass this durable boundary.
|
|
72
|
+
if (typeof event.target !== 'string' || event.target.trim() === '')
|
|
73
|
+
return 'feedback event requires a non-empty target';
|
|
74
|
+
return null;
|
|
75
|
+
case 'maintain':
|
|
76
|
+
return typeof event.runId === 'string' ? null : 'maintain event requires runId';
|
|
77
|
+
case 'learn':
|
|
78
|
+
case 'usage':
|
|
79
|
+
return null;
|
|
80
|
+
default:
|
|
81
|
+
// Unreachable for the declared union; reachable when a JS host writes
|
|
82
|
+
// the file. The cast restores the runtime value the union erased.
|
|
83
|
+
return `unknown event type "${type}"`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function isEventRecord(event) {
|
|
87
|
+
// C-05: NaN passed the bare typeof check — a NaN seq became a Map
|
|
88
|
+
// key that never matches and a sort comparator that never orders. Only a
|
|
89
|
+
// finite number is a seq.
|
|
90
|
+
const seq = event?.seq;
|
|
91
|
+
return typeof event === 'object' && event !== null && typeof seq === 'number' && Number.isFinite(seq);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse an event log body. A missing file, a whitespace-only file (rc.69:
|
|
95
|
+
* rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
|
|
96
|
+
* is still refused on append, never overwritten.
|
|
97
|
+
*
|
|
98
|
+
* This reader is **v1-only** (F-338): a body carrying a `version` other than
|
|
99
|
+
* `EVENT_LOG_VERSION` is a future-format log this reader cannot interpret, so
|
|
100
|
+
* it reads as an EMPTY timeline rather than being mis-parsed as v1. The read
|
|
101
|
+
* side never overwrites it on its own — `appendEvolutionEvent` rejects a
|
|
102
|
+
* version mismatch up front and preserves the original bytes, so a newer log
|
|
103
|
+
* is never silently downgraded here.
|
|
104
|
+
*
|
|
105
|
+
* Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
|
|
106
|
+
* skipped here and dropped at the next append — valid entries survive, the
|
|
107
|
+
* damaged record is the only loss (self-heal semantics, matching the usage
|
|
108
|
+
* sidecar's per-field normalization on read).
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* Post-parse v1 gate shared by parseEvolutionEvents and appendEvolutionEvent
|
|
112
|
+
* (C-06, v10 audit: the append used to JSON.parse the same body a second time
|
|
113
|
+
* through parseEvolutionEvents — double parse cost per append). A null parse
|
|
114
|
+
* (missing/whitespace/unparsable body) reads as an empty timeline.
|
|
115
|
+
*/
|
|
116
|
+
function v1EventRecords(parsed) {
|
|
117
|
+
if (parsed === null)
|
|
118
|
+
return [];
|
|
119
|
+
// v1-only reader: an explicit non-current version is a future format (or a
|
|
120
|
+
// corrupt version field) and must not be shaped as v1. A missing `version`
|
|
121
|
+
// is tolerated as legacy v1.
|
|
122
|
+
if (parsed.version !== undefined && parsed.version !== EVENT_LOG_VERSION)
|
|
123
|
+
return [];
|
|
124
|
+
if (!Array.isArray(parsed.events))
|
|
125
|
+
return [];
|
|
126
|
+
return parsed.events.filter(isEventRecord);
|
|
127
|
+
}
|
|
128
|
+
export function parseEvolutionEvents(raw) {
|
|
129
|
+
if (raw === null || raw.trim() === '')
|
|
130
|
+
return [];
|
|
131
|
+
try {
|
|
132
|
+
return v1EventRecords(JSON.parse(raw));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* List the numeric archives under the log's directory, sorted ascending by
|
|
140
|
+
* their last-archived seq. Single glob predicate for the timeline, the
|
|
141
|
+
* retention pass and the feedback migration check (rc.72 H-3).
|
|
142
|
+
*/
|
|
143
|
+
export async function listEventArchives(io, path) {
|
|
144
|
+
const dir = dirname(path);
|
|
145
|
+
const names = await io.list(dir);
|
|
146
|
+
return names
|
|
147
|
+
.filter(name => EVENT_ARCHIVE_RE.test(name))
|
|
148
|
+
.sort((a, b) => {
|
|
149
|
+
const sa = Number.parseInt(a.slice(EVENT_ARCHIVE_PREFIX.length, a.length - 5), 10);
|
|
150
|
+
const sb = Number.parseInt(b.slice(EVENT_ARCHIVE_PREFIX.length, b.length - 5), 10);
|
|
151
|
+
return sa - sb;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Append one event under the write lock (rc.68): `seq` = current max + 1
|
|
156
|
+
* computed inside the transact, so two processes appending concurrently never
|
|
157
|
+
* collide. A malformed log is refused (bytes preserved) and the append fails.
|
|
158
|
+
* Returns the assigned seq.
|
|
159
|
+
*
|
|
160
|
+
* Rotation (rc.71, 007 design): when the active log reaches `rotateAt`, the
|
|
161
|
+
* older half is copied into an archive inside the SAME transact (the archive
|
|
162
|
+
* path has its own lock, so no recursion) and the active is replaced with the
|
|
163
|
+
* newer half + the new event. seqs stay globally monotonic; a crash between
|
|
164
|
+
* archive write and active write leaves both copies, which the timeline merge
|
|
165
|
+
* dedupes by seq. An archive-write failure aborts the append (active keeps the
|
|
166
|
+
* full old content — no loss) and the caller's best-effort handling applies.
|
|
167
|
+
*
|
|
168
|
+
* rc.72 G-1: when the ACTIVE is missing/whitespace but archives exist (a
|
|
169
|
+
* deleted active, or B-2 self-heal), seq derivation consults the archive names
|
|
170
|
+
* — the active restarts AFTER the highest archived seq, never at 1, so a new
|
|
171
|
+
* event can never shadow an archived one in the seq-deduped timeline.
|
|
172
|
+
*/
|
|
173
|
+
export async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
|
|
174
|
+
// I-5 (v18): refuse an unfoldable record at the durable write boundary.
|
|
175
|
+
const issue = evolutionEventPayloadIssue(event);
|
|
176
|
+
if (issue !== null)
|
|
177
|
+
throw new Error(`evolution event refused: ${issue}`);
|
|
178
|
+
let assigned = 0;
|
|
179
|
+
// Empty when the append was refused as malformed; set when it was refused
|
|
180
|
+
// because the log carries a future `version` (F-338). Kept as a prebuilt
|
|
181
|
+
// message so a closure-side assignment is never narrowed to `never` by the
|
|
182
|
+
// outer control flow.
|
|
183
|
+
let refuseMessage = '';
|
|
184
|
+
// C-06 (v10 audit): the shape parse below is REUSED for the event list via
|
|
185
|
+
// v1EventRecords — the body used to be JSON.parsed twice per append.
|
|
186
|
+
let parsedBody = null;
|
|
187
|
+
await transactIo(io, path, async (current) => {
|
|
188
|
+
// A10 (audit P2-14): the lock layer is contractually allowed to re-invoke
|
|
189
|
+
// this task whole (a LostWriteLock converts into a full read-modify-write
|
|
190
|
+
// retry). Closure state captured during a PREVIOUS attempt must reset
|
|
191
|
+
// first, or a log that vanished/was emptied between attempts would skip
|
|
192
|
+
// the re-parse below and fold rotation from the stale previous parse.
|
|
193
|
+
// `assigned` belongs to the same discipline (R2 follow-up): without the
|
|
194
|
+
// reset, attempt 1's seq survives into a refusing attempt 2 and the
|
|
195
|
+
// caller reads a phantom success for an event that never landed.
|
|
196
|
+
parsedBody = null;
|
|
197
|
+
refuseMessage = '';
|
|
198
|
+
assigned = 0;
|
|
199
|
+
// rc.69: a whitespace-only log (crash residue) is rebuildable — treat it
|
|
200
|
+
// as missing; a genuinely corrupt body is still refused.
|
|
201
|
+
if (current !== null && current.trim() !== '') {
|
|
202
|
+
let shape;
|
|
203
|
+
try {
|
|
204
|
+
shape = JSON.parse(current);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return current;
|
|
208
|
+
}
|
|
209
|
+
parsedBody = shape;
|
|
210
|
+
// F-338: a non-v1 body is a FUTURE format. This v1 writer must never
|
|
211
|
+
// rewrite it back down to v1 — refuse and keep the original bytes. (The
|
|
212
|
+
// reader treats it as an empty timeline; only the append refuses.)
|
|
213
|
+
if (shape.version !== undefined && shape.version !== EVENT_LOG_VERSION) {
|
|
214
|
+
const found = typeof shape.version === 'number' || typeof shape.version === 'string' ? String(shape.version) : 'unknown';
|
|
215
|
+
const kind = typeof shape.version === 'number' || typeof shape.version === 'string' ? typeof shape.version : 'unknown';
|
|
216
|
+
// V4-48: a version written as the STRING "1" is statically unequal to
|
|
217
|
+
// the number 1 and is refused, but the old message rendered both as
|
|
218
|
+
// "found 1, expected 1" — type-ambiguous and self-contradictory. Report
|
|
219
|
+
// the type so a manual-editer/heterogeneous writer sees the real cause.
|
|
220
|
+
const rendered = kind === 'string' ? `"${found}"` : found;
|
|
221
|
+
refuseMessage = `evolution event log version mismatch (expected version ${EVENT_LOG_VERSION}, got ${rendered} (${kind})) and was not touched`;
|
|
222
|
+
return current;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const rotated = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
|
|
226
|
+
if (!rotated.ok) {
|
|
227
|
+
// P2-11: an unmergeable archive collision writes NOTHING — the active
|
|
228
|
+
// keeps the full band and the append reports the refusal.
|
|
229
|
+
refuseMessage = rotated.reason;
|
|
230
|
+
return current;
|
|
231
|
+
}
|
|
232
|
+
const events = rotated.events;
|
|
233
|
+
// P2-1 (v11): a single rotate pass — the second call was either a no-op
|
|
234
|
+
// (production threshold) or a double rotation (small test thresholds).
|
|
235
|
+
// v23 (ML-3): seq continues from the max of BOTH the active file and the
|
|
236
|
+
// archive names. The old `if (maxSeq === 0)` gate only consulted the
|
|
237
|
+
// archives when the active was empty — an active ROLLED BACK to an older
|
|
238
|
+
// backup (manual restore) kept a maxSeq below the archived range, so new
|
|
239
|
+
// events reused archived seqs and the timeline merge's "active wins" rule
|
|
240
|
+
// silently shadowed the archived records.
|
|
241
|
+
let maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
|
|
242
|
+
for (const name of await listEventArchives(io, path)) {
|
|
243
|
+
maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(EVENT_ARCHIVE_PREFIX.length, name.length - 5), 10));
|
|
244
|
+
}
|
|
245
|
+
const record = { ...event, seq: maxSeq + 1, at: new Date().toISOString() };
|
|
246
|
+
assigned = record.seq;
|
|
247
|
+
return JSON.stringify({ version: EVENT_LOG_VERSION, events: [...events, record] }, null, 2);
|
|
248
|
+
});
|
|
249
|
+
if (assigned === 0) {
|
|
250
|
+
throw new Error(`${refuseMessage || 'evolution event log is malformed and was not touched'}: ${path}`);
|
|
251
|
+
}
|
|
252
|
+
return assigned;
|
|
253
|
+
}
|
|
254
|
+
/** The collision archive is mergeable only when this v1 writer can read it
|
|
255
|
+
* (P2-11): a foreign `version` (F-338) or a body without an `events` array is
|
|
256
|
+
* refused, so a merge can never downgrade it or empty it out.
|
|
257
|
+
* @param parsed - the parsed collision archive body.
|
|
258
|
+
* @returns the usable events, or null when the archive must not be rewritten. */
|
|
259
|
+
function mergeableCollisionEvents(parsed) {
|
|
260
|
+
if (parsed.version !== undefined && parsed.version !== EVENT_LOG_VERSION)
|
|
261
|
+
return null;
|
|
262
|
+
if (!Array.isArray(parsed.events))
|
|
263
|
+
return null;
|
|
264
|
+
return parsed.events.filter(isEventRecord);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Split the active log at its midpoint when due: the older half is written to
|
|
268
|
+
* `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
|
|
269
|
+
* append so the active is never truncated without its copy), old archives are
|
|
270
|
+
* pruned, and the newer half is returned as the next active body. No-op when
|
|
271
|
+
* under the threshold; `rotateAt < 2` is a guarded no-op (rc.72 G-1: a
|
|
272
|
+
* one-event rotate would archive everything and restart seqs at 1).
|
|
273
|
+
*/
|
|
274
|
+
async function rotateIfDue(io, path, events, rotateAt) {
|
|
275
|
+
// A2-10 (v18): a NaN rotateAt made both comparisons false, so the log
|
|
276
|
+
// rotated on EVERY append. Treat non-finite as "no rotation".
|
|
277
|
+
if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt)
|
|
278
|
+
return { ok: true, events };
|
|
279
|
+
const mid = Math.ceil(events.length / 2);
|
|
280
|
+
const head = events.slice(0, mid);
|
|
281
|
+
const tail = events.slice(mid);
|
|
282
|
+
if (tail.length === 0)
|
|
283
|
+
return { ok: true, events };
|
|
284
|
+
const anchor = tail[0]?.seq ?? 0;
|
|
285
|
+
const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
|
|
286
|
+
// v31 EVENTS-01: after a manual active-file rollback the recomputed archive
|
|
287
|
+
// name can COLLIDE with an existing archive holding a DIFFERENT seq band —
|
|
288
|
+
// the old in-place write destroyed that band silently (feedback/learn/usage
|
|
289
|
+
// history gone while every reader reported a coherent timeline). Merge the
|
|
290
|
+
// two generations instead: both bands survive, overlapping seqs dedupe.
|
|
291
|
+
let archived = head;
|
|
292
|
+
// C-events-dispatch-2 (v43 audit): only a readText of `null` means "that slot
|
|
293
|
+
// is free" — the node backend maps just ENOENT/ENOTDIR to null and lets every
|
|
294
|
+
// other failure (EACCES/EIO/EBUSY) propagate (io.ts readText contract). The
|
|
295
|
+
// old `.catch(() => null)` folded such a failure into "nothing to collide
|
|
296
|
+
// with", so the archive write below destroyed a band that WAS on disk. An
|
|
297
|
+
// unreadable collision archive now refuses the rotation exactly like an
|
|
298
|
+
// unmergeable one (P2-11): the active keeps its full band, the archive keeps
|
|
299
|
+
// its bytes, and no band leaves the logical timeline.
|
|
300
|
+
let existing;
|
|
301
|
+
try {
|
|
302
|
+
existing = await io.readText(archivePath);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
306
|
+
return { ok: false, reason: `evolution event archive collision at ${archivePath} could not be read (${cause}) and was not touched` };
|
|
307
|
+
}
|
|
308
|
+
if (existing !== null) {
|
|
309
|
+
let parsed = null;
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(existing);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
parsed = null;
|
|
315
|
+
}
|
|
316
|
+
if (parsed === null) {
|
|
317
|
+
// An unparsable collision file must not be destroyed: shift this
|
|
318
|
+
// rotation's head aside under a distinct name instead. v31 EVENTS-02:
|
|
319
|
+
// the shift-aside is OBSERVABLE (the band leaves the logical timeline —
|
|
320
|
+
// the collide name matches no archive reader) and PRUNABLE (the old
|
|
321
|
+
// silent form accumulated without bound).
|
|
322
|
+
const shiftPath = `${archivePath}.${Date.now()}.collide`;
|
|
323
|
+
await io.writeText(shiftPath, JSON.stringify({ version: EVENT_LOG_VERSION, events: head }, null, 2));
|
|
324
|
+
console.warn(`evolution-events: rotation hit an unparsable archive collision — the rotated head band was preserved at ${shiftPath} but is OUTSIDE the logical timeline; inspect and merge it manually`);
|
|
325
|
+
await retainEventArchives(io, path);
|
|
326
|
+
await pruneCollideArchives(io, path);
|
|
327
|
+
return { ok: true, events: tail };
|
|
328
|
+
}
|
|
329
|
+
const prior = mergeableCollisionEvents(parsed);
|
|
330
|
+
if (prior === null) {
|
|
331
|
+
// P2-11 (v37): merging REWRITES the collision archive, so an archive this
|
|
332
|
+
// v1 writer cannot read must never be merged — a future version would be
|
|
333
|
+
// downgraded (F-338) and a body without `events` was emptied and
|
|
334
|
+
// destroyed. Refuse the rotation: the active keeps its full band, the
|
|
335
|
+
// archive keeps its bytes, and no band leaves the logical timeline.
|
|
336
|
+
return { ok: false, reason: `evolution event archive collision at ${archivePath} is not a readable version-${EVENT_LOG_VERSION} archive (future version, or no "events" array) and was not touched` };
|
|
337
|
+
}
|
|
338
|
+
// v33 F-3: the merge validates like every reader does - a collision
|
|
339
|
+
// archive with damaged entries must not re-persist junk into the
|
|
340
|
+
// canonical band (isEventRecord is the same filter the timeline uses).
|
|
341
|
+
const bySeq = new Map(prior.map(event => [event.seq, event]));
|
|
342
|
+
for (const event of head)
|
|
343
|
+
bySeq.set(event.seq, event);
|
|
344
|
+
archived = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
345
|
+
}
|
|
346
|
+
await io.writeText(archivePath, JSON.stringify({ version: EVENT_LOG_VERSION, events: archived }, null, 2));
|
|
347
|
+
await retainEventArchives(io, path);
|
|
348
|
+
return { ok: true, events: tail };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
|
|
352
|
+
* The name's numeric part is the last archived seq, so ordering is NUMERIC —
|
|
353
|
+
* lexicographic would rank `events-10` before `events-2`. Only strictly
|
|
354
|
+
* numeric names participate (rc.72 G-2: user files are never deleted).
|
|
355
|
+
* Best-effort per removal; exported for the retention test.
|
|
356
|
+
*/
|
|
357
|
+
export async function retainEventArchives(io, path) {
|
|
358
|
+
const dir = dirname(path);
|
|
359
|
+
const names = await listEventArchives(io, path);
|
|
360
|
+
const excess = names.slice(0, Math.max(0, names.length - EVENT_LOG_RETAIN_ARCHIVES));
|
|
361
|
+
for (const name of excess) {
|
|
362
|
+
await io.remove(join(dir, name)).catch(() => { });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** v31 EVENTS-02: prune shift-aside collision files (`events-*.json.<ts>.collide`)
|
|
366
|
+
* after the same 7-day window the `.corrupt` sweep uses. They are write-once
|
|
367
|
+
* recovery artifacts no reader accepts; without a sweep they accumulated
|
|
368
|
+
* without bound across rollback episodes. No mtime probe → keep (fail-safe). */
|
|
369
|
+
const COLLIDE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
370
|
+
async function pruneCollideArchives(io, path) {
|
|
371
|
+
const dir = dirname(path);
|
|
372
|
+
let names;
|
|
373
|
+
try {
|
|
374
|
+
names = await io.list(dir);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
for (const name of names) {
|
|
381
|
+
if (!name.endsWith('.collide') || !name.startsWith('events-'))
|
|
382
|
+
continue;
|
|
383
|
+
const full = join(dir, name);
|
|
384
|
+
const stamp = name.match(/\.(\d{13})\.collide$/);
|
|
385
|
+
if (stamp && now - Number(stamp[1]) < COLLIDE_AGE_MS)
|
|
386
|
+
continue;
|
|
387
|
+
if (!stamp) {
|
|
388
|
+
// v33 F-2: the no-stamp branch is FAIL-SAFE BY CONTRACT - an unknown
|
|
389
|
+
// collision artifact (no readable mtime, absent mtime probe) is KEPT,
|
|
390
|
+
// never destroyed. A rejecting mtime is contained here so housekeeping
|
|
391
|
+
// cannot abort the event append.
|
|
392
|
+
try {
|
|
393
|
+
const mtime = await io.mtime?.(full);
|
|
394
|
+
// C-events-dispatch-3 (v43 audit): only a NUMBER that is old enough may
|
|
395
|
+
// be deleted. `undefined` (a backend that omits the probe), `null` (a
|
|
396
|
+
// probe that cannot tell, io.ts) and a rejecting probe all mean "age
|
|
397
|
+
// unknown" and KEEP the artifact, exactly as the contract above states —
|
|
398
|
+
// the old branch fell through to `remove` on all three.
|
|
399
|
+
if (typeof mtime !== 'number' || now - mtime < COLLIDE_AGE_MS)
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
await io.remove(full).catch(() => { });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Read the event log; a missing/whitespace-only file reads as empty, corrupt
|
|
410
|
+
* content is flagged (and refused on append). A well-formed future-version body
|
|
411
|
+
* is v1-incompatible: it reads as EMPTY and is now flagged malformed as well
|
|
412
|
+
* (C-events-dispatch-1, v43). F-338's own guarantees are untouched — the reader
|
|
413
|
+
* never mis-shapes a newer format and the append path refuses it up front, so
|
|
414
|
+
* the original bytes survive — while the flag reports what the old reader hid:
|
|
415
|
+
* every record that body holds is dropped from this read. */
|
|
416
|
+
export async function readEvolutionEvents(io, path) {
|
|
417
|
+
let raw;
|
|
418
|
+
try {
|
|
419
|
+
raw = await io.readText(path);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return { events: [], malformed: true };
|
|
423
|
+
}
|
|
424
|
+
if (raw === null || raw.trim() === '')
|
|
425
|
+
return { events: [], malformed: false };
|
|
426
|
+
try {
|
|
427
|
+
const parsed = JSON.parse(raw);
|
|
428
|
+
// v1-only reader (F-338): a non-current `version` is a future format that
|
|
429
|
+
// must not be shaped as v1. It still reads as empty (the append path
|
|
430
|
+
// rejects it before writing, so nothing is overwritten) but it is FLAGGED:
|
|
431
|
+
// the band that body holds leaves the timeline HERE, and a consumer that
|
|
432
|
+
// cannot see the drop folds a truncated history as if it were the truth
|
|
433
|
+
// (C-events-dispatch-1, v43 audit).
|
|
434
|
+
if (parsed.version !== undefined && parsed.version !== EVENT_LOG_VERSION)
|
|
435
|
+
return { events: [], malformed: true };
|
|
436
|
+
// Shape damage is replaceable garbage (read as empty, rebuilt on append);
|
|
437
|
+
// only syntax-level damage is "malformed" (never overwritten).
|
|
438
|
+
if (!Array.isArray(parsed.events))
|
|
439
|
+
return { events: [], malformed: false };
|
|
440
|
+
return {
|
|
441
|
+
events: parsed.events.filter(isEventRecord),
|
|
442
|
+
malformed: false,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return { events: [], malformed: true };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Read the full timeline (rc.71): active log + all archives, merged by seq
|
|
451
|
+
* (active copy wins, duplicates only arise from the rotation crash window),
|
|
452
|
+
* sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
|
|
453
|
+
* flagged ARCHIVE (unreadable, damaged, or a future-version body this reader
|
|
454
|
+
* cannot interpret) is SKIPPED — it never bricks the boot, the returned events
|
|
455
|
+
* simply LACK that seq band, and `malformed` is the only signal that they do
|
|
456
|
+
* (C-events-dispatch-1, v43: the flag is the consumer's contract; a truncated
|
|
457
|
+
* timeline must never be folded back as if it were complete).
|
|
458
|
+
*/
|
|
459
|
+
export async function readEvolutionTimeline(io, path, archives) {
|
|
460
|
+
const dir = dirname(path);
|
|
461
|
+
let malformed = false;
|
|
462
|
+
const bySeq = new Map();
|
|
463
|
+
// C2 (v35): a caller that already listed the archives (the boot restore does)
|
|
464
|
+
// passes them in instead of paying a second directory scan.
|
|
465
|
+
for (const name of archives ?? await listEventArchives(io, path)) {
|
|
466
|
+
const read = await readEvolutionEvents(io, join(dir, name));
|
|
467
|
+
if (read.malformed)
|
|
468
|
+
malformed = true;
|
|
469
|
+
for (const event of read.events)
|
|
470
|
+
bySeq.set(event.seq, event);
|
|
471
|
+
}
|
|
472
|
+
const active = await readEvolutionEvents(io, path);
|
|
473
|
+
if (active.malformed)
|
|
474
|
+
malformed = true;
|
|
475
|
+
for (const event of active.events)
|
|
476
|
+
bySeq.set(event.seq, event);
|
|
477
|
+
return { events: [...bySeq.values()].sort((a, b) => a.seq - b.seq), malformed };
|
|
478
|
+
}
|
|
479
|
+
//# sourceMappingURL=evolution-events.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Untrusted text rendered as prompt lines (T3-03/A31) — ONE folding rule.
|
|
3
|
+
*
|
|
4
|
+
* The background-review digest embeds conversation text and tool output into a
|
|
5
|
+
* plain-text prompt whose PRODUCT is a plan with write permission. Anything the
|
|
6
|
+
* session ingested (a fetched page, a file body, command output) can therefore
|
|
7
|
+
* carry a newline plus a line shaped like the prompt's own structure — a
|
|
8
|
+
* `SYSTEM:`/`Review kind:` header, a `[result]` evidence line, a second copy
|
|
9
|
+
* of the closing instruction — and forge structure or inject instructions.
|
|
10
|
+
*
|
|
11
|
+
* FOLDING, not deleting: the reader (the model) must still SEE what the data
|
|
12
|
+
* said, it just must never become structure. Line separators become a visible
|
|
13
|
+
* `⏎`, and a line that would read as a role/evidence header gets a visible
|
|
14
|
+
* marker in front of it. Every original character stays in place.
|
|
15
|
+
*
|
|
16
|
+
* The boundary is the separator itself, not only a real newline: a payload can
|
|
17
|
+
* paste the separator verbatim, so neutralization matches after `⏎` whether it
|
|
18
|
+
* came from the fold or from the data. Both edits are idempotent (a second pass
|
|
19
|
+
* cannot add a second marker).
|
|
20
|
+
*
|
|
21
|
+
* Scope: text rendered as its OWN line(s) of a prompt. `render-facts`'
|
|
22
|
+
* sanitizer (evolution-maintenance) is the same posture for `key=value` fields
|
|
23
|
+
* and additionally escapes its block delimiter; it is not superseded here.
|
|
24
|
+
* @module @lmzhen/dsh-evolution-core
|
|
25
|
+
*/
|
|
26
|
+
/** Visible stand-in for a folded line separator: one character that cannot be read as line structure. */
|
|
27
|
+
export const UNTRUSTED_LINE_BREAK = ' ⏎ ';
|
|
28
|
+
/** Visible marker prefixed to a line that would otherwise read as a role/structure header. */
|
|
29
|
+
export const UNTRUSTED_LINE_MARK = '· ';
|
|
30
|
+
const NEWLINES = /\r\n|\r|\n/g;
|
|
31
|
+
/** A line that reads as structure: a `Label:` head (SYSTEM:, Review kind:, Signals:) or a digest evidence tag.
|
|
32
|
+
* The head is case-TOLERANT on purpose: this family's own prompt labels are mixed case, so an
|
|
33
|
+
* uppercase-only rule neutralized SYSTEM: while leaving a forged "Review kind:" untouched. */
|
|
34
|
+
const STRUCTURAL_HEAD = /(^|⏎)([ \t]*)((?:[A-Za-z][A-Za-z0-9 _]{2,}:|\[result\]|\[call\]))/g;
|
|
35
|
+
/**
|
|
36
|
+
* Fold untrusted `text` so it cannot become structure, then bound it to `limit`
|
|
37
|
+
* visible characters. A non-positive `limit` means "no truncation".
|
|
38
|
+
*/
|
|
39
|
+
export function foldUntrustedLines(text, limit) {
|
|
40
|
+
const folded = text.replace(NEWLINES, UNTRUSTED_LINE_BREAK);
|
|
41
|
+
const marked = folded.replace(STRUCTURAL_HEAD, (_match, boundary, spaces, head) => `${boundary}${spaces}${UNTRUSTED_LINE_MARK}${head}`);
|
|
42
|
+
return limit > 0 ? marked.slice(0, limit) : marked;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=fold-untrusted.js.map
|