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/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
+ }