dsh-tacit 0.2.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/lib/schema.js ADDED
@@ -0,0 +1,294 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — shared zod schemas (host side).
5
+ *
6
+ * All wire payloads, persisted state, and the plugin Config live here so the
7
+ * fold, the service, the HTTP routes, and the loader's config validation
8
+ * share one definition. zod v4 (the same major the profile hoists).
9
+ */
10
+
11
+ import { z } from 'zod'
12
+
13
+ // ── Config ─────────────────────────────────────────────────────────────────
14
+
15
+ export const COACH_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
16
+ /**
17
+ * Fallback provider id — the shipped DeepSeek adapter registers as
18
+ * `deepseek-official` (see dsh-llm-deepseek / agent-default-model settings).
19
+ * Whenever the session's own route is known (from its request/header events)
20
+ * that route wins, so proxy/custom providers keep working.
21
+ */
22
+ export const COACH_PROVIDER = 'deepseek-official'
23
+
24
+ /**
25
+ * The loader-facing plugin config. Wrapped in `z.preprocess` so a patch row
26
+ * without a `config:` block (`undefined`) resolves to all defaults — a bare
27
+ * `z.object` rejects `undefined` even when every field has a default.
28
+ */
29
+ export const Config = z.preprocess((v) => v ?? {}, z.object({
30
+ /** Coach model id, allowlisted (see COACH_MODELS). */
31
+ model: z.string().default('deepseek-v4-flash'),
32
+ /** Whether the live composer improvement feature may appear at all. */
33
+ liveSuggestions: z.boolean().default(true),
34
+ /** Whole turns retained in the projection (newest kept). */
35
+ maxKeptTurns: z.number().default(60),
36
+ /** Prompt text kept per turn (chars). */
37
+ maxPromptChars: z.number().default(4000),
38
+ /** Tool-call argument preview kept per call (chars). */
39
+ maxToolCallChars: z.number().default(500),
40
+ /** Final assistant text kept per turn (chars). */
41
+ maxAssistantChars: z.number().default(4000),
42
+ /** Tool-call entries kept per turn. */
43
+ maxToolCallsPerTurn: z.number().default(50),
44
+ /** Mistake patterns kept in the persistent profile. */
45
+ maxPatterns: z.number().default(12),
46
+ /** Analyze messy / corrected turns automatically on the projection feed (zero clicks). */
47
+ autoAnalyze: z.boolean().default(true),
48
+ /** Hard cap on automatic analyses per calendar day (cost guard). */
49
+ autoDailyBudget: z.number().default(30),
50
+ /** A finished turn with at least this many model steps counts as messy. */
51
+ autoMinSteps: z.number().default(15),
52
+ /** Inject the learned directives into every session's system prompt. */
53
+ steerAgent: z.boolean().default(true),
54
+ /** New analyses between two directive distillations. */
55
+ directiveEvery: z.number().default(3),
56
+ /** Opt-in: before each send, one small call appends learned context to the step (never rewrites the user's words). */
57
+ enrichPrompts: z.boolean().default(false),
58
+ /** Finished turns a distilled directive stays on trial before it is activated or retired. */
59
+ directiveTrialTurns: z.number().default(10),
60
+ /** A candidate retires when the messy-turn rate during its trial exceeds the baseline by more than this. */
61
+ directiveWorseBy: z.number().default(0.15),
62
+ }))
63
+
64
+ /**
65
+ * A UI-written config patch: only fields the user changed are persisted, so
66
+ * YAML/loader config keeps acting as the base for everything else.
67
+ */
68
+ const configPatchSchema = z.object({
69
+ model: z.string().optional(),
70
+ liveSuggestions: z.boolean().optional(),
71
+ maxKeptTurns: z.number().optional(),
72
+ maxPromptChars: z.number().optional(),
73
+ maxToolCallChars: z.number().optional(),
74
+ maxAssistantChars: z.number().optional(),
75
+ maxToolCallsPerTurn: z.number().optional(),
76
+ maxPatterns: z.number().optional(),
77
+ autoAnalyze: z.boolean().optional(),
78
+ autoDailyBudget: z.number().optional(),
79
+ autoMinSteps: z.number().optional(),
80
+ steerAgent: z.boolean().optional(),
81
+ directiveEvery: z.number().optional(),
82
+ enrichPrompts: z.boolean().optional(),
83
+ directiveTrialTurns: z.number().optional(),
84
+ directiveWorseBy: z.number().optional(),
85
+ })
86
+
87
+ // ── Trajectory projection ──────────────────────────────────────────────────
88
+
89
+ const usageSchema = z.object({
90
+ inputTokens: z.number(),
91
+ outputTokens: z.number(),
92
+ cacheReadTokens: z.number(),
93
+ cacheWriteTokens: z.number(),
94
+ reasoningTokens: z.number(),
95
+ })
96
+
97
+ const toolCallSchema = z.object({
98
+ name: z.string(),
99
+ args: z.string(),
100
+ })
101
+
102
+ /** One turn's digest — the fold's unit and the analysis input. */
103
+ export const turnSchema = z.object({
104
+ turn: z.number(),
105
+ startedAt: z.number(),
106
+ /** Fold-internal provisional (first user/message of any source); absent on the wire. */
107
+ provisionalPrompt: z.string().optional(),
108
+ prompt: z.string(),
109
+ steps: z.number(),
110
+ toolCalls: z.array(toolCallSchema),
111
+ toolErrors: z.number(),
112
+ retries: z.number(),
113
+ compactions: z.number(),
114
+ feedback: z.number(),
115
+ usage: usageSchema,
116
+ finalText: z.string(),
117
+ model: z.string(),
118
+ provider: z.string(),
119
+ finished: z.boolean(),
120
+ endedAt: z.number(),
121
+ /** How the turn ended (from turn/end data.reason, e.g. 'success'|'rejected'|'cancelled'); absent on old checkpoints. */
122
+ endReason: z.string().default(''),
123
+ /** Context the coach appended before the send (plugin-sourced user message); '' when none. */
124
+ enrichment: z.string().default(''),
125
+ })
126
+
127
+ /** Persisted projection state (plain JSON; bump stateVersion on change). */
128
+ export const timelineStateSchema = z.object({
129
+ createdAt: z.number().default(0),
130
+ maxKeptTurns: z.number().default(60),
131
+ turns: z.array(turnSchema),
132
+ current: turnSchema.nullable(),
133
+ })
134
+
135
+ /** The wire payload delivered to the browser for `tacitTimeline`. */
136
+ export const timelineViewSchema = z.object({
137
+ turns: z.array(turnSchema),
138
+ })
139
+
140
+ // ── Reports & profile ──────────────────────────────────────────────────────
141
+
142
+ const problemSchema = z.object({
143
+ kind: z.string(),
144
+ severity: z.string(),
145
+ what: z.string(),
146
+ why: z.string(),
147
+ })
148
+
149
+ export const reportSchema = z.object({
150
+ ok: z.boolean(),
151
+ turn: z.number(),
152
+ time: z.number(),
153
+ model: z.string(),
154
+ problems: z.array(problemSchema),
155
+ improvedPrompt: z.string(),
156
+ explanation: z.string(),
157
+ /** Original prompt excerpt (clipped at save time); older reports lack it. */
158
+ promptExcerpt: z.string().optional(),
159
+ /** What produced this report: 'manual' (click), 'auto' (messy turn), 'correction' (next prompt corrected the agent). */
160
+ trigger: z.string().default('manual'),
161
+ /** The user's next message when it triggered the analysis (clipped). */
162
+ followUp: z.string().optional(),
163
+ })
164
+
165
+ /**
166
+ * v2 trust/feedback counters on one mistake pattern. Every field is
167
+ * optional-with-default so v1 profiles (kind/count/lastExample only) parse
168
+ * unchanged — missing counters mean 0, exactly what `safeProfile` merges.
169
+ */
170
+ export const patternCountersSchema = z.object({
171
+ /** Times a rewrite touching this pattern was APPLIED to the composer. */
172
+ applied: z.number().int().default(0),
173
+ /** Times an applied rewrite was rated 👍. */
174
+ accepted: z.number().int().default(0),
175
+ /** Times an applied rewrite was rated 👎. */
176
+ rejected: z.number().int().default(0),
177
+ /** Times the next turn's outcome was BETTER than the baseline (free signals). */
178
+ verified: z.number().int().default(0),
179
+ /** Times the next turn's outcome was same/worse than the baseline. */
180
+ unverified: z.number().int().default(0),
181
+ })
182
+
183
+ /** One distilled durable style rule (from rejected-improvement reasons). */
184
+ const styleRuleSchema = z.object({
185
+ rule: z.string(),
186
+ createdAt: z.number(),
187
+ })
188
+
189
+ /** One recorded verdict in the bounded feedback log. */
190
+ const feedbackEntrySchema = z.object({
191
+ time: z.number(),
192
+ verdict: z.enum(['up', 'down']),
193
+ reason: z.string(),
194
+ patternKinds: z.array(z.string()),
195
+ })
196
+
197
+ /**
198
+ * One directive the AGENT follows on the user's behalf (rendered into the
199
+ * system prompt). `distilled` entries come from analyses; `user` entries are
200
+ * typed in Settings and survive every distillation.
201
+ */
202
+ const directiveTrialSchema = z.object({
203
+ /** Finished turns observed while the candidate was injected. */
204
+ turns: z.number().int().min(0),
205
+ /** How many of those were messy. */
206
+ messy: z.number().int().min(0),
207
+ /** Messy-turn rate over the 20 turns before the trial started. */
208
+ baselineRate: z.number(),
209
+ startedAt: z.number(),
210
+ })
211
+
212
+ const directiveSchema = z.object({
213
+ id: z.string(),
214
+ text: z.string(),
215
+ enabled: z.boolean().default(true),
216
+ source: z.enum(['distilled', 'user']).default('distilled'),
217
+ createdAt: z.number(),
218
+ /** candidate = injected on trial; active = proven (or user-made); retired = made things worse. */
219
+ status: z.enum(['candidate', 'active', 'retired']).default('active'),
220
+ trial: directiveTrialSchema.optional(),
221
+ retiredReason: z.string().optional(),
222
+ })
223
+
224
+ /** The persistent user-wide mistake profile. */
225
+ export const profileSchema = z.object({
226
+ analyzedCount: z.number(),
227
+ patterns: z.array(
228
+ z.object({
229
+ kind: z.string(),
230
+ count: z.number(),
231
+ lastExample: z.string(),
232
+ }).merge(patternCountersSchema),
233
+ ),
234
+ updatedAt: z.number(),
235
+ /** Distilled style rules riding every improve call (max 6, oldest replaced). */
236
+ styleRules: z.array(styleRuleSchema).default([]),
237
+ /** Verdict log (max 10, oldest replaced). */
238
+ feedbackLog: z.array(feedbackEntrySchema).default([]),
239
+ /** Down-reasons not yet distilled into style rules. */
240
+ pendingDistill: z.number().int().min(0).default(0),
241
+ /** Agent-facing directives (max 8) rendered into the steering section. */
242
+ directives: z.array(directiveSchema).default([]),
243
+ /** New analyses since the last directive distillation. */
244
+ analysesSinceDirectives: z.number().int().min(0).default(0),
245
+ })
246
+
247
+ // ── Route argument codecs ──────────────────────────────────────────────────
248
+
249
+ export const bootstrapArgSchema = z.object({
250
+ sessionId: z.string().min(1).max(200).optional(),
251
+ limit: z.number().int().min(1).max(50).optional(),
252
+ })
253
+
254
+ export const statsArgSchema = z.object({
255
+ window: z.number().int().min(3).max(200).optional(),
256
+ })
257
+
258
+ export const directivesArgSchema = z.discriminatedUnion('action', [
259
+ z.object({ action: z.literal('toggle'), id: z.string().min(1).max(64), enabled: z.boolean() }),
260
+ z.object({ action: z.literal('add'), text: z.string().min(1).max(300) }),
261
+ z.object({ action: z.literal('remove'), id: z.string().min(1).max(64) }),
262
+ ])
263
+
264
+ // ── Route argument codecs ──────────────────────────────────────────────────
265
+
266
+ export const sessionArgSchema = z.object({
267
+ sessionId: z.string().min(1).max(200),
268
+ })
269
+
270
+ export const analyzeArgSchema = z.object({
271
+ sessionId: z.string().min(1).max(200),
272
+ turn: z.number().int().min(1),
273
+ })
274
+
275
+ export const improveArgSchema = z.object({
276
+ sessionId: z.string().min(1).max(200),
277
+ draft: z.string().min(1).max(100000),
278
+ })
279
+
280
+ export const feedbackArgSchema = z.object({
281
+ rewriteId: z.string().min(1).max(64),
282
+ verdict: z.enum(['up', 'down']),
283
+ /** One-line rejection reason; the service clips it to 300 chars (bounded log). */
284
+ reason: z.string().max(2000).optional(),
285
+ })
286
+
287
+ export const appliedArgSchema = z.object({
288
+ sessionId: z.string().min(1).max(200),
289
+ rewriteId: z.string().min(1).max(64),
290
+ })
291
+
292
+ export const configArgSchema = z.object({
293
+ patch: configPatchSchema,
294
+ })