@vibe-hero/server 0.1.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.
- package/LICENSE +190 -0
- package/README.md +151 -0
- package/dist/catalog/bundled/claude-code/.gitkeep +0 -0
- package/dist/catalog/bundled/claude-code/context-management.yaml +302 -0
- package/dist/catalog/bundled/claude-code/planning.yaml +313 -0
- package/dist/catalog/bundled/claude-code/subagents.yaml +357 -0
- package/dist/catalog/bundled/general/.gitkeep +0 -0
- package/dist/catalog/bundled/general/_placeholder.yaml +39 -0
- package/dist/catalog/bundled/general/task-decomposition.yaml +390 -0
- package/dist/catalog/bundled/index.d.ts +39 -0
- package/dist/catalog/bundled/index.d.ts.map +1 -0
- package/dist/catalog/bundled/index.js +41 -0
- package/dist/catalog/bundled/index.js.map +1 -0
- package/dist/catalog/fetcher.d.ts +201 -0
- package/dist/catalog/fetcher.d.ts.map +1 -0
- package/dist/catalog/fetcher.js +452 -0
- package/dist/catalog/fetcher.js.map +1 -0
- package/dist/catalog/loader.d.ts +165 -0
- package/dist/catalog/loader.d.ts.map +1 -0
- package/dist/catalog/loader.js +241 -0
- package/dist/catalog/loader.js.map +1 -0
- package/dist/catalog/resolve.d.ts +85 -0
- package/dist/catalog/resolve.d.ts.map +1 -0
- package/dist/catalog/resolve.js +103 -0
- package/dist/catalog/resolve.js.map +1 -0
- package/dist/cli/getOffer.d.ts +38 -0
- package/dist/cli/getOffer.d.ts.map +1 -0
- package/dist/cli/getOffer.js +150 -0
- package/dist/cli/getOffer.js.map +1 -0
- package/dist/cli/index.d.ts +46 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +88 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +63 -0
- package/dist/config.js.map +1 -0
- package/dist/engine/elo.d.ts +76 -0
- package/dist/engine/elo.d.ts.map +1 -0
- package/dist/engine/elo.js +79 -0
- package/dist/engine/elo.js.map +1 -0
- package/dist/engine/graduation.d.ts +108 -0
- package/dist/engine/graduation.d.ts.map +1 -0
- package/dist/engine/graduation.js +161 -0
- package/dist/engine/graduation.js.map +1 -0
- package/dist/engine/lapse.d.ts +80 -0
- package/dist/engine/lapse.d.ts.map +1 -0
- package/dist/engine/lapse.js +125 -0
- package/dist/engine/lapse.js.map +1 -0
- package/dist/engine/selection.d.ts +84 -0
- package/dist/engine/selection.d.ts.map +1 -0
- package/dist/engine/selection.js +119 -0
- package/dist/engine/selection.js.map +1 -0
- package/dist/grading/deterministic.d.ts +102 -0
- package/dist/grading/deterministic.d.ts.map +1 -0
- package/dist/grading/deterministic.js +118 -0
- package/dist/grading/deterministic.js.map +1 -0
- package/dist/grading/freeform.d.ts +64 -0
- package/dist/grading/freeform.d.ts.map +1 -0
- package/dist/grading/freeform.js +85 -0
- package/dist/grading/freeform.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/observation/hookEvents.d.ts +113 -0
- package/dist/observation/hookEvents.d.ts.map +1 -0
- package/dist/observation/hookEvents.js +170 -0
- package/dist/observation/hookEvents.js.map +1 -0
- package/dist/observation/offers.d.ts +215 -0
- package/dist/observation/offers.d.ts.map +1 -0
- package/dist/observation/offers.js +327 -0
- package/dist/observation/offers.js.map +1 -0
- package/dist/observation/source.d.ts +133 -0
- package/dist/observation/source.d.ts.map +1 -0
- package/dist/observation/source.js +105 -0
- package/dist/observation/source.js.map +1 -0
- package/dist/profile/migrate.d.ts +122 -0
- package/dist/profile/migrate.d.ts.map +1 -0
- package/dist/profile/migrate.js +147 -0
- package/dist/profile/migrate.js.map +1 -0
- package/dist/profile/store.d.ts +84 -0
- package/dist/profile/store.d.ts.map +1 -0
- package/dist/profile/store.js +267 -0
- package/dist/profile/store.js.map +1 -0
- package/dist/schemas/common.d.ts +95 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +106 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/schemas/content.d.ts +828 -0
- package/dist/schemas/content.d.ts.map +1 -0
- package/dist/schemas/content.js +219 -0
- package/dist/schemas/content.js.map +1 -0
- package/dist/schemas/profile.d.ts +599 -0
- package/dist/schemas/profile.d.ts.map +1 -0
- package/dist/schemas/profile.js +177 -0
- package/dist/schemas/profile.js.map +1 -0
- package/dist/schemas/tools.d.ts +1581 -0
- package/dist/schemas/tools.d.ts.map +1 -0
- package/dist/schemas/tools.js +286 -0
- package/dist/schemas/tools.js.map +1 -0
- package/dist/tools/config.d.ts +51 -0
- package/dist/tools/config.d.ts.map +1 -0
- package/dist/tools/config.js +104 -0
- package/dist/tools/config.js.map +1 -0
- package/dist/tools/gate.d.ts +50 -0
- package/dist/tools/gate.d.ts.map +1 -0
- package/dist/tools/gate.js +67 -0
- package/dist/tools/gate.js.map +1 -0
- package/dist/tools/guidance.d.ts +36 -0
- package/dist/tools/guidance.d.ts.map +1 -0
- package/dist/tools/guidance.js +117 -0
- package/dist/tools/guidance.js.map +1 -0
- package/dist/tools/listTopics.d.ts +55 -0
- package/dist/tools/listTopics.d.ts.map +1 -0
- package/dist/tools/listTopics.js +78 -0
- package/dist/tools/listTopics.js.map +1 -0
- package/dist/tools/offers.d.ts +60 -0
- package/dist/tools/offers.d.ts.map +1 -0
- package/dist/tools/offers.js +152 -0
- package/dist/tools/offers.js.map +1 -0
- package/dist/tools/placeholders.d.ts +27 -0
- package/dist/tools/placeholders.d.ts.map +1 -0
- package/dist/tools/placeholders.js +49 -0
- package/dist/tools/placeholders.js.map +1 -0
- package/dist/tools/recordObservation.d.ts +52 -0
- package/dist/tools/recordObservation.d.ts.map +1 -0
- package/dist/tools/recordObservation.js +87 -0
- package/dist/tools/recordObservation.js.map +1 -0
- package/dist/tools/startQuiz.d.ts +82 -0
- package/dist/tools/startQuiz.d.ts.map +1 -0
- package/dist/tools/startQuiz.js +180 -0
- package/dist/tools/startQuiz.js.map +1 -0
- package/dist/tools/status.d.ts +59 -0
- package/dist/tools/status.d.ts.map +1 -0
- package/dist/tools/status.js +133 -0
- package/dist/tools/status.js.map +1 -0
- package/dist/tools/submitAnswer.d.ts +156 -0
- package/dist/tools/submitAnswer.d.ts.map +1 -0
- package/dist/tools/submitAnswer.js +402 -0
- package/dist/tools/submitAnswer.js.map +1 -0
- package/dist/tools/types.d.ts +82 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +48 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/us2/standing.d.ts +111 -0
- package/dist/tools/us2/standing.d.ts.map +1 -0
- package/dist/tools/us2/standing.js +143 -0
- package/dist/tools/us2/standing.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Offer engine (T035) — cadence + anti-fatigue decision logic.
|
|
3
|
+
*
|
|
4
|
+
* This is the brain behind the observation → offer pipeline. It does two
|
|
5
|
+
* trigger-only jobs (it NEVER scores — FR-005 / SC-003):
|
|
6
|
+
*
|
|
7
|
+
* 1. **Match** derived activity signals to candidate topic keys by scanning the
|
|
8
|
+
* loaded topics' {@link TriggerSignal}s ({@link matchCandidates}).
|
|
9
|
+
* 2. **Decide** whether an offer may surface at an end-of-work breakpoint, and
|
|
10
|
+
* for which key, honoring the configured cadence and the full anti-fatigue
|
|
11
|
+
* stack ({@link resolveOffer}) — within-session decline suppression
|
|
12
|
+
* (FR-020), configurable cadence off / per_session / per_topic (FR-020a),
|
|
13
|
+
* and cross-session decline backoff + global mute (FR-020b).
|
|
14
|
+
*
|
|
15
|
+
* Design: the decision core is **pure**. {@link resolveOffer},
|
|
16
|
+
* {@link matchCandidates}, {@link applyDecline}, and {@link applyAccept} take
|
|
17
|
+
* plain state + a `now` timestamp and return plain results — no IO, no clock, no
|
|
18
|
+
* randomness — so they are trivially testable and deterministic. The tool layer
|
|
19
|
+
* (`tools/offers.ts`, `tools/recordObservation.ts`) is the thin wrapper that
|
|
20
|
+
* reads the clock, loads the catalog, and persists via `updateProfile`.
|
|
21
|
+
*
|
|
22
|
+
* Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-005, FR-015..017,
|
|
23
|
+
* FR-019/020/020a/020b, SC-003), specs/001-vibe-hero-mvp/data-model.md
|
|
24
|
+
* (§ OfferLedger), specs/001-vibe-hero-mvp/contracts/mcp-tools.md
|
|
25
|
+
* (`record_observation` / `get_offer` / `record_offer_response`),
|
|
26
|
+
* src/config.ts (ASSESSMENT_CONFIG.declineMuteThreshold / backoff*).
|
|
27
|
+
*/
|
|
28
|
+
import { ASSESSMENT_CONFIG } from "../config.js";
|
|
29
|
+
import { abilityKey } from "../schemas/common.js";
|
|
30
|
+
/**
|
|
31
|
+
* Does a single {@link TriggerSignal} match a single {@link ObservedSignal} for
|
|
32
|
+
* the given tool? A match requires the tool to agree and at least one of the
|
|
33
|
+
* signal's selectors to hit:
|
|
34
|
+
* - `match.toolName` — exact (case-sensitive) equality against `signal.toolName`.
|
|
35
|
+
* - `match.toolNamePattern` — regex tested against `signal.toolName`.
|
|
36
|
+
* - `match.mcpToolPattern` — regex tested against `signal.mcpTool`.
|
|
37
|
+
*
|
|
38
|
+
* A malformed regex pattern fails closed (no match) rather than throwing — a bad
|
|
39
|
+
* trigger declaration must never crash the observation intake path.
|
|
40
|
+
*/
|
|
41
|
+
const triggerMatchesSignal = (trigger, signal) => {
|
|
42
|
+
const { match } = trigger;
|
|
43
|
+
if (match.toolName !== undefined &&
|
|
44
|
+
signal.toolName !== undefined &&
|
|
45
|
+
match.toolName === signal.toolName) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (match.toolNamePattern !== undefined && signal.toolName !== undefined) {
|
|
49
|
+
if (safeRegexTest(match.toolNamePattern, signal.toolName))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (match.mcpToolPattern !== undefined && signal.mcpTool !== undefined) {
|
|
53
|
+
if (safeRegexTest(match.mcpToolPattern, signal.mcpTool))
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
};
|
|
58
|
+
/** Test `pattern` against `value`, failing closed on an invalid regex. */
|
|
59
|
+
const safeRegexTest = (pattern, value) => {
|
|
60
|
+
try {
|
|
61
|
+
return new RegExp(pattern).test(value);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Match derived signals against the loaded topics' trigger declarations and
|
|
69
|
+
* return the distinct candidate topics (deduped by {@link AbilityKey}, in
|
|
70
|
+
* topic-iteration order). Trigger-only (FR-015): this selects *which topic to
|
|
71
|
+
* offer* and never scores.
|
|
72
|
+
*
|
|
73
|
+
* Only triggers whose `tool` equals the observation's `tool` are considered, so
|
|
74
|
+
* a Claude Code Bash signal never trips a Codex topic. A topic with no matching
|
|
75
|
+
* trigger is omitted.
|
|
76
|
+
*
|
|
77
|
+
* @param topics - The loaded catalog topics (each carrying `triggerSignals`).
|
|
78
|
+
* @param tool - The host tool the activity belongs to.
|
|
79
|
+
* @param signals - The derived signals observed this turn.
|
|
80
|
+
* @returns The distinct offer candidates `{ key, title, reason }`.
|
|
81
|
+
*/
|
|
82
|
+
export const matchCandidates = (topics, tool, signals) => {
|
|
83
|
+
const candidates = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const topic of topics) {
|
|
86
|
+
const key = abilityKey(topic.class, topic.id);
|
|
87
|
+
if (seen.has(key))
|
|
88
|
+
continue;
|
|
89
|
+
// Find a trigger for THIS tool that some observed signal satisfies.
|
|
90
|
+
const hit = topic.triggerSignals.find((trigger) => trigger.tool === tool &&
|
|
91
|
+
signals.some((signal) => triggerMatchesSignal(trigger, signal)));
|
|
92
|
+
if (hit === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
candidates.push({
|
|
96
|
+
key,
|
|
97
|
+
title: topic.title,
|
|
98
|
+
reason: describeMatch(hit),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return candidates;
|
|
102
|
+
};
|
|
103
|
+
/** A short, privacy-safe human reason for why a topic became a candidate. */
|
|
104
|
+
const describeMatch = (trigger) => {
|
|
105
|
+
const { match } = trigger;
|
|
106
|
+
if (match.toolName !== undefined) {
|
|
107
|
+
return `Observed ${match.toolName} activity, which exercises this topic.`;
|
|
108
|
+
}
|
|
109
|
+
if (match.toolNamePattern !== undefined) {
|
|
110
|
+
return `Observed tool activity matching this topic's trigger.`;
|
|
111
|
+
}
|
|
112
|
+
return `Observed MCP tool activity matching this topic's trigger.`;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Decide whether an end-of-work offer may surface, and for which key (FR-019
|
|
116
|
+
* non-interrupting timing is the caller's concern; this is the *whether/which*).
|
|
117
|
+
*
|
|
118
|
+
* Gates, in order (first failure wins):
|
|
119
|
+
* 1. `offerCadence === "off"` ⇒ `offers_off` (FR-020a).
|
|
120
|
+
* 2. `proactiveOffers === false` ⇒ `offers_off` (master switch, FR-031).
|
|
121
|
+
* 3. `mutedUntil` in the future ⇒ `cadence` (global mute, FR-020b).
|
|
122
|
+
* 4. `declinedThisSession` ⇒ `declined` (within-session suppression, FR-020).
|
|
123
|
+
* 5. `per_session` and an offer already surfaced this session ⇒ `cadence`
|
|
124
|
+
* (≤1 offer/session, FR-020a).
|
|
125
|
+
* 6. No candidate keys at all ⇒ `no_candidate`.
|
|
126
|
+
* 7. Otherwise pick the first candidate that is BOTH not already offered this
|
|
127
|
+
* session under `per_topic` (≤1 per distinct key/session, FR-020a) AND not
|
|
128
|
+
* within its cross-session `perTopicNextEligibleAt` backoff window
|
|
129
|
+
* (FR-020b). If none survives ⇒ `cadence`.
|
|
130
|
+
*
|
|
131
|
+
* Pure: no clock, no IO. `now` is supplied by the caller.
|
|
132
|
+
*
|
|
133
|
+
* @param state - The bundled offer state (config flags, ledger, backoff, candidates).
|
|
134
|
+
* @param now - The current instant, for muted/backoff comparisons.
|
|
135
|
+
* @returns An {@link OfferDecision}.
|
|
136
|
+
*/
|
|
137
|
+
export const resolveOffer = (state, now) => {
|
|
138
|
+
const { proactiveOffers, offerCadence, ledger, backoff, candidates } = state;
|
|
139
|
+
// 1 + 2: offers disabled entirely.
|
|
140
|
+
if (offerCadence === "off" || !proactiveOffers) {
|
|
141
|
+
return { kind: "suppressed", reason: "offers_off" };
|
|
142
|
+
}
|
|
143
|
+
// 3: global cross-session mute (FR-020b) — N consecutive declines reached.
|
|
144
|
+
if (isMuted(backoff, now)) {
|
|
145
|
+
return { kind: "suppressed", reason: "cadence" };
|
|
146
|
+
}
|
|
147
|
+
// 4: a decline this session suppresses the rest of the session (FR-020).
|
|
148
|
+
if (ledger.declinedThisSession) {
|
|
149
|
+
return { kind: "suppressed", reason: "declined" };
|
|
150
|
+
}
|
|
151
|
+
// 5: per_session cap — at most one offer for the whole session (FR-020a).
|
|
152
|
+
if (offerCadence === "per_session" && ledger.offersThisSession >= 1) {
|
|
153
|
+
return { kind: "suppressed", reason: "cadence" };
|
|
154
|
+
}
|
|
155
|
+
// 6: nothing to offer.
|
|
156
|
+
if (candidates.length === 0) {
|
|
157
|
+
return { kind: "suppressed", reason: "no_candidate" };
|
|
158
|
+
}
|
|
159
|
+
// 7: pick the first candidate clearing the per-topic + backoff gates.
|
|
160
|
+
const alreadyOffered = new Set(ledger.offeredTopicKeys);
|
|
161
|
+
for (const key of candidates) {
|
|
162
|
+
if (offerCadence === "per_topic" && alreadyOffered.has(key)) {
|
|
163
|
+
// ≤1 offer per distinct key this session (FR-020a).
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (isBackedOff(backoff, key, now)) {
|
|
167
|
+
// Within the cross-session re-offer window for this topic (FR-020b).
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
return { kind: "offer", key };
|
|
171
|
+
}
|
|
172
|
+
// Every candidate was per-topic-exhausted or backed off.
|
|
173
|
+
return { kind: "suppressed", reason: "cadence" };
|
|
174
|
+
};
|
|
175
|
+
/** Is `mutedUntil` set and still in the future at `now`? (Global mute, FR-020b.) */
|
|
176
|
+
const isMuted = (backoff, now) => backoff.mutedUntil !== undefined &&
|
|
177
|
+
Date.parse(backoff.mutedUntil) > now.getTime();
|
|
178
|
+
/**
|
|
179
|
+
* Is `key` within its cross-session re-offer backoff window at `now`? A key with
|
|
180
|
+
* no recorded `perTopicNextEligibleAt` entry is always eligible (FR-020b).
|
|
181
|
+
*/
|
|
182
|
+
const isBackedOff = (backoff, key, now) => {
|
|
183
|
+
const nextAt = backoff.perTopicNextEligibleAt[key];
|
|
184
|
+
return nextAt !== undefined && Date.parse(nextAt) > now.getTime();
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* The per-session ledger reset to a fresh `sessionId`. The cross-session backoff
|
|
188
|
+
* is intentionally NOT reset here — it persists across sessions until an accept
|
|
189
|
+
* resets it (FR-020b). Used by the tool layer when a new session begins.
|
|
190
|
+
*/
|
|
191
|
+
export const freshLedger = (sessionId) => ({
|
|
192
|
+
sessionId,
|
|
193
|
+
offersThisSession: 0,
|
|
194
|
+
declinedThisSession: false,
|
|
195
|
+
offeredTopicKeys: [],
|
|
196
|
+
candidateKeys: [],
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Reconcile the persisted ledger with the session being observed/queried. If the
|
|
200
|
+
* ledger's `sessionId` differs (or is the empty-string sentinel), the previous
|
|
201
|
+
* session's per-session accounting is stale, so return a {@link freshLedger}.
|
|
202
|
+
* Otherwise return the ledger unchanged. Pure.
|
|
203
|
+
*
|
|
204
|
+
* @param ledger - The persisted per-session ledger.
|
|
205
|
+
* @param sessionId - The session id of the current request.
|
|
206
|
+
*/
|
|
207
|
+
export const ledgerForSession = (ledger, sessionId) => ledger.sessionId === sessionId ? ledger : freshLedger(sessionId);
|
|
208
|
+
/**
|
|
209
|
+
* Record that an offer for `key` surfaced this session: bump the per-session
|
|
210
|
+
* count and add the key to `offeredTopicKeys` (deduped). Pure; the tool layer
|
|
211
|
+
* persists the result. Called when `get_offer` actually returns an offer so the
|
|
212
|
+
* cadence caps are enforced on the *next* `get_offer`.
|
|
213
|
+
*/
|
|
214
|
+
export const markOffered = (ledger, key) => ({
|
|
215
|
+
...ledger,
|
|
216
|
+
offersThisSession: ledger.offersThisSession + 1,
|
|
217
|
+
offeredTopicKeys: ledger.offeredTopicKeys.includes(key)
|
|
218
|
+
? ledger.offeredTopicKeys
|
|
219
|
+
: [...ledger.offeredTopicKeys, key],
|
|
220
|
+
});
|
|
221
|
+
/**
|
|
222
|
+
* Merge freshly-matched candidate keys into the per-session ledger's candidate
|
|
223
|
+
* pool (`candidateKeys`) without counting them as offered. `record_observation`
|
|
224
|
+
* uses this to accumulate candidates across the session as signals arrive;
|
|
225
|
+
* `get_offer` (which receives no signals) later resolves from this pool. New
|
|
226
|
+
* candidates are appended in match order, deduped, after the existing pool so
|
|
227
|
+
* the most-relevant-first ordering of a given turn is preserved. Does NOT touch
|
|
228
|
+
* `offeredTopicKeys` or `offersThisSession` — a candidate is not an offer until
|
|
229
|
+
* {@link markOffered}. Pure.
|
|
230
|
+
*
|
|
231
|
+
* @param ledger - The per-session ledger (already reconciled to this session).
|
|
232
|
+
* @param keys - The candidate keys matched this turn (most-relevant first).
|
|
233
|
+
*/
|
|
234
|
+
export const noteCandidates = (ledger, keys) => {
|
|
235
|
+
const pool = [...ledger.candidateKeys];
|
|
236
|
+
const seen = new Set(pool);
|
|
237
|
+
for (const key of keys) {
|
|
238
|
+
if (!seen.has(key)) {
|
|
239
|
+
seen.add(key);
|
|
240
|
+
pool.push(key);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { ...ledger, candidateKeys: pool };
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Apply a decline for `key` (FR-020 within-session + FR-020b cross-session).
|
|
247
|
+
*
|
|
248
|
+
* Within-session (FR-020): set `declinedThisSession = true` so no further offer
|
|
249
|
+
* surfaces for the rest of the session.
|
|
250
|
+
*
|
|
251
|
+
* Cross-session (FR-020b):
|
|
252
|
+
* - increment `consecutiveDeclines`;
|
|
253
|
+
* - push `perTopicNextEligibleAt[key]` out by an exponential backoff:
|
|
254
|
+
* `backoffBaseHours * backoffFactor^(consecutiveDeclines - 1)` hours from
|
|
255
|
+
* `now`, so each successive decline lengthens the re-offer interval;
|
|
256
|
+
* - once `consecutiveDeclines >= declineMuteThreshold`, set a global
|
|
257
|
+
* `mutedUntil` far in the future (offers globally muted until the user
|
|
258
|
+
* re-enables — modeled as a long horizon derived from the backoff).
|
|
259
|
+
*
|
|
260
|
+
* Pure: `now` and config are inputs; the tool layer persists the result.
|
|
261
|
+
*
|
|
262
|
+
* @param ledger - The current per-session ledger (for this session).
|
|
263
|
+
* @param backoff - The current cross-session backoff.
|
|
264
|
+
* @param key - The declined topic key.
|
|
265
|
+
* @param now - The decline instant.
|
|
266
|
+
* @param config - Tunables (decline mute threshold + backoff base/factor).
|
|
267
|
+
* @returns The updated ledger + backoff.
|
|
268
|
+
*/
|
|
269
|
+
export const applyDecline = (ledger, backoff, key, now, config = ASSESSMENT_CONFIG) => {
|
|
270
|
+
const consecutiveDeclines = backoff.consecutiveDeclines + 1;
|
|
271
|
+
// Exponential per-topic backoff: base * factor^(n-1) hours from now.
|
|
272
|
+
const backoffHours = config.backoffBaseHours *
|
|
273
|
+
Math.pow(config.backoffFactor, consecutiveDeclines - 1);
|
|
274
|
+
const nextEligibleAt = addHours(now, backoffHours).toISOString();
|
|
275
|
+
const perTopicNextEligibleAt = {
|
|
276
|
+
...backoff.perTopicNextEligibleAt,
|
|
277
|
+
[key]: nextEligibleAt,
|
|
278
|
+
};
|
|
279
|
+
// Global mute once the threshold is hit (FR-020b). Horizon: a generously long
|
|
280
|
+
// multiple of the current backoff so offers stay muted until the user
|
|
281
|
+
// re-enables / requests one.
|
|
282
|
+
const muted = consecutiveDeclines >= config.declineMuteThreshold;
|
|
283
|
+
const mutedUntil = muted
|
|
284
|
+
? addHours(now, backoffHours * MUTE_HORIZON_MULTIPLIER).toISOString()
|
|
285
|
+
: backoff.mutedUntil;
|
|
286
|
+
const nextBackoff = {
|
|
287
|
+
consecutiveDeclines,
|
|
288
|
+
perTopicNextEligibleAt,
|
|
289
|
+
...(mutedUntil !== undefined ? { mutedUntil } : {}),
|
|
290
|
+
};
|
|
291
|
+
const nextLedger = {
|
|
292
|
+
...ledger,
|
|
293
|
+
declinedThisSession: true,
|
|
294
|
+
};
|
|
295
|
+
return { ledger: nextLedger, backoff: nextBackoff };
|
|
296
|
+
};
|
|
297
|
+
/**
|
|
298
|
+
* Multiplier applied to the current per-topic backoff to derive the global mute
|
|
299
|
+
* horizon when the decline threshold is reached. A large multiple models
|
|
300
|
+
* "muted until the user re-enables" without an unbounded/never-parses date.
|
|
301
|
+
*/
|
|
302
|
+
const MUTE_HORIZON_MULTIPLIER = 1000;
|
|
303
|
+
/**
|
|
304
|
+
* Apply an accept (FR-020b): reset `consecutiveDeclines` to 0 and clear the
|
|
305
|
+
* global `mutedUntil`. Per-topic backoff entries are left as-is (an accept on
|
|
306
|
+
* one topic does not retroactively un-back-off unrelated topics, but the global
|
|
307
|
+
* counter/mute reset means the user is engaged again). The per-session ledger is
|
|
308
|
+
* unchanged by an accept. Pure.
|
|
309
|
+
*
|
|
310
|
+
* @param backoff - The current cross-session backoff.
|
|
311
|
+
* @returns The reset backoff.
|
|
312
|
+
*/
|
|
313
|
+
export const applyAccept = (backoff) => ({
|
|
314
|
+
consecutiveDeclines: 0,
|
|
315
|
+
perTopicNextEligibleAt: backoff.perTopicNextEligibleAt,
|
|
316
|
+
// mutedUntil intentionally dropped (cleared).
|
|
317
|
+
});
|
|
318
|
+
/**
|
|
319
|
+
* Apply a defer (FR-020): a defer is treated as "ask me later" — it does NOT
|
|
320
|
+
* count as a decline (no backoff increment, no within-session decline flag) and
|
|
321
|
+
* does NOT reset the consecutive-decline counter. The state is returned
|
|
322
|
+
* unchanged so the next end-of-work breakpoint may re-offer normally. Pure.
|
|
323
|
+
*/
|
|
324
|
+
export const applyDefer = (ledger, backoff) => ({ ledger, backoff });
|
|
325
|
+
/** Add `hours` to a `Date`, returning a new `Date`. */
|
|
326
|
+
const addHours = (base, hours) => new Date(base.getTime() + hours * 3_600_000);
|
|
327
|
+
//# sourceMappingURL=offers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offers.js","sourceRoot":"","sources":["../../src/observation/offers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Observation source abstraction (FR-015/016).
|
|
3
|
+
*
|
|
4
|
+
* Observation is **trigger-only**: a source yields derived, privacy-safe
|
|
5
|
+
* {@link ObservationEvent}s that say *a topic was exercised*, used solely to
|
|
6
|
+
* populate offer candidates. Observation NEVER scores (FR-005, SC-003) and
|
|
7
|
+
* NEVER persists raw prompts, tool inputs, or tool outputs (FR-018, SC-008).
|
|
8
|
+
*
|
|
9
|
+
* The {@link ObservationSource} interface is the seam (FR-016) behind which any
|
|
10
|
+
* provenance can live — a real-time Claude Code hook
|
|
11
|
+
* ({@link ./hookEvents.js#HookSource}), a future transcript-backfill source, or
|
|
12
|
+
* the always-available {@link SelfReportSource} manual path. The rest of the
|
|
13
|
+
* system depends only on this interface, so adding a source needs no redesign.
|
|
14
|
+
*
|
|
15
|
+
* Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-015..018),
|
|
16
|
+
* specs/001-vibe-hero-mvp/data-model.md (§ ObservationEvent),
|
|
17
|
+
* specs/001-vibe-hero-mvp/research.md (§ Observation & hook correlation).
|
|
18
|
+
*/
|
|
19
|
+
import { type ObservationEvent } from "../schemas/profile.js";
|
|
20
|
+
import { type AbilityKey, type ToolId } from "../schemas/common.js";
|
|
21
|
+
/**
|
|
22
|
+
* A provenance of derived observation signals (FR-016).
|
|
23
|
+
*
|
|
24
|
+
* Two complementary entry points so every concrete source fits, regardless of
|
|
25
|
+
* how its raw signal arrives:
|
|
26
|
+
*
|
|
27
|
+
* - {@link poll} — for sources that accumulate signals out-of-band (e.g. a hook
|
|
28
|
+
* writing to a buffer, or a transcript reader) and are *drained* on demand.
|
|
29
|
+
* Returns and clears whatever has been observed since the last poll.
|
|
30
|
+
* - {@link record} — for sources handed a single raw payload synchronously
|
|
31
|
+
* (e.g. one hook invocation, or an explicit self-report) and asked to derive
|
|
32
|
+
* events from it immediately.
|
|
33
|
+
*
|
|
34
|
+
* A concrete source MAY implement only the entry point natural to it; the other
|
|
35
|
+
* defaults to yielding nothing. Both return only privacy-safe
|
|
36
|
+
* {@link ObservationEvent}s — never raw payload fields (FR-018).
|
|
37
|
+
*/
|
|
38
|
+
export interface ObservationSource {
|
|
39
|
+
/**
|
|
40
|
+
* Stable identifier for the source kind, for diagnostics/telemetry routing.
|
|
41
|
+
* Examples: `"self-report"`, `"claude-code-hook"`, `"transcript-backfill"`.
|
|
42
|
+
*/
|
|
43
|
+
readonly kind: string;
|
|
44
|
+
/**
|
|
45
|
+
* Drain accumulated derived events. Implementations that push via
|
|
46
|
+
* {@link record} (or have no buffer) return an empty array.
|
|
47
|
+
*
|
|
48
|
+
* @returns the privacy-safe events observed since the previous poll.
|
|
49
|
+
*/
|
|
50
|
+
poll(): Promise<readonly ObservationEvent[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Derive zero or more privacy-safe events from a single raw payload.
|
|
53
|
+
*
|
|
54
|
+
* Implementations MUST treat `raw` as untrusted `unknown`, validate
|
|
55
|
+
* defensively, and copy ONLY derived signals — never `tool_input`,
|
|
56
|
+
* `tool_output`, or any nested raw content (FR-018).
|
|
57
|
+
*
|
|
58
|
+
* @param raw - an untrusted, source-specific payload.
|
|
59
|
+
* @returns the privacy-safe events derived from `raw` (possibly empty).
|
|
60
|
+
*/
|
|
61
|
+
record(raw: unknown): readonly ObservationEvent[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Explicit input for the manual self-report path. The user (or the host agent
|
|
65
|
+
* on their behalf) states outright that one or more topics were exercised; no
|
|
66
|
+
* telemetry is involved, so this path ALWAYS works (FR-016, SC-011).
|
|
67
|
+
*/
|
|
68
|
+
export interface SelfReport {
|
|
69
|
+
/** The host tool the activity belongs to. */
|
|
70
|
+
readonly tool: ToolId;
|
|
71
|
+
/**
|
|
72
|
+
* The topic ability keys the user reports exercising (e.g. produced from
|
|
73
|
+
* {@link abilityKey}). At least one is expected; an empty list yields no
|
|
74
|
+
* event.
|
|
75
|
+
*/
|
|
76
|
+
readonly topicKeys: readonly AbilityKey[];
|
|
77
|
+
/**
|
|
78
|
+
* Whether the reported activity succeeded. Self-report defaults to `true`
|
|
79
|
+
* (the user is asserting they did the thing); pass `false` to report a
|
|
80
|
+
* failed/abandoned attempt. Note: success is trigger metadata only and never
|
|
81
|
+
* affects scoring (FR-005).
|
|
82
|
+
*/
|
|
83
|
+
readonly success?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Optional correlation id for this report. Defaults to a generated
|
|
86
|
+
* `self-report:<timestamp>` token; self-report has no upstream id to align
|
|
87
|
+
* with (FR-017 correlation applies to hook↔transcript, not this path).
|
|
88
|
+
*/
|
|
89
|
+
readonly correlationId?: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Manual, always-available observation source (FR-016, SC-011).
|
|
93
|
+
*
|
|
94
|
+
* Produces an {@link ObservationEvent} from an explicit {@link SelfReport} with
|
|
95
|
+
* no telemetry whatsoever — the canonical fallback when no hook/transcript
|
|
96
|
+
* source is present (Edge: "No telemetry available"). Because the input is
|
|
97
|
+
* already a set of derived `topicKeys`, there is nothing raw to leak; the event
|
|
98
|
+
* is still re-validated against {@link ObservationEventSchema} before it leaves.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const src = new SelfReportSource();
|
|
102
|
+
* const events = src.record({
|
|
103
|
+
* tool: "claude-code",
|
|
104
|
+
* topicKeys: [abilityKey({ kind: "tool", tool: "claude-code" }, "subagents")],
|
|
105
|
+
* });
|
|
106
|
+
*/
|
|
107
|
+
export declare class SelfReportSource implements ObservationSource {
|
|
108
|
+
private readonly now;
|
|
109
|
+
readonly kind = "self-report";
|
|
110
|
+
/**
|
|
111
|
+
* @param now - clock for the event timestamp / default correlation id,
|
|
112
|
+
* injectable for deterministic tests. Defaults to `Date.now`-backed
|
|
113
|
+
* {@link Date}.
|
|
114
|
+
*/
|
|
115
|
+
constructor(now?: () => Date);
|
|
116
|
+
/**
|
|
117
|
+
* Self-report is push-only via {@link record}; there is no buffer to drain.
|
|
118
|
+
*/
|
|
119
|
+
poll(): Promise<readonly ObservationEvent[]>;
|
|
120
|
+
/**
|
|
121
|
+
* Derive a single {@link ObservationEvent} from an explicit {@link SelfReport}.
|
|
122
|
+
*
|
|
123
|
+
* Returns an empty array when the report names no topics. The returned event
|
|
124
|
+
* carries only derived fields and is schema-validated before return.
|
|
125
|
+
*
|
|
126
|
+
* @param raw - expected to satisfy {@link SelfReport}; validated defensively.
|
|
127
|
+
* @throws {Error} if `raw` is not a well-formed self-report.
|
|
128
|
+
*/
|
|
129
|
+
record(raw: unknown): readonly ObservationEvent[];
|
|
130
|
+
/** Defensive structural validation of an untrusted self-report payload. */
|
|
131
|
+
private parseReport;
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../../src/observation/source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACL,KAAK,gBAAgB,EAEtB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,MAAM,EAAc,MAAM,sBAAsB,CAAC;AAEhF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;;;OAKG;IACH,IAAI,IAAI,OAAO,CAAC,SAAS,gBAAgB,EAAE,CAAC,CAAC;IAE7C;;;;;;;;;OASG;IACH,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,gBAAgB,EAAE,CAAC;CACnD;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,UAAU,EAAE,CAAC;IAC1C;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,gBAAiB,YAAW,iBAAiB;IAQrC,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPvC,SAAgB,IAAI,iBAAiB;IAErC;;;;OAIG;gBACiC,GAAG,GAAE,MAAM,IAAuB;IAEtE;;OAEG;IACI,IAAI,IAAI,OAAO,CAAC,SAAS,gBAAgB,EAAE,CAAC;IAInD;;;;;;;;OAQG;IACI,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,gBAAgB,EAAE;IAkBxD,2EAA2E;IAC3E,OAAO,CAAC,WAAW;CA0BpB"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Observation source abstraction (FR-015/016).
|
|
3
|
+
*
|
|
4
|
+
* Observation is **trigger-only**: a source yields derived, privacy-safe
|
|
5
|
+
* {@link ObservationEvent}s that say *a topic was exercised*, used solely to
|
|
6
|
+
* populate offer candidates. Observation NEVER scores (FR-005, SC-003) and
|
|
7
|
+
* NEVER persists raw prompts, tool inputs, or tool outputs (FR-018, SC-008).
|
|
8
|
+
*
|
|
9
|
+
* The {@link ObservationSource} interface is the seam (FR-016) behind which any
|
|
10
|
+
* provenance can live — a real-time Claude Code hook
|
|
11
|
+
* ({@link ./hookEvents.js#HookSource}), a future transcript-backfill source, or
|
|
12
|
+
* the always-available {@link SelfReportSource} manual path. The rest of the
|
|
13
|
+
* system depends only on this interface, so adding a source needs no redesign.
|
|
14
|
+
*
|
|
15
|
+
* Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-015..018),
|
|
16
|
+
* specs/001-vibe-hero-mvp/data-model.md (§ ObservationEvent),
|
|
17
|
+
* specs/001-vibe-hero-mvp/research.md (§ Observation & hook correlation).
|
|
18
|
+
*/
|
|
19
|
+
import { ObservationEventSchema, } from "../schemas/profile.js";
|
|
20
|
+
/**
|
|
21
|
+
* Manual, always-available observation source (FR-016, SC-011).
|
|
22
|
+
*
|
|
23
|
+
* Produces an {@link ObservationEvent} from an explicit {@link SelfReport} with
|
|
24
|
+
* no telemetry whatsoever — the canonical fallback when no hook/transcript
|
|
25
|
+
* source is present (Edge: "No telemetry available"). Because the input is
|
|
26
|
+
* already a set of derived `topicKeys`, there is nothing raw to leak; the event
|
|
27
|
+
* is still re-validated against {@link ObservationEventSchema} before it leaves.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const src = new SelfReportSource();
|
|
31
|
+
* const events = src.record({
|
|
32
|
+
* tool: "claude-code",
|
|
33
|
+
* topicKeys: [abilityKey({ kind: "tool", tool: "claude-code" }, "subagents")],
|
|
34
|
+
* });
|
|
35
|
+
*/
|
|
36
|
+
export class SelfReportSource {
|
|
37
|
+
now;
|
|
38
|
+
kind = "self-report";
|
|
39
|
+
/**
|
|
40
|
+
* @param now - clock for the event timestamp / default correlation id,
|
|
41
|
+
* injectable for deterministic tests. Defaults to `Date.now`-backed
|
|
42
|
+
* {@link Date}.
|
|
43
|
+
*/
|
|
44
|
+
constructor(now = () => new Date()) {
|
|
45
|
+
this.now = now;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Self-report is push-only via {@link record}; there is no buffer to drain.
|
|
49
|
+
*/
|
|
50
|
+
poll() {
|
|
51
|
+
return Promise.resolve([]);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Derive a single {@link ObservationEvent} from an explicit {@link SelfReport}.
|
|
55
|
+
*
|
|
56
|
+
* Returns an empty array when the report names no topics. The returned event
|
|
57
|
+
* carries only derived fields and is schema-validated before return.
|
|
58
|
+
*
|
|
59
|
+
* @param raw - expected to satisfy {@link SelfReport}; validated defensively.
|
|
60
|
+
* @throws {Error} if `raw` is not a well-formed self-report.
|
|
61
|
+
*/
|
|
62
|
+
record(raw) {
|
|
63
|
+
const report = this.parseReport(raw);
|
|
64
|
+
if (report.topicKeys.length === 0) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const timestamp = this.now().toISOString();
|
|
68
|
+
const event = {
|
|
69
|
+
tool: report.tool,
|
|
70
|
+
topicKeys: [...report.topicKeys],
|
|
71
|
+
success: report.success ?? true,
|
|
72
|
+
timestamp,
|
|
73
|
+
correlationId: report.correlationId ?? `self-report:${timestamp}`,
|
|
74
|
+
};
|
|
75
|
+
// Re-validate the derived event; this is the privacy boundary's final
|
|
76
|
+
// checkpoint (the schema has no fields for raw payload content).
|
|
77
|
+
return [ObservationEventSchema.parse(event)];
|
|
78
|
+
}
|
|
79
|
+
/** Defensive structural validation of an untrusted self-report payload. */
|
|
80
|
+
parseReport(raw) {
|
|
81
|
+
if (typeof raw !== "object" || raw === null) {
|
|
82
|
+
throw new Error("SelfReportSource.record: expected a SelfReport object");
|
|
83
|
+
}
|
|
84
|
+
const obj = raw;
|
|
85
|
+
const tool = obj["tool"];
|
|
86
|
+
const topicKeys = obj["topicKeys"];
|
|
87
|
+
if (typeof tool !== "string") {
|
|
88
|
+
throw new Error("SelfReportSource.record: 'tool' must be a ToolId string");
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(topicKeys)) {
|
|
91
|
+
throw new Error("SelfReportSource.record: 'topicKeys' must be an array of AbilityKey");
|
|
92
|
+
}
|
|
93
|
+
const success = obj["success"];
|
|
94
|
+
const correlationId = obj["correlationId"];
|
|
95
|
+
return {
|
|
96
|
+
tool: tool,
|
|
97
|
+
topicKeys: topicKeys,
|
|
98
|
+
...(typeof success === "boolean" ? { success } : {}),
|
|
99
|
+
...(typeof correlationId === "string" && correlationId.length > 0
|
|
100
|
+
? { correlationId }
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=source.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source.js","sourceRoot":"","sources":["../../src/observation/source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAEL,sBAAsB,GACvB,MAAM,uBAAuB,CAAC;AA6E/B;;;;;;;;;;;;;;;GAeG;AACH,MAAM,OAAO,gBAAgB;IAQS;IAPpB,IAAI,GAAG,aAAa,CAAC;IAErC;;;;OAIG;IACH,YAAoC,MAAkB,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE;QAAlC,QAAG,GAAH,GAAG,CAA+B;IAAG,CAAC;IAE1E;;OAEG;IACI,IAAI;QACT,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC7B,CAAC;IAED;;;;;;;;OAQG;IACI,MAAM,CAAC,GAAY;QACxB,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QACrC,IAAI,MAAM,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAClC,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE,CAAC;QAC3C,MAAM,KAAK,GAAqB;YAC9B,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,SAAS,EAAE,CAAC,GAAG,MAAM,CAAC,SAAS,CAAC;YAChC,OAAO,EAAE,MAAM,CAAC,OAAO,IAAI,IAAI;YAC/B,SAAS;YACT,aAAa,EAAE,MAAM,CAAC,aAAa,IAAI,eAAe,SAAS,EAAE;SAClE,CAAC;QACF,sEAAsE;QACtE,iEAAiE;QACjE,OAAO,CAAC,sBAAsB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;IAC/C,CAAC;IAED,2EAA2E;IACnE,WAAW,CAAC,GAAY;QAC9B,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YAC5C,MAAM,IAAI,KAAK,CAAC,uDAAuD,CAAC,CAAC;QAC3E,CAAC;QACD,MAAM,GAAG,GAAG,GAA8B,CAAC;QAC3C,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC;QACzB,MAAM,SAAS,GAAG,GAAG,CAAC,WAAW,CAAC,CAAC;QACnC,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC7B,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;QAC7E,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CACb,qEAAqE,CACtE,CAAC;QACJ,CAAC;QACD,MAAM,OAAO,GAAG,GAAG,CAAC,SAAS,CAAC,CAAC;QAC/B,MAAM,aAAa,GAAG,GAAG,CAAC,eAAe,CAAC,CAAC;QAC3C,OAAO;YACL,IAAI,EAAE,IAAc;YACpB,SAAS,EAAE,SAAyB;YACpC,GAAG,CAAC,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACpD,GAAG,CAAC,OAAO,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC;gBAC/D,CAAC,CAAC,EAAE,aAAa,EAAE;gBACnB,CAAC,CAAC,EAAE,CAAC;SACR,CAAC;IACJ,CAAC;CACF"}
|