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.
- package/README.md +16 -4
- package/index.js +72 -6
- package/lib/candidate.js +58 -5
- package/lib/client.js +283 -63
- package/lib/dashboard.js +307 -34
- package/lib/evolution.js +328 -20
- package/lib/model-runtime.js +53 -8
- package/lib/runtime-identity.js +7 -0
- package/lib/service.js +187 -33
- package/lib/session-diagnostic.js +320 -0
- package/lib/session-materializer.js +194 -0
- package/lib/session-projection.js +161 -0
- package/lib/session-redaction.js +311 -0
- package/lib/session-selection.js +294 -0
- package/lib/setup.js +11 -5
- package/lib/version.js +128 -0
- package/lib/web.js +5 -1
- package/package.json +13 -3
- package/schemas/dsh-session-observation.schema.json +69 -0
- package/schemas/evaluation-result-v2.schema.json +45 -0
- package/schemas/historical-evaluation-context.schema.json +66 -0
- package/schemas/historical-evaluation-summary.schema.json +49 -0
- package/schemas/historical-generation-batch.schema.json +76 -0
- package/skills/evolve-agent-with-harbor/SKILL.md +127 -13
- package/skills/evolve-agent-with-harbor/evals/evals.json +57 -9
- package/skills/evolve-agent-with-harbor/references/evaluator-upgrade.md +34 -1
- package/skills/evolve-agent-with-harbor/references/initialization.md +9 -2
|
@@ -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
|
+
}
|