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/LICENSE +21 -0
- package/README.md +83 -0
- package/README.zh.md +77 -0
- package/client/client.js +1691 -0
- package/cordis.patch.yml +15 -0
- package/lib/analyze.js +893 -0
- package/lib/fold.js +300 -0
- package/lib/index.js +86 -0
- package/lib/routes.js +152 -0
- package/lib/schema.js +294 -0
- package/lib/service.js +1044 -0
- package/lib/store.js +219 -0
- package/package.json +104 -0
package/lib/fold.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — trajectory fold (session projection unit).
|
|
5
|
+
*
|
|
6
|
+
* A pure, synchronous fold over the committed session event log that keeps a
|
|
7
|
+
* bounded ring of per-turn digests: the human prompt, step count, tool calls
|
|
8
|
+
* (name + clipped argument preview), tool errors, retries, compactions,
|
|
9
|
+
* feedback counts, provider-reported usage, and the final assistant text.
|
|
10
|
+
*
|
|
11
|
+
* Registered on the harness's `ctx.sessionProjections` registry, which drives
|
|
12
|
+
* `apply(state, event)` over every committed event, persists the state through
|
|
13
|
+
* the projection cache, and pushes the finished value to the browser as a
|
|
14
|
+
* `session/projection` frame where the client reads it with
|
|
15
|
+
* `useProjection('tacitTimeline')`.
|
|
16
|
+
*
|
|
17
|
+
* The contract on dsh >= 0.1.1-rc.1 requires BOTH `stateSchema` (validates the
|
|
18
|
+
* persisted state on restore) and `wire` (declares the browser-delivered
|
|
19
|
+
* payload); `schema`/`view` are kept for older hosts. `stateVersion` must be
|
|
20
|
+
* bumped whenever the persisted state shape or fold semantics change.
|
|
21
|
+
*
|
|
22
|
+
* Fork-seed guard: a forked session's log starts with parent-copied events
|
|
23
|
+
* whose original timestamps predate the child's createdAt (carried by the
|
|
24
|
+
* `session` header-line event during cold restore). Those are skipped — the
|
|
25
|
+
* parent session already folded them. A resumed session's seed is its own
|
|
26
|
+
* history and folds normally.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { timelineStateSchema, timelineViewSchema } from './schema.js'
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_BOUNDS = {
|
|
32
|
+
maxKeptTurns: 60,
|
|
33
|
+
maxPromptChars: 4000,
|
|
34
|
+
maxToolCallChars: 500,
|
|
35
|
+
maxAssistantChars: 4000,
|
|
36
|
+
maxToolCallsPerTurn: 50,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Clip text to `max` chars (string-safe). */
|
|
40
|
+
export function clip(text, max) {
|
|
41
|
+
const value = typeof text === 'string' ? text : ''
|
|
42
|
+
return value.length <= max ? value : value.slice(0, max)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Concatenate the text blocks of a message content list (blocks may be any shape). */
|
|
46
|
+
export function textOfBlocks(content) {
|
|
47
|
+
if (!Array.isArray(content)) return ''
|
|
48
|
+
let out = ''
|
|
49
|
+
for (const block of content) {
|
|
50
|
+
if (
|
|
51
|
+
block !== null && typeof block === 'object'
|
|
52
|
+
&& block.type === 'text' && typeof block.text === 'string'
|
|
53
|
+
) {
|
|
54
|
+
out = out.length > 0 ? `${out}\n${block.text}` : block.text
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function zeroUsage() {
|
|
61
|
+
return {
|
|
62
|
+
inputTokens: 0,
|
|
63
|
+
outputTokens: 0,
|
|
64
|
+
cacheReadTokens: 0,
|
|
65
|
+
cacheWriteTokens: 0,
|
|
66
|
+
reasoningTokens: 0,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function emptyTurn(turn, time) {
|
|
71
|
+
return {
|
|
72
|
+
turn,
|
|
73
|
+
startedAt: time,
|
|
74
|
+
prompt: '',
|
|
75
|
+
provisionalPrompt: '',
|
|
76
|
+
steps: 0,
|
|
77
|
+
toolCalls: [],
|
|
78
|
+
toolErrors: 0,
|
|
79
|
+
retries: 0,
|
|
80
|
+
compactions: 0,
|
|
81
|
+
feedback: 0,
|
|
82
|
+
usage: zeroUsage(),
|
|
83
|
+
finalText: '',
|
|
84
|
+
model: '',
|
|
85
|
+
provider: '',
|
|
86
|
+
finished: false,
|
|
87
|
+
endedAt: 0,
|
|
88
|
+
endReason: '',
|
|
89
|
+
enrichment: '',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is this user/message the coach's own pre-send context note? */
|
|
94
|
+
function isCoachEnrichment(source) {
|
|
95
|
+
return source !== null && typeof source === 'object' && source.kind === 'plugin' && source.plugin === 'dsh-tacit'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Add the usage of one assistant message into the turn's running totals (mutates `usage`). */
|
|
99
|
+
function accumulateUsage(usage, usageReport) {
|
|
100
|
+
if (usageReport === null || typeof usageReport !== 'object') return usage
|
|
101
|
+
const read = (key) => {
|
|
102
|
+
const value = usageReport[key]
|
|
103
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
104
|
+
}
|
|
105
|
+
usage.inputTokens += read('inputTokens')
|
|
106
|
+
usage.outputTokens += read('outputTokens')
|
|
107
|
+
usage.cacheReadTokens += read('cacheReadTokens')
|
|
108
|
+
usage.cacheWriteTokens += read('cacheWriteTokens')
|
|
109
|
+
usage.reasoningTokens += read('reasoningTokens')
|
|
110
|
+
return usage
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Close the open turn: strip fold-internal fields, mark finished, append to the ring, trim. */
|
|
114
|
+
function closeTurn(state, turn, finished, endedAt, endReason = '') {
|
|
115
|
+
const { provisionalPrompt, ...record } = turn
|
|
116
|
+
const closed = {
|
|
117
|
+
...record,
|
|
118
|
+
prompt: record.prompt.length > 0 ? record.prompt : provisionalPrompt,
|
|
119
|
+
finished,
|
|
120
|
+
endedAt,
|
|
121
|
+
endReason: clip(endReason, 40),
|
|
122
|
+
}
|
|
123
|
+
const turns = state.turns.concat([closed])
|
|
124
|
+
const excess = turns.length - state.maxKeptTurns
|
|
125
|
+
if (excess > 0) turns.splice(0, excess)
|
|
126
|
+
return { ...state, turns, current: null }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Fold one committed event into the timeline state (same reference when irrelevant). */
|
|
130
|
+
export function applyTimeline(state, event, bounds) {
|
|
131
|
+
// The persistence header line arrives as a `type: 'session'` event during
|
|
132
|
+
// cold restore; it carries the session's createdAt at the envelope level.
|
|
133
|
+
if (event.type === 'session') {
|
|
134
|
+
const created = Number(event.createdAt)
|
|
135
|
+
if (Number.isFinite(created) && created > 0 && created !== state.createdAt) {
|
|
136
|
+
return { ...state, createdAt: created }
|
|
137
|
+
}
|
|
138
|
+
return state
|
|
139
|
+
}
|
|
140
|
+
// Fork-seed guard: a forked session's log starts with the parent's copied
|
|
141
|
+
// events, whose original timestamps predate the child's createdAt. The
|
|
142
|
+
// parent already folded them, so skip. A resumed session's seed is its own
|
|
143
|
+
// history (timestamps >= its own createdAt) and folds normally.
|
|
144
|
+
const eventTime = Number(event.time)
|
|
145
|
+
if (state.createdAt > 0 && Number.isFinite(eventTime) && eventTime > 0 && eventTime < state.createdAt) {
|
|
146
|
+
return state
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
switch (event.type) {
|
|
150
|
+
case 'turn/start': {
|
|
151
|
+
const turn = event.data !== null && typeof event.data === 'object' && typeof event.data.turn === 'number'
|
|
152
|
+
? event.data.turn
|
|
153
|
+
: (state.current?.turn ?? 0) + 1
|
|
154
|
+
const next = state.current === null
|
|
155
|
+
? state
|
|
156
|
+
: closeTurn(state, state.current, false, event.time)
|
|
157
|
+
return { ...next, current: emptyTurn(turn, event.time) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'turn/end': {
|
|
161
|
+
if (state.current === null) return state
|
|
162
|
+
const reason = event.data !== null && typeof event.data === 'object' && typeof event.data.reason === 'string'
|
|
163
|
+
? event.data.reason
|
|
164
|
+
: ''
|
|
165
|
+
return closeTurn(state, state.current, true, event.time, reason)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'user/message': {
|
|
169
|
+
if (state.current === null) return state
|
|
170
|
+
const data = event.data
|
|
171
|
+
const source = data !== null && typeof data === 'object' ? data.source : null
|
|
172
|
+
const text = data !== null && typeof data === 'object' ? textOfBlocks(data.content) : ''
|
|
173
|
+
if (text.length === 0) return state
|
|
174
|
+
const current = state.current
|
|
175
|
+
if (isCoachEnrichment(source)) {
|
|
176
|
+
if (current.enrichment === '') current.enrichment = clip(text, bounds.maxPromptChars)
|
|
177
|
+
return { ...state }
|
|
178
|
+
}
|
|
179
|
+
const isHuman = source !== null && typeof source === 'object' && source.kind === 'user'
|
|
180
|
+
if (current.provisionalPrompt === '') current.provisionalPrompt = clip(text, 100000)
|
|
181
|
+
if (isHuman && current.prompt === '') current.prompt = clip(text, bounds.maxPromptChars)
|
|
182
|
+
return { ...state }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case 'step/start':
|
|
186
|
+
case 'step/end': {
|
|
187
|
+
if (state.current === null) return state
|
|
188
|
+
const step = event.data !== null && typeof event.data === 'object' && typeof event.data.step === 'number'
|
|
189
|
+
? event.data.step
|
|
190
|
+
: 0
|
|
191
|
+
if (step > state.current.steps) state.current.steps = step
|
|
192
|
+
return { ...state }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'tool/call': {
|
|
196
|
+
if (state.current === null) return state
|
|
197
|
+
if (state.current.toolCalls.length >= bounds.maxToolCallsPerTurn) return state
|
|
198
|
+
const data = event.data !== null && typeof event.data === 'object' ? event.data : {}
|
|
199
|
+
state.current.toolCalls.push({
|
|
200
|
+
name: typeof data.name === 'string' ? data.name : '?',
|
|
201
|
+
args: clip(typeof data.arguments === 'string' ? data.arguments : '', bounds.maxToolCallChars),
|
|
202
|
+
})
|
|
203
|
+
return { ...state }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'tool/result': {
|
|
207
|
+
if (state.current === null) return state
|
|
208
|
+
const data = event.data !== null && typeof event.data === 'object' ? event.data : null
|
|
209
|
+
if (data !== null && data.error !== undefined && data.error !== null) state.current.toolErrors += 1
|
|
210
|
+
return { ...state }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'assistant/message': {
|
|
214
|
+
if (state.current === null) return state
|
|
215
|
+
const data = event.data !== null && typeof event.data === 'object' ? event.data : {}
|
|
216
|
+
accumulateUsage(state.current.usage, data.usage)
|
|
217
|
+
const text = data.message !== null && typeof data.message === 'object'
|
|
218
|
+
? textOfBlocks(data.message.content)
|
|
219
|
+
: ''
|
|
220
|
+
if (text.length > 0) state.current.finalText = clip(text, bounds.maxAssistantChars)
|
|
221
|
+
return { ...state }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case 'request/header': {
|
|
225
|
+
if (state.current === null) return state
|
|
226
|
+
const header = event.data !== null && typeof event.data === 'object' ? event.data.header : null
|
|
227
|
+
const config = header !== null && typeof header === 'object' ? header.config : null
|
|
228
|
+
if (config !== null && typeof config === 'object') {
|
|
229
|
+
if (typeof config.model === 'string') state.current.model = config.model
|
|
230
|
+
if (typeof config.provider === 'string') state.current.provider = config.provider
|
|
231
|
+
}
|
|
232
|
+
return { ...state }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
case 'llm/retry': {
|
|
236
|
+
if (state.current === null) return state
|
|
237
|
+
state.current.retries += 1
|
|
238
|
+
return { ...state }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
case 'compaction/summary': {
|
|
242
|
+
if (state.current === null) return state
|
|
243
|
+
state.current.compactions += 1
|
|
244
|
+
return { ...state }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'feedback/record': {
|
|
248
|
+
if (state.current === null) return state
|
|
249
|
+
state.current.feedback += 1
|
|
250
|
+
return { ...state }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
default:
|
|
254
|
+
return state
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The `tacitTimeline` projection definition. `boundsOf` is re-read on
|
|
260
|
+
* every fold so retention settings changed from the UI apply live.
|
|
261
|
+
*/
|
|
262
|
+
export function createTimelineDefinition(boundsOf) {
|
|
263
|
+
const view = (state) => ({
|
|
264
|
+
turns: state.current === null ? state.turns : state.turns.concat([stripProvisional(state.current)]),
|
|
265
|
+
})
|
|
266
|
+
const definition = {
|
|
267
|
+
key: 'tacitTimeline',
|
|
268
|
+
// Contract (dsh >= 0.1.1-rc.1): without `wire` and `stateSchema`
|
|
269
|
+
// the registry treats the unit as host-only and never pushes to the browser.
|
|
270
|
+
stateSchema: timelineStateSchema,
|
|
271
|
+
wire: {
|
|
272
|
+
viewSchema: timelineViewSchema,
|
|
273
|
+
view,
|
|
274
|
+
},
|
|
275
|
+
init: () => ({ createdAt: 0, turns: [], current: null, maxKeptTurns: 60 }),
|
|
276
|
+
apply: (state, event) => {
|
|
277
|
+
const bounds = {
|
|
278
|
+
...DEFAULT_BOUNDS,
|
|
279
|
+
...(boundsOf === undefined ? {} : boundsOf()),
|
|
280
|
+
}
|
|
281
|
+
// Keep the retention cap inside the persisted state so old checkpoints
|
|
282
|
+
// restore with the bound that produced them; refresh it live here.
|
|
283
|
+
if (state.maxKeptTurns !== bounds.maxKeptTurns) {
|
|
284
|
+
state = { ...state, maxKeptTurns: bounds.maxKeptTurns }
|
|
285
|
+
}
|
|
286
|
+
return applyTimeline(state, event, bounds)
|
|
287
|
+
},
|
|
288
|
+
// 1 → 2: turn digests gained `endReason` (from turn/end data.reason).
|
|
289
|
+
// 2 → 3: turn digests gained `enrichment` (the coach's pre-send context note).
|
|
290
|
+
stateVersion: 3,
|
|
291
|
+
}
|
|
292
|
+
return definition
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Strip the fold-internal provisional field before anything leaves the fold. */
|
|
296
|
+
function stripProvisional(turn) {
|
|
297
|
+
if (turn === null || turn === undefined) return turn
|
|
298
|
+
const { provisionalPrompt, ...rest } = turn
|
|
299
|
+
return rest
|
|
300
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — host half entry.
|
|
5
|
+
*
|
|
6
|
+
* Loaded by the profile's bundle patch row (`cordis.patch.yml`):
|
|
7
|
+
* - registers the `tacitTimeline` session projection (the trajectory
|
|
8
|
+
* fold the browser reads via useProjection);
|
|
9
|
+
* - provides the `tacit` service and its /api/tacit/* routes;
|
|
10
|
+
* - injects the learned directives as a system-prompt section;
|
|
11
|
+
* - stores everything under $DSH_HOME/storages/tacit/.
|
|
12
|
+
*
|
|
13
|
+
* The plugin shares the harness process, so it only talks to services through
|
|
14
|
+
* the duck-typed `ctx` — no cordis runtime imports, no second instance of
|
|
15
|
+
* anything stateful. API keys are handled entirely by the harness's own LLM
|
|
16
|
+
* service.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
22
|
+
import { Config } from './schema.js'
|
|
23
|
+
import { createTimelineDefinition } from './fold.js'
|
|
24
|
+
import { CoachStore } from './store.js'
|
|
25
|
+
import { createCoachService, mergeConfig } from './service.js'
|
|
26
|
+
import { registerWebRoutes } from './routes.js'
|
|
27
|
+
|
|
28
|
+
export const name = 'tacit'
|
|
29
|
+
export { Config }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One-time adoption of the plugin's previous name: an existing
|
|
33
|
+
* storages/prompt-coach directory becomes storages/tacit (rename only —
|
|
34
|
+
* nothing is deleted; when both exist the newer one wins untouched).
|
|
35
|
+
* Safe to drop after 0.3.0 — every dsh-prompt-coach user has migrated by then.
|
|
36
|
+
*/
|
|
37
|
+
export function migrateLegacyStorage(dshHome) {
|
|
38
|
+
const legacy = path.join(dshHome, 'storages', 'prompt-coach')
|
|
39
|
+
const current = path.join(dshHome, 'storages', 'tacit')
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(legacy) && !fs.existsSync(current)) fs.renameSync(legacy, current)
|
|
42
|
+
} catch {
|
|
43
|
+
// Best effort: a failed rename just means a fresh profile.
|
|
44
|
+
}
|
|
45
|
+
return current
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function apply(ctx, config) {
|
|
49
|
+
const store = new CoachStore(migrateLegacyStorage(resolveDshHome()))
|
|
50
|
+
const effectiveConfig = () => mergeConfig(config, store.configPatch())
|
|
51
|
+
|
|
52
|
+
// Trajectory fold. Guarded inject so assemblies without the projection
|
|
53
|
+
// registry (headless) simply skip this half instead of failing.
|
|
54
|
+
ctx.inject(['sessionProjections'], (scope) => {
|
|
55
|
+
scope.sessionProjections.register(createTimelineDefinition(() => effectiveConfig()))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Browser-facing service + routes.
|
|
59
|
+
const service = createCoachService(ctx, store, effectiveConfig)
|
|
60
|
+
ctx.provide('tacit', service)
|
|
61
|
+
registerWebRoutes(ctx, service)
|
|
62
|
+
|
|
63
|
+
// Ambient steering: the learned directives ride every session's system
|
|
64
|
+
// prompt (order 60: after the persona, before tool guidance). Guarded
|
|
65
|
+
// inject — assemblies without a system-prompt service simply skip it.
|
|
66
|
+
ctx.inject(['systemPrompt'], (scope) => {
|
|
67
|
+
if (scope.systemPrompt === undefined || scope.systemPrompt === null || typeof scope.systemPrompt.section !== 'function') return
|
|
68
|
+
scope.systemPrompt.section({
|
|
69
|
+
name: 'tacit:steering',
|
|
70
|
+
order: 60,
|
|
71
|
+
text: (assemble) => service.steeringText(assemble),
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Opt-in pre-send enrichment (config.enrichPrompts, default off): append a
|
|
76
|
+
// learned context note to the first step of a turn; never rewrites the
|
|
77
|
+
// user's message. The listener itself is a no-op while the option is off.
|
|
78
|
+
if (typeof ctx.on === 'function') {
|
|
79
|
+
const off = ctx.on('agent/pre-step', (payload, next) => service.preStep(payload, next))
|
|
80
|
+
if (typeof off === 'function') ctx.effect(() => off, 'tacit: pre-step enrichment')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.effect(() => () => {
|
|
84
|
+
// Nothing to flush: all writes are atomic and complete at call time.
|
|
85
|
+
}, 'tacit: dispose')
|
|
86
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — browser-facing HTTP routes on the harness's own web
|
|
5
|
+
* server (the same transport dsh-memento's panel uses). No third-party
|
|
6
|
+
* server, no extra port: the GUI already talks to these /api/* routes on the
|
|
7
|
+
* harness origin.
|
|
8
|
+
*
|
|
9
|
+
* Registered through the guarded `webServer` service; disposers are collected
|
|
10
|
+
* into one ctx.effect so a fiber unload removes every route.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function withService(ctx, serviceName, fn) {
|
|
14
|
+
const existing = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined
|
|
15
|
+
if (existing !== undefined && existing !== null) {
|
|
16
|
+
fn(existing)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
if (ctx.on !== undefined && typeof ctx.on === 'function') {
|
|
20
|
+
const off = ctx.on('internal/service', (name) => {
|
|
21
|
+
if (name !== serviceName) return
|
|
22
|
+
const service = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined
|
|
23
|
+
if (service !== undefined && service !== null) {
|
|
24
|
+
off()
|
|
25
|
+
fn(service)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sendJson(res, status, value) {
|
|
32
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
33
|
+
res.end(JSON.stringify(value))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read a JSON request body (bounded); never rejects — returns {} on any problem. */
|
|
37
|
+
async function readJsonBody(req) {
|
|
38
|
+
const chunks = []
|
|
39
|
+
let bytes = 0
|
|
40
|
+
try {
|
|
41
|
+
for await (const chunk of req) {
|
|
42
|
+
if (chunk === null || chunk === undefined) continue
|
|
43
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
44
|
+
bytes += buffer.length
|
|
45
|
+
if (bytes > 256 * 1024) return null
|
|
46
|
+
chunks.push(buffer)
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
52
|
+
if (raw.trim() === '') return {}
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(raw)
|
|
55
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Cross-site request guard. The harness web server has no origin policy, and
|
|
63
|
+
* Tacit's routes write into the agent's system prompt (directives) and spend
|
|
64
|
+
* money (analyze/improve/bootstrap) — so a web page must not be able to drive
|
|
65
|
+
* them from another origin. Browsers block cross-origin `application/json`
|
|
66
|
+
* fetches at the CORS preflight; this closes the remaining "simple request"
|
|
67
|
+
* shapes (forms, text/plain) and honours the fetch-metadata headers.
|
|
68
|
+
* Non-browser callers (curl, the smoke script) carry none of these headers and
|
|
69
|
+
* pass; the browser client always sends application/json from the same origin.
|
|
70
|
+
*/
|
|
71
|
+
function crossSiteReason(req) {
|
|
72
|
+
const headers = req !== null && typeof req === 'object' && req.headers !== null && typeof req.headers === 'object' ? req.headers : {}
|
|
73
|
+
const site = typeof headers['sec-fetch-site'] === 'string' ? headers['sec-fetch-site'].toLowerCase() : ''
|
|
74
|
+
if (site !== '' && site !== 'same-origin' && site !== 'none') return 'sec-fetch-site'
|
|
75
|
+
const origin = typeof headers.origin === 'string' ? headers.origin : ''
|
|
76
|
+
const host = typeof headers.host === 'string' ? headers.host : ''
|
|
77
|
+
if (origin !== '' && origin !== 'null' && host !== '') {
|
|
78
|
+
let originHost = ''
|
|
79
|
+
try { originHost = new URL(origin).host } catch { originHost = '' }
|
|
80
|
+
if (originHost !== host) return 'origin'
|
|
81
|
+
}
|
|
82
|
+
const type = typeof headers['content-type'] === 'string' ? headers['content-type'].toLowerCase() : ''
|
|
83
|
+
if (type !== '' && !type.startsWith('application/json')) return 'content-type'
|
|
84
|
+
return ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register the /api/tacit/* routes against the harness web server.
|
|
89
|
+
* `service` is the object returned by createCoachService.
|
|
90
|
+
*/
|
|
91
|
+
export function registerWebRoutes(ctx, service) {
|
|
92
|
+
withService(ctx, 'webServer', (webServer) => {
|
|
93
|
+
if (webServer === null || typeof webServer.register !== 'function') return
|
|
94
|
+
const disposers = []
|
|
95
|
+
const route = (method, pathName, handler) => {
|
|
96
|
+
disposers.push(webServer.register({
|
|
97
|
+
kind: 'exact',
|
|
98
|
+
path: pathName,
|
|
99
|
+
handler: async (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
if (typeof req.method === 'string' && req.method.toUpperCase() !== method) {
|
|
102
|
+
sendJson(res, 405, { ok: false, code: 'bad-request', detail: 'method' })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const reason = crossSiteReason(req)
|
|
106
|
+
if (reason !== '') {
|
|
107
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', detail: reason })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const body = await readJsonBody(req)
|
|
111
|
+
if (body === null) {
|
|
112
|
+
sendJson(res, 400, { ok: false, code: 'bad-json', detail: '' })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const result = await handler(body)
|
|
116
|
+
// Malformed or orphaned feedback/applied requests are rejected
|
|
117
|
+
// with an HTTP 400 (soft envelopes keep the other codes at 200).
|
|
118
|
+
const rejected = result !== null && typeof result === 'object' && result.ok === false
|
|
119
|
+
&& (result.code === 'bad-request' || result.code === 'unknown-rewrite')
|
|
120
|
+
sendJson(res, rejected ? 400 : 200, result)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
|
|
123
|
+
sendJson(res, 500, { ok: false, code: 'internal', detail })
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
}))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
route('POST', '/api/tacit/state', () => service.getState())
|
|
130
|
+
route('POST', '/api/tacit/reports', (body) => service.getReports(body))
|
|
131
|
+
route('POST', '/api/tacit/history', (body) => service.listHistory(body))
|
|
132
|
+
route('POST', '/api/tacit/analyze', (body) => service.analyzeTurn(body))
|
|
133
|
+
route('POST', '/api/tacit/improve', (body) => service.improveDraft(body))
|
|
134
|
+
route('POST', '/api/tacit/feedback', (body) => service.feedback(body))
|
|
135
|
+
route('POST', '/api/tacit/applied', (body) => service.applied(body))
|
|
136
|
+
route('POST', '/api/tacit/directives', (body) => service.directives(body))
|
|
137
|
+
route('POST', '/api/tacit/stats', (body) => service.stats(body))
|
|
138
|
+
route('POST', '/api/tacit/bootstrap', (body) => service.bootstrap(body))
|
|
139
|
+
route('POST', '/api/tacit/config', (body) => service.updateConfig(body))
|
|
140
|
+
route('POST', '/api/tacit/clear', () => service.clearReports())
|
|
141
|
+
|
|
142
|
+
ctx.effect(() => () => {
|
|
143
|
+
for (const dispose of disposers.splice(0).reverse()) {
|
|
144
|
+
try {
|
|
145
|
+
dispose?.()
|
|
146
|
+
} catch {
|
|
147
|
+
// Best-effort teardown.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}, 'tacit: web routes')
|
|
151
|
+
})
|
|
152
|
+
}
|