@moxxy/core 0.26.0 → 0.28.0

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.
Files changed (70) hide show
  1. package/dist/index.d.ts +4 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/plugins/host-options.d.ts +2 -0
  6. package/dist/plugins/host-options.d.ts.map +1 -1
  7. package/dist/plugins/host.d.ts +8 -0
  8. package/dist/plugins/host.d.ts.map +1 -1
  9. package/dist/plugins/host.js +14 -0
  10. package/dist/plugins/host.js.map +1 -1
  11. package/dist/plugins/registry-kinds.d.ts +1 -0
  12. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  13. package/dist/plugins/registry-kinds.js +11 -0
  14. package/dist/plugins/registry-kinds.js.map +1 -1
  15. package/dist/registries/modes.d.ts +8 -0
  16. package/dist/registries/modes.d.ts.map +1 -1
  17. package/dist/registries/modes.js +11 -0
  18. package/dist/registries/modes.js.map +1 -1
  19. package/dist/registries/reflectors.d.ts +15 -0
  20. package/dist/registries/reflectors.d.ts.map +1 -0
  21. package/dist/registries/reflectors.js +16 -0
  22. package/dist/registries/reflectors.js.map +1 -0
  23. package/dist/run-turn.d.ts.map +1 -1
  24. package/dist/run-turn.js +5 -0
  25. package/dist/run-turn.js.map +1 -1
  26. package/dist/session-runtime.d.ts +4 -0
  27. package/dist/session-runtime.d.ts.map +1 -1
  28. package/dist/session.d.ts +9 -1
  29. package/dist/session.d.ts.map +1 -1
  30. package/dist/session.js +17 -0
  31. package/dist/session.js.map +1 -1
  32. package/dist/sessions/event-shape.d.ts +68 -0
  33. package/dist/sessions/event-shape.d.ts.map +1 -0
  34. package/dist/sessions/event-shape.js +171 -0
  35. package/dist/sessions/event-shape.js.map +1 -0
  36. package/dist/sessions/persistence.d.ts +7 -5
  37. package/dist/sessions/persistence.d.ts.map +1 -1
  38. package/dist/sessions/persistence.js +39 -13
  39. package/dist/sessions/persistence.js.map +1 -1
  40. package/dist/skill-usage.d.ts +62 -0
  41. package/dist/skill-usage.d.ts.map +1 -0
  42. package/dist/skill-usage.js +106 -0
  43. package/dist/skill-usage.js.map +1 -0
  44. package/dist/skills/synthesize.d.ts.map +1 -1
  45. package/dist/skills/synthesize.js +42 -0
  46. package/dist/skills/synthesize.js.map +1 -1
  47. package/dist/subagents/run-child.d.ts.map +1 -1
  48. package/dist/subagents/run-child.js +4 -0
  49. package/dist/subagents/run-child.js.map +1 -1
  50. package/package.json +4 -4
  51. package/src/index.ts +11 -0
  52. package/src/plugins/host-options.ts +2 -0
  53. package/src/plugins/host.ts +14 -0
  54. package/src/plugins/registry-kinds.test.ts +4 -0
  55. package/src/plugins/registry-kinds.ts +13 -0
  56. package/src/registries/modes.test.ts +27 -0
  57. package/src/registries/modes.ts +12 -0
  58. package/src/registries/reflectors.test.ts +50 -0
  59. package/src/registries/reflectors.ts +17 -0
  60. package/src/run-turn.ts +5 -0
  61. package/src/session-runtime.ts +4 -0
  62. package/src/session.ts +18 -0
  63. package/src/sessions/event-shape.test.ts +207 -0
  64. package/src/sessions/event-shape.ts +196 -0
  65. package/src/sessions/persistence.test.ts +132 -0
  66. package/src/sessions/persistence.ts +39 -13
  67. package/src/skill-usage.test.ts +102 -0
  68. package/src/skill-usage.ts +165 -0
  69. package/src/skills/synthesize.ts +42 -0
  70. package/src/subagents/run-child.ts +4 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Runtime shape guard for event-log lines — the read side of the EventStore
3
+ * trust boundary.
4
+ *
5
+ * The per-session JSONL is parsed back into `MoxxyEvent`s on every resume/
6
+ * attach (`restoreEvents`), every history page (`readEventPage`), and every
7
+ * index hydration (`matchingSessionStatsFromLog`). Corrupt-JSON lines were
8
+ * always skipped, but a structurally-valid-but-wrong-shape line used to be
9
+ * CAST straight to `MoxxyEvent` and then drive replay and state
10
+ * reconstruction — e.g. a `compaction` line missing `replacedRange`/`summary`
11
+ * throws inside `projectMessagesFromLog`'s unconditional
12
+ * `event.summary.trim()` / `event.replacedRange[0]` dereferences, mid-replay.
13
+ * This guard closes that gap: wrong-shape lines are skipped with the exact
14
+ * same semantics as corrupt lines (never throw, never truncate what follows).
15
+ *
16
+ * CALIBRATION — why this is a shallow structural check, not a full schema:
17
+ *
18
+ * `restoreEvents` REWRITES the repaired log after skipping bad lines, so a
19
+ * false positive here permanently deletes history. The guard therefore
20
+ * rejects only shapes that would actively corrupt replay, and tolerates
21
+ * benign drift:
22
+ *
23
+ * - Envelope: strict on the fields the replay machinery itself dereferences
24
+ * (`id`, `seq`, `ts`, `type` — mirrors dedupe by `id`, ingest/paging/
25
+ * re-sequencing key on numeric `seq`). Lenient-if-present on `sessionId` /
26
+ * `turnId` / `source`: the existing session-ownership path already
27
+ * tolerates and normalizes a missing `sessionId`
28
+ * (`eventBelongsToSession`), and none of these are dereferenced in a way
29
+ * that can throw.
30
+ * - Unknown `type` with a valid envelope is KEPT, not dropped: every
31
+ * downstream fold pattern-matches known types and ignores the rest (there
32
+ * is no exhaustive-switch throw), and the desktop floor mechanism makes
33
+ * "older core replaying a newer log" a designed scenario — dropping (and
34
+ * then rewriting away) a newer version's events on rollback would be
35
+ * silent history loss. This matches the pre-guard behavior for that class
36
+ * of line exactly.
37
+ * - Known `type`: the variant's required primitive fields are checked
38
+ * shallowly (`typeof` / `Array.isArray`), because the projection folds
39
+ * dereference them unconditionally. Enum-valued strings (`stopReason`,
40
+ * `decidedBy`, …) are checked as `string` only — a newer version adding an
41
+ * enum member must not get its events dropped by an older reader.
42
+ *
43
+ * LOCKSTEP — the spec map below is typed so it cannot drift from the union:
44
+ * it is exhaustive over `MoxxyEventType` (adding a variant fails the build
45
+ * until a spec is added) and each spec's keys must be required non-envelope
46
+ * keys of that exact variant (renaming/removing a field fails the build).
47
+ * Listing a field is optional per-variant, so a future required field can be
48
+ * deliberately left unchecked for back-compat (old logs won't have it) — omit
49
+ * with a comment when doing so.
50
+ *
51
+ * PERFORMANCE — this runs once per line while iterating whole logs on the
52
+ * replay hot path. It allocates nothing per call: a handful of `typeof`
53
+ * checks plus an indexed walk over a per-variant spec array precomputed at
54
+ * module load. (This is also why it is hand-rolled rather than zod: a parsed
55
+ * schema library allocates per-line result/issue objects and would
56
+ * deep-validate bulky payloads — attachments, tool outputs — that replay
57
+ * never dereferences structurally.)
58
+ */
59
+
60
+ import type { EventBase, MoxxyEvent, MoxxyEventOfType, MoxxyEventType } from '@moxxy/sdk';
61
+
62
+ /** Shallow runtime kinds a required variant field can be checked as. */
63
+ type FieldKind = 'string' | 'number' | 'boolean' | 'array' | 'seqRange';
64
+
65
+ /**
66
+ * Keys of `T` that are required AND carry a checkable type. `undefined
67
+ * extends T[K]` excludes both optional fields and `unknown`-typed ones
68
+ * (`tool_call_requested.input`, `plugin_event.payload`) — the latter on
69
+ * purpose: any JSON value is a valid `unknown`, and `JSON.stringify` drops
70
+ * the key entirely when the value was `undefined` at emit time, so even a
71
+ * presence check would drop legitimate events.
72
+ */
73
+ type CheckableKeys<T> = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T];
74
+
75
+ /**
76
+ * The checkable fields of one variant: required keys that are NOT part of the
77
+ * shared envelope (`EventBase` + the discriminant). Keys are optional so a
78
+ * back-compat omission is possible, but any key listed must exist on the
79
+ * variant — a rename/removal in the sdk union breaks this file's typecheck.
80
+ */
81
+ type VariantFieldSpec<T extends MoxxyEventType> = {
82
+ readonly [K in Exclude<
83
+ CheckableKeys<MoxxyEventOfType<T>>,
84
+ keyof EventBase | 'type'
85
+ >]?: FieldKind;
86
+ };
87
+
88
+ /** Exhaustive per-variant field specs — one entry per union member. */
89
+ const VARIANT_SPECS: { readonly [T in MoxxyEventType]: VariantFieldSpec<T> } = {
90
+ user_prompt: { text: 'string' },
91
+ assistant_chunk: { delta: 'string' },
92
+ assistant_message: { content: 'string', stopReason: 'string' },
93
+ reasoning_chunk: { delta: 'string' },
94
+ reasoning_message: { content: 'string' },
95
+ tool_call_requested: { callId: 'string', name: 'string' },
96
+ tool_call_approved: { callId: 'string', decidedBy: 'string', mode: 'string' },
97
+ tool_call_denied: { callId: 'string', decidedBy: 'string', reason: 'string' },
98
+ tool_result: { callId: 'string', ok: 'boolean' },
99
+ skill_invoked: { skillId: 'string', name: 'string', reason: 'string' },
100
+ skill_created: {
101
+ skillId: 'string',
102
+ name: 'string',
103
+ path: 'string',
104
+ scope: 'string',
105
+ originatingPrompt: 'string',
106
+ },
107
+ plugin_registered: { pluginId: 'string', name: 'string', version: 'string', kind: 'array' },
108
+ plugin_unregistered: { pluginId: 'string', name: 'string', reason: 'string' },
109
+ mode_iteration: { strategy: 'string', iteration: 'number' },
110
+ compaction: {
111
+ compactor: 'string',
112
+ replacedRange: 'seqRange',
113
+ summary: 'string',
114
+ tokensSaved: 'number',
115
+ },
116
+ elision: {
117
+ elidedThrough: 'number',
118
+ stubbedRanges: 'array',
119
+ elideConversational: 'boolean',
120
+ conversationalRecallThreshold: 'number',
121
+ maxRecallBytes: 'number',
122
+ neverElideTools: 'array',
123
+ tokensSaved: 'number',
124
+ },
125
+ provider_request: { provider: 'string', model: 'string' },
126
+ provider_response: { provider: 'string', model: 'string' },
127
+ error: { kind: 'string', message: 'string' },
128
+ abort: { reason: 'string' },
129
+ plugin_event: { pluginId: 'string', subtype: 'string' },
130
+ };
131
+
132
+ /** Specs flattened to `[key, kind]` arrays ONCE at module load, so the per-line
133
+ * check walks a plain array by index — no per-call object/iterator work. */
134
+ const SPEC_ENTRIES: ReadonlyMap<string, ReadonlyArray<readonly [string, FieldKind]>> = new Map(
135
+ Object.entries(VARIANT_SPECS).map(([type, spec]) => [
136
+ type,
137
+ Object.entries(spec) as ReadonlyArray<readonly [string, FieldKind]>,
138
+ ]),
139
+ );
140
+
141
+ function fieldOk(value: unknown, kind: FieldKind): boolean {
142
+ switch (kind) {
143
+ case 'string':
144
+ return typeof value === 'string';
145
+ case 'number':
146
+ // JSON.parse CAN yield non-finite numbers (`1e999` → Infinity); a
147
+ // non-finite seq-adjacent number poisons every downstream comparison.
148
+ return typeof value === 'number' && Number.isFinite(value);
149
+ case 'boolean':
150
+ return typeof value === 'boolean';
151
+ case 'array':
152
+ return Array.isArray(value);
153
+ case 'seqRange':
154
+ // `[from, to]` seq bounds, compared against `event.seq` unconditionally
155
+ // by the projection fold — both must be real numbers.
156
+ return (
157
+ Array.isArray(value) &&
158
+ value.length === 2 &&
159
+ typeof value[0] === 'number' &&
160
+ Number.isFinite(value[0]) &&
161
+ typeof value[1] === 'number' &&
162
+ Number.isFinite(value[1])
163
+ );
164
+ }
165
+ }
166
+
167
+ /**
168
+ * True when `value` (a successfully-parsed JSONL line) is structurally safe to
169
+ * replay as a {@link MoxxyEvent}. See the module doc for what "safe" means —
170
+ * this is a calibrated floor (reject what corrupts replay, keep benign
171
+ * drift), not a proof of full conformance: an unknown newer event type with a
172
+ * valid envelope passes, exactly as it (unvalidated) did before this guard.
173
+ */
174
+ export function isMoxxyEventShape(value: unknown): value is MoxxyEvent {
175
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
176
+ const e = value as Record<string, unknown>;
177
+ // Envelope — strict: replay machinery dereferences these on every event.
178
+ if (typeof e.type !== 'string') return false;
179
+ if (typeof e.id !== 'string') return false;
180
+ if (typeof e.seq !== 'number' || !Number.isFinite(e.seq)) return false;
181
+ if (typeof e.ts !== 'number' || !Number.isFinite(e.ts)) return false;
182
+ // Envelope — lenient-if-present: absent on some legacy logs (sessionId is
183
+ // normalized by `eventForSession`), but a present-with-wrong-type value is
184
+ // junk, not drift.
185
+ if (e.sessionId != null && typeof e.sessionId !== 'string') return false;
186
+ if (e.turnId != null && typeof e.turnId !== 'string') return false;
187
+ if (e.source != null && typeof e.source !== 'string') return false;
188
+ const fields = SPEC_ENTRIES.get(e.type);
189
+ // Unknown (newer) event type with a valid envelope: keep — see module doc.
190
+ if (!fields) return true;
191
+ for (let i = 0; i < fields.length; i += 1) {
192
+ const entry = fields[i]!;
193
+ if (!fieldOk(e[entry[0]], entry[1])) return false;
194
+ }
195
+ return true;
196
+ }
@@ -589,6 +589,138 @@ describe('SessionPersistence', () => {
589
589
  expect(again.lines).toHaveLength(0);
590
590
  });
591
591
 
592
+ it('restore skips valid-JSON wrong-shape lines exactly like corrupt ones and repairs the file', async () => {
593
+ const dir = await makeTempDir();
594
+ const id = '01BADSHAPE0000000000000000';
595
+ const mk = (seq: number, text: string) => ({
596
+ id: `e${seq}`,
597
+ seq,
598
+ ts: seq,
599
+ sessionId: id,
600
+ turnId: 't1',
601
+ source: 'user',
602
+ type: 'user_prompt',
603
+ text,
604
+ });
605
+ const lines = [
606
+ JSON.stringify(mk(0, 'zero')),
607
+ // Valid JSON, not an event at all (no envelope).
608
+ JSON.stringify({ foo: 'bar' }),
609
+ JSON.stringify(mk(2, 'two')),
610
+ // Valid envelope + known type, but missing the required fields the
611
+ // projection fold dereferences unconditionally (would throw mid-replay
612
+ // if trusted: `event.summary.trim()`, `event.replacedRange[0]`).
613
+ JSON.stringify({ id: 'junk', seq: 3, ts: 3, sessionId: id, turnId: 't1', source: 'compactor', type: 'compaction', compactor: 'x' }),
614
+ // Corrupt JSON still goes down its own (unchanged) path.
615
+ '{half a line',
616
+ JSON.stringify(mk(5, 'five')),
617
+ ];
618
+ await fs.writeFile(path.join(dir, `${id}.jsonl`), lines.join('\n') + '\n', 'utf8');
619
+
620
+ const { logger, lines: logged } = captureLogger();
621
+ const restored = await restoreEvents(id, dir, logger);
622
+
623
+ // Survivors only, re-sequenced to contiguous 0..n-1, order + ids preserved.
624
+ expect(restored.map((e) => e.id)).toEqual(['e0', 'e2', 'e5']);
625
+ expect(restored.map((e) => e.seq)).toEqual([0, 1, 2]);
626
+ expect(restored.map((e) => (e as { text?: string }).text)).toEqual(['zero', 'two', 'five']);
627
+ // One structured warn, counting shape-junk separately from corrupt JSON.
628
+ const warn = logged.find((l) => l.level === 'warn');
629
+ expect(warn?.meta).toMatchObject({ corruptLines: 1, invalidShapeLines: 2, restoredEvents: 3 });
630
+ // Never throws mid-replay: every survivor ingests into a fresh mirror.
631
+ const mirror = new EventLog();
632
+ for (const e of restored) mirror.ingest(e);
633
+ expect(mirror.length).toBe(3);
634
+ // The file was repaired on disk, so the next restore is clean.
635
+ const repaired = (await fs.readFile(path.join(dir, `${id}.jsonl`), 'utf8'))
636
+ .trim()
637
+ .split('\n')
638
+ .map((line) => JSON.parse(line) as { id: string });
639
+ expect(repaired.map((e) => e.id)).toEqual(['e0', 'e2', 'e5']);
640
+ const again = captureLogger();
641
+ await restoreEvents(id, dir, again.logger);
642
+ expect(again.lines).toHaveLength(0);
643
+ });
644
+
645
+ it('restore leaves a fully-valid log untouched on disk and replays it byte-identically', async () => {
646
+ const dir = await makeTempDir();
647
+ const id = '01ALLVALID0000000000000000';
648
+ const events = [
649
+ { id: 'e0', seq: 0, ts: 1, sessionId: id, turnId: 't1', source: 'user', type: 'user_prompt', text: 'hi' },
650
+ { id: 'e1', seq: 1, ts: 2, sessionId: id, turnId: 't1', source: 'model', type: 'assistant_message', content: 'hello', stopReason: 'end_turn' },
651
+ { id: 'e2', seq: 2, ts: 3, sessionId: id, turnId: 't1', source: 'compactor', type: 'compaction', compactor: 'x', replacedRange: [0, 1], summary: 's', tokensSaved: 10 },
652
+ ];
653
+ const original = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
654
+ await fs.writeFile(path.join(dir, `${id}.jsonl`), original, 'utf8');
655
+
656
+ const { logger, lines } = captureLogger();
657
+ const restored = await restoreEvents(id, dir, logger);
658
+
659
+ expect(restored).toEqual(events);
660
+ expect(lines).toHaveLength(0);
661
+ // No repair rewrite for a clean log — the bytes on disk are untouched.
662
+ expect(await fs.readFile(path.join(dir, `${id}.jsonl`), 'utf8')).toBe(original);
663
+ });
664
+
665
+ it('readEventPage skips wrong-shape lines like corrupt ones (read-only, no rewrite)', async () => {
666
+ const dir = await makeTempDir();
667
+ const id = '01PAGEBADSHAPE000000000000';
668
+ const mk = (seq: number, text: string) => ({
669
+ id: `e${seq}`,
670
+ seq,
671
+ ts: seq,
672
+ sessionId: id,
673
+ turnId: 't1',
674
+ source: 'user',
675
+ type: 'user_prompt',
676
+ text,
677
+ });
678
+ const lines = [
679
+ JSON.stringify(mk(0, 'zero')),
680
+ JSON.stringify({ notAnEvent: true }),
681
+ JSON.stringify(mk(1, 'one')),
682
+ '{corrupt',
683
+ JSON.stringify(mk(2, 'two')),
684
+ ];
685
+ const original = lines.join('\n') + '\n';
686
+ await fs.writeFile(path.join(dir, `${id}.jsonl`), original, 'utf8');
687
+
688
+ const page = await readEventPage(id, { before: null, limit: 10 }, dir);
689
+ expect(page.events.map((e) => e.seq)).toEqual([0, 1, 2]);
690
+ expect(page.prevCursor).toBeNull();
691
+ // Read-only: the junk stays on disk (restore owns the repair).
692
+ expect(await fs.readFile(path.join(dir, `${id}.jsonl`), 'utf8')).toBe(original);
693
+ });
694
+
695
+ it('readIndex hydration ignores a wrong-shape line when recovering firstPrompt', async () => {
696
+ const dir = await makeTempDir();
697
+ await fs.mkdir(dir, { recursive: true });
698
+ const id = '01HYDRATEBADSHAPE000000000';
699
+ await fs.writeFile(
700
+ path.join(dir, `${id}.json`),
701
+ JSON.stringify({ ...meta(id, 3), firstPrompt: null }, null, 2),
702
+ 'utf8',
703
+ );
704
+ const junkPrompt = JSON.stringify({ type: 'user_prompt', text: 'junk without envelope' });
705
+ const validPrompt = JSON.stringify({
706
+ id: 'e1',
707
+ seq: 1,
708
+ ts: 2,
709
+ sessionId: id,
710
+ turnId: 't1',
711
+ source: 'user',
712
+ type: 'user_prompt',
713
+ text: 'real prompt',
714
+ });
715
+ await fs.writeFile(path.join(dir, `${id}.jsonl`), junkPrompt + '\n' + validPrompt + '\n', 'utf8');
716
+
717
+ const [restored] = await readIndex(dir);
718
+
719
+ // The junk line neither becomes the label nor hides the later valid prompt.
720
+ expect(restored?.firstPrompt).toBe('real prompt');
721
+ expect(restored?.eventCount).toBe(1);
722
+ });
723
+
592
724
  it('restore removes foreign-session events, creates a backup, and re-sequences survivors', async () => {
593
725
  const dir = await makeTempDir();
594
726
  const id = '01RESTOREFILTER000000000';
@@ -22,12 +22,14 @@ import { createMutex, type Mutex, type MoxxyEvent, type SessionId } from '@moxxy
22
22
  import type { EventLogLike, EventPage, SessionMeta, SessionSource } from '@moxxy/sdk';
23
23
  import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
24
24
  import { createLogger, type Logger } from '../logger.js';
25
+ import { isMoxxyEventShape } from './event-shape.js';
25
26
 
26
27
  // `SessionSource`, `SessionMeta` and `EventPage` are the EventStore contract's
27
28
  // data shapes — defined in @moxxy/sdk so `EventStoreDef` can reference them.
28
29
  // Re-exported here (and onward from @moxxy/core) so existing importers are
29
30
  // unaffected by the move.
30
31
  export type { EventLogLike, EventPage, SessionMeta, SessionSource } from '@moxxy/sdk';
32
+ export { SESSION_SOURCES } from '@moxxy/sdk';
31
33
 
32
34
  /** Schema version of the per-session metadata file (`<id>.json`). Bump when the
33
35
  * shape changes incompatibly; readers tolerate a missing/older version. */
@@ -529,7 +531,10 @@ async function matchingSessionStatsFromLog(
529
531
  for (const line of raw.split('\n')) {
530
532
  if (!line.trim()) continue;
531
533
  try {
532
- events.push(JSON.parse(line) as MoxxyEvent);
534
+ const parsed: unknown = JSON.parse(line);
535
+ // Wrong-shape lines are skipped exactly like corrupt ones: neither a
536
+ // corrupt nor a junk line should hide a later valid prompt.
537
+ if (isMoxxyEventShape(parsed)) events.push(parsed);
533
538
  } catch {
534
539
  // A corrupt line should not hide a later valid prompt.
535
540
  }
@@ -549,10 +554,11 @@ function providerHeaderFromEvent(event: MoxxyEvent): { provider?: string | null;
549
554
  * Restore a previously-persisted session's events. Returns the full
550
555
  * event array suitable for passing into `new EventLog(events)`.
551
556
  *
552
- * Skips malformed lines (a single corrupted append shouldn't make the
553
- * rest of the conversation unreadable) and then RE-SEQUENCES the
554
- * survivors to contiguous `seq` 0..n-1, preserving order and ids. This
555
- * matters twice over:
557
+ * Skips malformed lines corrupt JSON and valid-JSON-but-wrong-shape
558
+ * alike (see {@link isMoxxyEventShape}); a single bad append shouldn't
559
+ * make the rest of the conversation unreadable and then RE-SEQUENCES
560
+ * the survivors to contiguous `seq` 0..n-1, preserving order and ids.
561
+ * This matters twice over:
556
562
  *
557
563
  * - Mirror replay: `EventLog.ingest` accepts only `seq === length`, so
558
564
  * a gap left by one corrupt middle line would silently truncate every
@@ -584,15 +590,25 @@ export async function restoreEvents(
584
590
  }
585
591
  const events: MoxxyEvent[] = [];
586
592
  let corruptLines = 0;
593
+ let invalidShapeLines = 0;
587
594
  let foreignEvents = 0;
588
595
  let normalizedSessionIds = 0;
589
596
  for (const line of raw.split('\n')) {
590
597
  if (!line.trim()) continue;
591
598
  try {
592
- const event = JSON.parse(line) as MoxxyEvent;
593
- const ownedEvent = eventForSession(event, sessionId);
599
+ const parsed: unknown = JSON.parse(line);
600
+ // Valid JSON but not a replayable event shape (see isMoxxyEventShape):
601
+ // skipped with the exact same semantics as a corrupt line — counted,
602
+ // re-sequenced around, and repaired away by the rewrite below. Casting
603
+ // it through instead used to let a junk line drive replay (a compaction
604
+ // line without `replacedRange` throws mid-projection).
605
+ if (!isMoxxyEventShape(parsed)) {
606
+ invalidShapeLines += 1;
607
+ continue;
608
+ }
609
+ const ownedEvent = eventForSession(parsed, sessionId);
594
610
  if (ownedEvent) {
595
- if (ownedEvent !== event) normalizedSessionIds += 1;
611
+ if (ownedEvent !== parsed) normalizedSessionIds += 1;
596
612
  events.push(ownedEvent);
597
613
  } else {
598
614
  foreignEvents += 1;
@@ -612,7 +628,13 @@ export async function restoreEvents(
612
628
  }
613
629
  }
614
630
 
615
- if (corruptLines > 0 || resequenced > 0 || foreignEvents > 0 || normalizedSessionIds > 0) {
631
+ if (
632
+ corruptLines > 0 ||
633
+ invalidShapeLines > 0 ||
634
+ resequenced > 0 ||
635
+ foreignEvents > 0 ||
636
+ normalizedSessionIds > 0
637
+ ) {
616
638
  const message =
617
639
  foreignEvents > 0
618
640
  ? 'session log restored with foreign-session events removed — re-sequenced to keep full history replayable'
@@ -621,6 +643,7 @@ export async function restoreEvents(
621
643
  sessionId,
622
644
  path: logPath,
623
645
  corruptLines,
646
+ invalidShapeLines,
624
647
  foreignEvents,
625
648
  normalizedSessionIds,
626
649
  resequencedEvents: resequenced,
@@ -695,7 +718,7 @@ export async function restoreEvents(
695
718
  * `prevCursor` is the `seq` of the page's oldest event, or `null` once the
696
719
  * first persisted event is included (no older page remains).
697
720
  *
698
- * Corrupt lines are skipped (matching {@link restoreEvents}); unlike
721
+ * Corrupt and wrong-shape lines are skipped (matching {@link restoreEvents}); unlike
699
722
  * `restoreEvents` this is a READ-ONLY reader — it never rewrites the file, so
700
723
  * it preserves the JSONL exactly (no atomic-write / mutex needed: there is no
701
724
  * mutation). Paging keys on each event's on-disk `seq`. Determinism holds for a
@@ -724,13 +747,16 @@ export async function readEventPage(
724
747
  return { events: [], prevCursor: null };
725
748
  }
726
749
 
727
- // Parse the JSONL, skipping corrupt lines (one bad append must not make the
728
- // rest unreadable). Read-only: we never rewrite the file here.
750
+ // Parse the JSONL, skipping corrupt AND wrong-shape lines (one bad append
751
+ // must not make the rest unreadable). Read-only: we never rewrite the file
752
+ // here.
729
753
  const all: MoxxyEvent[] = [];
730
754
  for (const line of raw.split('\n')) {
731
755
  if (!line.trim()) continue;
732
756
  try {
733
- all.push(JSON.parse(line) as MoxxyEvent);
757
+ const parsed: unknown = JSON.parse(line);
758
+ // skip a valid-JSON-but-not-an-event line, same as restoreEvents
759
+ if (isMoxxyEventShape(parsed)) all.push(parsed);
734
760
  } catch {
735
761
  // skip a malformed/half-written line, same as restoreEvents
736
762
  }
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { loadSkillUsage, mergeSkillUsage } from './skill-usage.js';
6
+
7
+ let tmpDir: string;
8
+ let usagePath: string;
9
+
10
+ beforeEach(async () => {
11
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-skill-usage-'));
12
+ usagePath = path.join(tmpDir, 'usage.json');
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await fs.rm(tmpDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('skill-usage store', () => {
20
+ it('returns an empty aggregate when the file is missing', async () => {
21
+ const file = await loadSkillUsage(usagePath);
22
+ expect(file.skills).toEqual({});
23
+ expect(file.version).toBe(1);
24
+ });
25
+
26
+ it('merges a delta and round-trips through disk', async () => {
27
+ await mergeSkillUsage(
28
+ { 'deploy-app': { invocations: 2, lastInvokedAt: '2026-07-03T10:00:00.000Z' } },
29
+ usagePath,
30
+ );
31
+ const file = await loadSkillUsage(usagePath);
32
+ expect(file.skills['deploy-app']!.invocations).toBe(2);
33
+ expect(file.skills['deploy-app']!.lastInvokedAt).toBe('2026-07-03T10:00:00.000Z');
34
+ });
35
+
36
+ it('accumulates invocations across merges and advances lastInvokedAt', async () => {
37
+ await mergeSkillUsage(
38
+ { 'deploy-app': { invocations: 1, lastInvokedAt: '2026-07-01T00:00:00.000Z' } },
39
+ usagePath,
40
+ );
41
+ await mergeSkillUsage(
42
+ { 'deploy-app': { invocations: 3, lastInvokedAt: '2026-07-02T00:00:00.000Z' } },
43
+ usagePath,
44
+ );
45
+ const file = await loadSkillUsage(usagePath);
46
+ expect(file.skills['deploy-app']!.invocations).toBe(4);
47
+ // The later timestamp wins even if a merge arrives out of order.
48
+ expect(file.skills['deploy-app']!.lastInvokedAt).toBe('2026-07-02T00:00:00.000Z');
49
+ });
50
+
51
+ it('keeps the earliest createdAt and never lets a later merge overwrite it', async () => {
52
+ await mergeSkillUsage(
53
+ { 'deploy-app': { invocations: 0, createdAt: '2026-06-01T00:00:00.000Z' } },
54
+ usagePath,
55
+ );
56
+ await mergeSkillUsage(
57
+ { 'deploy-app': { invocations: 1, createdAt: '2026-07-01T00:00:00.000Z' } },
58
+ usagePath,
59
+ );
60
+ const file = await loadSkillUsage(usagePath);
61
+ expect(file.skills['deploy-app']!.createdAt).toBe('2026-06-01T00:00:00.000Z');
62
+ expect(file.skills['deploy-app']!.invocations).toBe(1);
63
+ });
64
+
65
+ it('does not advance lastInvokedAt when an out-of-order (earlier) delta arrives', async () => {
66
+ await mergeSkillUsage(
67
+ { s: { invocations: 1, lastInvokedAt: '2026-07-05T00:00:00.000Z' } },
68
+ usagePath,
69
+ );
70
+ await mergeSkillUsage(
71
+ { s: { invocations: 1, lastInvokedAt: '2026-07-01T00:00:00.000Z' } },
72
+ usagePath,
73
+ );
74
+ const file = await loadSkillUsage(usagePath);
75
+ expect(file.skills['s']!.lastInvokedAt).toBe('2026-07-05T00:00:00.000Z');
76
+ expect(file.skills['s']!.invocations).toBe(2);
77
+ });
78
+
79
+ it('an empty delta is a no-op read (does not create the file)', async () => {
80
+ const file = await mergeSkillUsage({}, usagePath);
81
+ expect(file.skills).toEqual({});
82
+ await expect(fs.access(usagePath)).rejects.toThrow();
83
+ });
84
+
85
+ it('falls back to empty on a corrupt (unparseable) file', async () => {
86
+ await fs.writeFile(usagePath, '{ this is not json');
87
+ expect((await loadSkillUsage(usagePath)).skills).toEqual({});
88
+ });
89
+
90
+ it('falls back to empty on a shape-invalid file (non-numeric counter)', async () => {
91
+ // A hand-edited file with a string counter must not flow into the additive
92
+ // merge (which would corrupt the aggregate via string concatenation).
93
+ await fs.writeFile(
94
+ usagePath,
95
+ JSON.stringify({ version: 1, updatedAt: 'x', skills: { s: { invocations: '5' } } }),
96
+ );
97
+ expect((await loadSkillUsage(usagePath)).skills).toEqual({});
98
+ // A subsequent merge therefore starts fresh rather than concatenating.
99
+ await mergeSkillUsage({ s: { invocations: 2 } }, usagePath);
100
+ expect((await loadSkillUsage(usagePath)).skills['s']!.invocations).toBe(2);
101
+ });
102
+ });