dsh-harbor-evolution 0.7.2 → 0.8.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.
@@ -0,0 +1,194 @@
1
+ import { chmod, lstat, mkdir, mkdtemp, realpath, rename, rm, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { canonicalDigest } from './session-selection.js'
5
+ import { DEFAULT_REDACTION_POLICY } from './session-redaction.js'
6
+
7
+ function isoCompact(now) {
8
+ return now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '')
9
+ }
10
+
11
+ function routeName(route) {
12
+ return `${route.provider}/${route.model}`
13
+ }
14
+
15
+ export function buildHistoricalGenerationBatch({ projectRoot, selections, observations, limit = 10, createdAfter, now = new Date() }) {
16
+ if (!Array.isArray(selections) || !selections.length || selections.length > 10) {
17
+ throw new Error('HISTORICAL_BATCH_SIZE_INVALID: a batch requires 1 to 10 Session observations')
18
+ }
19
+ if (!Array.isArray(observations) || observations.length !== selections.length) {
20
+ throw new Error('HISTORICAL_BATCH_OBSERVATION_MISMATCH')
21
+ }
22
+ const seed = canonicalDigest(
23
+ observations.map(observation => observation.digest),
24
+ 'harbor-dsh-historical-batch-id-v1',
25
+ ).slice('sha256:'.length, 'sha256:'.length + 8)
26
+ const batchId = `recent-${isoCompact(now)}-${seed}`.toLowerCase()
27
+ const records = selections.map((selection, index) => {
28
+ const observation = observations[index]
29
+ if (observation.trial_id !== selection.trialId) {
30
+ throw new Error('HISTORICAL_BATCH_TRIAL_ID_MISMATCH')
31
+ }
32
+ return {
33
+ trial_id: selection.trialId,
34
+ record_kind: 'dsh-session',
35
+ source_ref: selection.sourceRef,
36
+ captured_through_seq: selection.capturedThroughSeq,
37
+ source_digest: selection.sourceDigest,
38
+ observation_digest: observation.digest,
39
+ last_activity_at: observation.source.last_activity_at,
40
+ generator: {
41
+ agent_preset: observation.generator.agent_preset,
42
+ model_routes: [...new Map(observation.generator.model_segments.map(segment => {
43
+ const route = {
44
+ provider: segment.provider,
45
+ model: segment.model,
46
+ ...(segment.reasoning_effort ? { reasoning_effort: segment.reasoning_effort } : {}),
47
+ }
48
+ return [routeName(route), route]
49
+ })).values()],
50
+ homogeneous: new Set(observation.generator.model_segments.map(routeName)).size <= 1,
51
+ },
52
+ observation_path: `sessions/${selection.trialId}.json`,
53
+ }
54
+ })
55
+ const presets = [...new Set(records.map(record => record.generator.agent_preset).filter(Boolean))].sort()
56
+ const routes = [...new Set(records.flatMap(record => record.generator.model_routes.map(routeName)))].sort()
57
+ const batch = {
58
+ schema_version: 1,
59
+ protocol: 'historical-generation-batch/v1',
60
+ batch_id: batchId,
61
+ created_at: now.toISOString(),
62
+ project: {
63
+ cwd_digest: canonicalDigest({ cwd: path.resolve(projectRoot) }, 'harbor-dsh-project-cwd-v1'),
64
+ },
65
+ selection: {
66
+ scope: 'exact-cwd',
67
+ order: 'last-activity-desc',
68
+ requested_limit: limit,
69
+ selected_count: records.length,
70
+ current_session_excluded: true,
71
+ ...(createdAfter === undefined ? {} : { created_after: new Date(createdAfter).toISOString() }),
72
+ },
73
+ source: {
74
+ kind: 'dsh-session',
75
+ adapter: 'dsh-session-query',
76
+ session_format_versions: [...new Set(selections.map(item => item.header.version))].sort(),
77
+ },
78
+ redaction_policy: DEFAULT_REDACTION_POLICY,
79
+ records,
80
+ generator_population: {
81
+ homogeneous: presets.length <= 1 && routes.length <= 1,
82
+ agent_presets: presets,
83
+ model_routes: routes,
84
+ },
85
+ }
86
+ const serialized = JSON.stringify(batch)
87
+ for (const selection of selections) {
88
+ const canary = String(selection.rawSessionId ?? '')
89
+ if (canary.length >= 8 && serialized.includes(canary)) {
90
+ throw new Error('SESSION_REDACTION_FAILED: a raw Session id survived Batch materialization')
91
+ }
92
+ }
93
+ batch.digest = canonicalDigest(batch, 'harbor-dsh-historical-generation-batch-v1')
94
+ return batch
95
+ }
96
+
97
+ async function writePrivateJson(pathname, value) {
98
+ await writeFile(pathname, `${JSON.stringify(value, null, 2)}\n`, {
99
+ encoding: 'utf8',
100
+ mode: 0o600,
101
+ flag: 'wx',
102
+ })
103
+ await chmod(pathname, 0o600)
104
+ }
105
+
106
+ function unsafePrivateEvidencePath(label) {
107
+ return new Error(
108
+ `PRIVATE_EVIDENCE_PATH_UNSAFE: ${label} must be a real directory inside projectRoot, not a symlink or non-directory`,
109
+ )
110
+ }
111
+
112
+ async function ensureSafeDirectory(directory, {
113
+ parentReal,
114
+ label,
115
+ mode = 0o700,
116
+ enforceMode = true,
117
+ }) {
118
+ try {
119
+ await mkdir(directory, { mode })
120
+ } catch (error) {
121
+ if (error?.code !== 'EEXIST') throw error
122
+ }
123
+ const details = await lstat(directory)
124
+ if (details.isSymbolicLink() || !details.isDirectory()) {
125
+ throw unsafePrivateEvidencePath(label)
126
+ }
127
+ const resolved = await realpath(directory)
128
+ if (path.dirname(resolved) !== parentReal) {
129
+ throw unsafePrivateEvidencePath(label)
130
+ }
131
+ if (enforceMode) await chmod(directory, mode)
132
+ return resolved
133
+ }
134
+
135
+ export async function writePrivateHistoricalBatch({ projectRoot, batch, observations }) {
136
+ const resolvedProjectRoot = path.resolve(projectRoot)
137
+ const projectReal = await realpath(resolvedProjectRoot)
138
+ const projectDetails = await lstat(projectReal)
139
+ if (!projectDetails.isDirectory()) {
140
+ throw unsafePrivateEvidencePath('projectRoot')
141
+ }
142
+ const harborRoot = path.join(resolvedProjectRoot, '.harbor')
143
+ const harborReal = await ensureSafeDirectory(harborRoot, {
144
+ parentReal: projectReal,
145
+ label: '.harbor',
146
+ enforceMode: false,
147
+ })
148
+ const privateRoot = path.join(harborRoot, 'private')
149
+ const privateReal = await ensureSafeDirectory(privateRoot, {
150
+ parentReal: harborReal,
151
+ label: '.harbor/private',
152
+ })
153
+ const batchesRoot = path.join(privateRoot, 'session-batches')
154
+ try {
155
+ await writeFile(path.join(privateRoot, '.gitignore'), '*\n!.gitignore\n', {
156
+ encoding: 'utf8', mode: 0o600, flag: 'wx',
157
+ })
158
+ } catch (error) {
159
+ if (error?.code !== 'EEXIST') throw error
160
+ }
161
+ await ensureSafeDirectory(batchesRoot, {
162
+ parentReal: privateReal,
163
+ label: '.harbor/private/session-batches',
164
+ })
165
+ const staging = await mkdtemp(path.join(batchesRoot, '.staging-'))
166
+ const target = path.join(batchesRoot, batch.batch_id)
167
+ try {
168
+ await chmod(staging, 0o700)
169
+ const sessions = path.join(staging, 'sessions')
170
+ await mkdir(sessions, { mode: 0o700 })
171
+ for (const observation of observations) {
172
+ await writePrivateJson(path.join(sessions, `${observation.trial_id}.json`), observation)
173
+ }
174
+ const redactionReport = {
175
+ schema_version: 1,
176
+ policy: batch.redaction_policy,
177
+ sessions: observations.map(observation => ({
178
+ trial_id: observation.trial_id,
179
+ ...observation.redaction,
180
+ })),
181
+ }
182
+ await writePrivateJson(path.join(staging, 'session-redaction-report.json'), redactionReport)
183
+ await writePrivateJson(path.join(staging, 'historical-generation-batch.json'), batch)
184
+ await rename(staging, target)
185
+ return {
186
+ batchDir: target,
187
+ batchPath: path.join(target, 'historical-generation-batch.json'),
188
+ redactionReportPath: path.join(target, 'session-redaction-report.json'),
189
+ }
190
+ } catch (error) {
191
+ await rm(staging, { recursive: true, force: true })
192
+ throw error
193
+ }
194
+ }
@@ -0,0 +1,161 @@
1
+ function isAppendOrigin(event) {
2
+ return event?.surfaceOp === undefined || event.surfaceOp === 'append'
3
+ }
4
+
5
+ function finiteInteger(value) {
6
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined
7
+ }
8
+
9
+ function visibleTextBlocks(message) {
10
+ return Array.isArray(message?.content)
11
+ ? message.content.filter(block => block?.type === 'text' && typeof block.text === 'string' && block.text.trim())
12
+ : []
13
+ }
14
+
15
+ function routeKey(route) {
16
+ return `${route.provider}\u0000${route.model}\u0000${route.reasoning_effort ?? ''}`
17
+ }
18
+
19
+ /**
20
+ * Resolve the composition that actually produced the Session history.
21
+ *
22
+ * DSH freezes the creation-time preset in the Session header, but a blank
23
+ * Session may select another preset before its first turn. That selection is
24
+ * durable evidence and the newest `agent-preset/selected` event wins.
25
+ */
26
+ export function resolveEffectiveAgentPreset(header, events) {
27
+ for (let index = Array.isArray(events) ? events.length - 1 : -1; index >= 0; index -= 1) {
28
+ const event = events[index]
29
+ if (event?.type === 'agent-preset/selected') {
30
+ return typeof event.data?.agentPreset === 'string'
31
+ ? event.data.agentPreset
32
+ : undefined
33
+ }
34
+ }
35
+ return typeof header?.agentPreset === 'string' ? header.agentPreset : undefined
36
+ }
37
+
38
+ /**
39
+ * Fold only non-content selection facts from a validated DSH Session log.
40
+ * Transcript text deliberately never enters this projection.
41
+ */
42
+ export function foldSessionDiagnosticIndex(events, header) {
43
+ const openTurns = new Set()
44
+ const seenTurns = new Set()
45
+ const headerRouteEvents = []
46
+ const contextRouteEvents = []
47
+ const assistantRouteEvents = []
48
+ let lastActivityAt = 0
49
+ let lastSeq = null
50
+ let humanMessageCount = 0
51
+ let assistantMessageCount = 0
52
+ let lastTurnReason = null
53
+ let hasHarborToolCall = false
54
+ let toolCallCount = 0
55
+
56
+ for (const event of Array.isArray(events) ? events : []) {
57
+ const seq = finiteInteger(event?.seq)
58
+ const time = finiteInteger(event?.time)
59
+ if (seq !== undefined) lastSeq = lastSeq === null ? seq : Math.max(lastSeq, seq)
60
+ if (time !== undefined) lastActivityAt = Math.max(lastActivityAt, time)
61
+
62
+ if (event?.type === 'turn/start') {
63
+ const turn = finiteInteger(event.data?.turn)
64
+ if (turn !== undefined) {
65
+ openTurns.add(turn)
66
+ seenTurns.add(turn)
67
+ }
68
+ } else if (event?.type === 'turn/end') {
69
+ const turn = finiteInteger(event.data?.turn)
70
+ if (turn !== undefined) {
71
+ openTurns.delete(turn)
72
+ seenTurns.add(turn)
73
+ }
74
+ lastTurnReason = typeof event.data?.reason?.kind === 'string'
75
+ ? event.data.reason.kind
76
+ : null
77
+ } else if (event?.type === 'user/message' && isAppendOrigin(event)) {
78
+ if (event.data?.source?.kind === 'user' && visibleTextBlocks(event.data).length) {
79
+ humanMessageCount += 1
80
+ }
81
+ } else if (event?.type === 'assistant/message' && isAppendOrigin(event)) {
82
+ if (visibleTextBlocks(event.data?.message).length) assistantMessageCount += 1
83
+ const source = event.data?.message?.source
84
+ if (source?.kind === 'model' && typeof source.provider === 'string' && typeof source.model === 'string') {
85
+ const route = { provider: source.provider, model: source.model }
86
+ assistantRouteEvents.push({ seq: seq ?? 0, ...route })
87
+ }
88
+ } else if (event?.type === 'tool/call') {
89
+ toolCallCount += 1
90
+ const name = String(event.data?.name ?? '')
91
+ if (name.startsWith('harbor_')) hasHarborToolCall = true
92
+ } else if (event?.type === 'request/context') {
93
+ const { provider, model } = event.data ?? {}
94
+ if (typeof provider === 'string' && typeof model === 'string') {
95
+ const route = { provider, model }
96
+ contextRouteEvents.push({ seq: seq ?? 0, ...route })
97
+ }
98
+ } else if (event?.type === 'request/header') {
99
+ const config = event.data?.header?.config
100
+ if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
101
+ const route = {
102
+ provider: config.provider,
103
+ model: config.model,
104
+ ...(typeof config.reasoningEffort === 'string'
105
+ ? { reasoning_effort: config.reasoningEffort }
106
+ : {}),
107
+ }
108
+ headerRouteEvents.push({ seq: seq ?? 0, ...route })
109
+ }
110
+ }
111
+ }
112
+
113
+ // request/header is the canonical full request identity. Fall back to
114
+ // request/context or assembled assistant provenance only for older logs.
115
+ const routeEvents = headerRouteEvents.length
116
+ ? headerRouteEvents
117
+ : contextRouteEvents.length ? contextRouteEvents : assistantRouteEvents
118
+ const modelSegments = []
119
+ for (const route of routeEvents) {
120
+ const previous = modelSegments.at(-1)
121
+ if (previous && routeKey(previous) === routeKey(route)) continue
122
+ modelSegments.push({
123
+ from_seq: route.seq,
124
+ through_seq: lastSeq ?? route.seq,
125
+ provider: route.provider,
126
+ model: route.model,
127
+ ...(route.reasoning_effort ? { reasoning_effort: route.reasoning_effort } : {}),
128
+ })
129
+ }
130
+ for (let index = 0; index < modelSegments.length - 1; index += 1) {
131
+ modelSegments[index].through_seq = Math.max(
132
+ modelSegments[index].from_seq,
133
+ modelSegments[index + 1].from_seq - 1,
134
+ )
135
+ }
136
+ const routes = new Map()
137
+ for (const segment of modelSegments) {
138
+ const route = {
139
+ provider: segment.provider,
140
+ model: segment.model,
141
+ ...(segment.reasoning_effort ? { reasoning_effort: segment.reasoning_effort } : {}),
142
+ }
143
+ routes.set(routeKey(route), route)
144
+ }
145
+
146
+ const effectiveAgentPreset = resolveEffectiveAgentPreset(header, events)
147
+ return {
148
+ lastActivityAt,
149
+ lastSeq,
150
+ openTurn: openTurns.size > 0,
151
+ turnCount: seenTurns.size,
152
+ humanMessageCount,
153
+ assistantMessageCount,
154
+ toolCallCount,
155
+ lastTurnReason,
156
+ hasHarborToolCall,
157
+ ...(effectiveAgentPreset === undefined ? {} : { effectiveAgentPreset }),
158
+ modelRoutes: [...routes.values()].sort((left, right) => routeKey(left).localeCompare(routeKey(right))),
159
+ modelSegments,
160
+ }
161
+ }
@@ -0,0 +1,311 @@
1
+ import { canonicalDigest } from './session-selection.js'
2
+
3
+ const MAX_MESSAGE_CHARS = 4_000
4
+ const MAX_TRANSCRIPT_MESSAGES = 80
5
+ const MAX_OBSERVATION_BYTES = 512 * 1024
6
+
7
+ const SECRET_PATTERNS = [
8
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi,
9
+ /\b(?:authorization\s*[:=]\s*(?:bearer\s+)?|bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
10
+ /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password|passwd)\s*[:=]\s*["']?[^\s,"'}\]]{4,}/gi,
11
+ /\b(?:sk|rk|pk)-[A-Za-z0-9_-]{12,}\b/g,
12
+ /\bgh[opusr]_[A-Za-z0-9]{20,}\b/g,
13
+ /\bAKIA[0-9A-Z]{16}\b/g,
14
+ ]
15
+
16
+ const ABSOLUTE_PATH = /(?:[A-Za-z]:\\(?:[^\s<>:"|?*]+\\)+[^\s<>:"|?*]*|\/(?:Users|home|private|tmp|var|etc|opt|Volumes|workspace)(?:\/[A-Za-z0-9._ @+-]+)+)/g
17
+
18
+ function replaceCanaries(value, canaries) {
19
+ let text = value
20
+ let replacements = 0
21
+ for (const canary of canaries) {
22
+ if (!canary || !text.includes(canary)) continue
23
+ const pieces = text.split(canary)
24
+ replacements += pieces.length - 1
25
+ text = pieces.join('[REDACTED_SESSION_ID]')
26
+ }
27
+ return { text, replacements }
28
+ }
29
+
30
+ function replaceSecrets(value, canaries = []) {
31
+ const canaryResult = replaceCanaries(value, canaries)
32
+ value = canaryResult.text
33
+ let text = value
34
+ let replacements = canaryResult.replacements
35
+ for (const pattern of SECRET_PATTERNS) {
36
+ text = text.replace(pattern, () => {
37
+ replacements += 1
38
+ return '[REDACTED_SECRET]'
39
+ })
40
+ }
41
+ text = text.replace(ABSOLUTE_PATH, () => {
42
+ replacements += 1
43
+ return '[REDACTED_PATH]'
44
+ })
45
+ return { text, replacements }
46
+ }
47
+
48
+ function sanitizeText(value, maxChars = MAX_MESSAGE_CHARS, canaries = []) {
49
+ const input = String(value ?? '')
50
+ const redacted = replaceSecrets(input, canaries)
51
+ const truncated = redacted.text.length > maxChars
52
+ return {
53
+ text: truncated ? `${redacted.text.slice(0, maxChars)}\n[TRUNCATED]` : redacted.text,
54
+ replacements: redacted.replacements,
55
+ truncated,
56
+ }
57
+ }
58
+
59
+ function isoTime(value) {
60
+ return Number.isSafeInteger(value) && value > 0 ? new Date(value).toISOString() : null
61
+ }
62
+
63
+ function appendOrigin(event) {
64
+ return event?.surfaceOp === undefined || event.surfaceOp === 'append'
65
+ }
66
+
67
+ function visibleContent(message, report, canaries) {
68
+ const content = []
69
+ for (const block of Array.isArray(message?.content) ? message.content : []) {
70
+ if (block?.type !== 'text' || typeof block.text !== 'string') continue
71
+ const sanitized = sanitizeText(block.text, MAX_MESSAGE_CHARS, canaries)
72
+ report.replacements += sanitized.replacements
73
+ if (sanitized.truncated) report.truncations += 1
74
+ if (sanitized.text.trim()) content.push({ type: 'text', text: sanitized.text })
75
+ }
76
+ return content
77
+ }
78
+
79
+ function messageRef(message) {
80
+ return canonicalDigest(
81
+ { id: typeof message?.id === 'string' ? message.id : null },
82
+ 'harbor-dsh-session-message-ref-v1',
83
+ )
84
+ }
85
+
86
+ function sanitizeIdentity(value, report, canaries) {
87
+ const sanitized = sanitizeText(value, 160, canaries)
88
+ report.replacements += sanitized.replacements
89
+ if (sanitized.truncated) report.truncations += 1
90
+ return sanitized.text
91
+ }
92
+
93
+ function modelSegments(selected, report, canaries) {
94
+ return selected.index.modelSegments.map(segment => ({
95
+ from_seq: segment.from_seq,
96
+ through_seq: segment.through_seq,
97
+ provider: sanitizeIdentity(segment.provider, report, canaries),
98
+ model: sanitizeIdentity(segment.model, report, canaries),
99
+ ...(segment.reasoning_effort
100
+ ? { reasoning_effort: sanitizeIdentity(segment.reasoning_effort, report, canaries) }
101
+ : {}),
102
+ }))
103
+ }
104
+
105
+ function toolEvidence(events) {
106
+ const results = new Map()
107
+ for (const event of events) {
108
+ if (event?.type !== 'tool/result') continue
109
+ results.set(event.data?.message?.source?.callId, event)
110
+ }
111
+ const tools = []
112
+ for (const event of events) {
113
+ if (event?.type !== 'tool/call') continue
114
+ const result = results.get(event.data?.callId)
115
+ tools.push({
116
+ event_seq: event.seq,
117
+ name: String(event.data?.name ?? 'unknown').slice(0, 160),
118
+ outcome: result?.data?.error || result?.data?.message?.content?.[0]?.isError ? 'error' : result ? 'success' : 'unknown',
119
+ error_code: typeof result?.data?.error?.code === 'string'
120
+ ? result.data.error.code.slice(0, 160)
121
+ : null,
122
+ result_summary: result ? 'Tool completed; payload intentionally omitted.' : 'No matching tool result observed.',
123
+ truncated: true,
124
+ })
125
+ }
126
+ return tools.slice(0, 200)
127
+ }
128
+
129
+ function turnEvidence(events) {
130
+ const starts = new Map()
131
+ const turns = []
132
+ for (const event of events) {
133
+ if (event?.type === 'turn/start') starts.set(event.data?.turn, event.time)
134
+ if (event?.type === 'turn/end') {
135
+ turns.push({
136
+ turn: event.data?.turn,
137
+ reason: event.data?.reason?.kind ?? 'unknown',
138
+ started_at: isoTime(starts.get(event.data?.turn)),
139
+ ended_at: isoTime(event.time),
140
+ })
141
+ }
142
+ }
143
+ return turns
144
+ }
145
+
146
+ function usageEvidence(events) {
147
+ let inputTokens = 0
148
+ let outputTokens = 0
149
+ let reported = false
150
+ for (const event of events) {
151
+ if (event?.type !== 'assistant/message' || !event.data?.usage) continue
152
+ const usage = event.data.usage
153
+ if (Number.isFinite(usage.inputTokens)) inputTokens += usage.inputTokens
154
+ if (Number.isFinite(usage.outputTokens)) outputTokens += usage.outputTokens
155
+ reported = true
156
+ }
157
+ return { input_tokens: inputTokens, output_tokens: outputTokens, reported }
158
+ }
159
+
160
+ function sanitizeFeedback(items, report, canaries) {
161
+ const output = []
162
+ for (const item of Array.isArray(items) ? items : []) {
163
+ if (!['positive', 'negative'].includes(item?.rating)) continue
164
+ const note = sanitizeText(item.note ?? '', 1_000, canaries)
165
+ report.replacements += note.replacements
166
+ if (note.truncated) report.truncations += 1
167
+ output.push({
168
+ message_ref: canonicalDigest({ id: item.messageId ?? null }, 'harbor-dsh-feedback-message-ref-v1'),
169
+ rating: item.rating,
170
+ ...(note.text.trim() ? { note: note.text } : {}),
171
+ updated_at: isoTime(item.updatedAt),
172
+ })
173
+ }
174
+ return output.slice(0, 100)
175
+ }
176
+
177
+ function assertNoSecret(value, canaries = []) {
178
+ const serialized = JSON.stringify(value)
179
+ for (const canary of canaries) {
180
+ if (canary.length >= 8 && serialized.includes(canary)) {
181
+ throw new Error('SESSION_REDACTION_FAILED: a raw Session id survived the redaction pipeline')
182
+ }
183
+ }
184
+ for (const pattern of SECRET_PATTERNS) {
185
+ pattern.lastIndex = 0
186
+ if (pattern.test(serialized)) {
187
+ throw new Error('SESSION_REDACTION_FAILED: a credential-shaped value survived the redaction pipeline')
188
+ }
189
+ }
190
+ ABSOLUTE_PATH.lastIndex = 0
191
+ if (ABSOLUTE_PATH.test(serialized)) {
192
+ throw new Error('SESSION_REDACTION_FAILED: an absolute local path survived the redaction pipeline')
193
+ }
194
+ if (Buffer.byteLength(serialized) > MAX_OBSERVATION_BYTES) {
195
+ throw new Error(`SESSION_OBSERVATION_TOO_LARGE: redacted observation exceeds ${MAX_OBSERVATION_BYTES} bytes`)
196
+ }
197
+ }
198
+
199
+ const policyWithoutDigest = {
200
+ id: 'dsh-session-default-redaction',
201
+ version: '1.0.0',
202
+ projection: 'direct-human-and-assembled-assistant-text',
203
+ tool_payloads: 'omit',
204
+ reasoning: 'omit',
205
+ attachments: 'omit',
206
+ credentials: 'redact-and-fail-closed',
207
+ }
208
+
209
+ export const DEFAULT_REDACTION_POLICY = Object.freeze({
210
+ ...policyWithoutDigest,
211
+ digest: canonicalDigest(policyWithoutDigest, 'harbor-dsh-session-redaction-policy-v1'),
212
+ })
213
+
214
+ export function buildSessionObservation(selected, feedbackItems = []) {
215
+ const report = { replacements: 0, truncations: 0, omitted_blocks: 0 }
216
+ const canaries = [String(selected.rawSessionId ?? '')].filter(Boolean)
217
+ const visibleTranscript = []
218
+ for (const event of selected.events) {
219
+ if (!appendOrigin(event)) continue
220
+ let role
221
+ let message
222
+ if (event?.type === 'user/message' && event.data?.source?.kind === 'user') {
223
+ role = 'user'
224
+ message = event.data
225
+ } else if (event?.type === 'assistant/message') {
226
+ role = 'assistant'
227
+ message = event.data?.message
228
+ } else {
229
+ continue
230
+ }
231
+ const content = visibleContent(message, report, canaries)
232
+ const originalBlocks = Array.isArray(message?.content) ? message.content.length : 0
233
+ report.omitted_blocks += Math.max(0, originalBlocks - content.length)
234
+ if (!content.length) continue
235
+ if (visibleTranscript.length >= MAX_TRANSCRIPT_MESSAGES) {
236
+ report.truncations += 1
237
+ break
238
+ }
239
+ visibleTranscript.push({
240
+ event_seq: event.seq,
241
+ message_ref: messageRef(message),
242
+ role,
243
+ content,
244
+ time: isoTime(event.time),
245
+ })
246
+ }
247
+ const initialGoal = visibleTranscript.find(message => message.role === 'user')?.content
248
+ ?.map(block => block.text).join('\n') ?? ''
249
+ const sanitizedTitle = sanitizeText(
250
+ initialGoal.split('\n').find(Boolean) ?? 'Historical DSH Session',
251
+ 120,
252
+ canaries,
253
+ )
254
+ report.replacements += sanitizedTitle.replacements
255
+ if (sanitizedTitle.truncated) report.truncations += 1
256
+
257
+ const agentPreset = selected.index.effectiveAgentPreset ?? selected.header.agentPreset
258
+ const observation = {
259
+ schema_version: 1,
260
+ protocol: 'dsh-session-observation/v1',
261
+ record_kind: 'dsh-session',
262
+ execution_mode: 'observe-existing',
263
+ trial_id: selected.trialId,
264
+ source: {
265
+ ref: selected.sourceRef,
266
+ captured_through_seq: selected.capturedThroughSeq,
267
+ source_digest: selected.sourceDigest,
268
+ created_at: isoTime(selected.header.createdAt),
269
+ last_activity_at: isoTime(selected.index.lastActivityAt),
270
+ last_turn_reason: selected.index.lastTurnReason,
271
+ session_format_version: selected.header.version,
272
+ },
273
+ generator: {
274
+ agent_preset: agentPreset
275
+ ? sanitizeIdentity(agentPreset, report, canaries)
276
+ : null,
277
+ model_segments: modelSegments(selected, report, canaries),
278
+ },
279
+ task: {
280
+ title: sanitizedTitle.text || 'Historical DSH Session',
281
+ initial_user_goal: initialGoal,
282
+ turn_count: selected.index.turnCount,
283
+ },
284
+ visible_transcript: visibleTranscript,
285
+ execution: {
286
+ tools: toolEvidence(selected.events),
287
+ turns: turnEvidence(selected.events),
288
+ usage: usageEvidence(selected.events),
289
+ },
290
+ feedback: { items: sanitizeFeedback(feedbackItems, report, canaries) },
291
+ completeness: {
292
+ transcript_complete: visibleTranscript.length < MAX_TRANSCRIPT_MESSAGES,
293
+ tool_payloads_complete: false,
294
+ attachments_complete: false,
295
+ truncations: report.truncations ? [`${report.truncations} bounded text projection(s)`] : [],
296
+ },
297
+ redaction: report,
298
+ }
299
+ observation.digest = canonicalDigest(observation, 'harbor-dsh-session-observation-v1')
300
+ assertNoSecret(observation, canaries)
301
+ return observation
302
+ }
303
+
304
+ export function scanForCredentialCanaries(value) {
305
+ try {
306
+ assertNoSecret(value)
307
+ return []
308
+ } catch (error) {
309
+ return [error.message]
310
+ }
311
+ }