rcs-context 0.0.0-stage → 0.1.39

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,430 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { posix, win32 } from 'node:path'
4
+
5
+ const TOOL_OPERATIONS = new Map([
6
+ ['create', 'create'],
7
+ ['write', 'write'],
8
+ ['edit', 'write'],
9
+ ['str_replace', 'write'],
10
+ ['str_replace_editor', 'write'],
11
+ ['read', 'read'],
12
+ ['stat', 'stat'],
13
+ ['delete', 'delete'],
14
+ ['delete_file', 'delete'],
15
+ ['rename', 'rename'],
16
+ ['rename_file', 'rename'],
17
+ ])
18
+
19
+ const WINDOWS = 'win32'
20
+
21
+ function stripTrailingSeparator(value) {
22
+ if (value === '/') return value
23
+ if (/^[A-Za-z]:\/$/.test(value)) return value
24
+ const stripped = value.replace(/\/+$/, '')
25
+ return stripped === '' ? '/' : stripped
26
+ }
27
+
28
+ // File entity identity: resolve against cwd when provided, unify separators to '/',
29
+ // strip a trailing separator except at a root, and fold case only on Windows.
30
+ // Relative tool paths have no stable identity without an explicit cwd, so they are
31
+ // refused instead of becoming alias entities. Display/original paths stay with the
32
+ // caller; this helper never rewrites them.
33
+ export function fileEntityRef(path, { cwd = null, platform = process.platform } = {}) {
34
+ if (typeof path !== 'string' || path.trim() === '') return null
35
+ const windows = platform === WINDOWS
36
+ const api = windows ? win32 : posix
37
+ if (!cwd && !api.isAbsolute(path)) return null
38
+ // resolve() removes `.`/`..` segments and normalizes separators for both absolute
39
+ // and cwd-relative paths while the caller retains the original display path.
40
+ const resolvedPath = cwd ? api.resolve(cwd, path) : api.resolve(path)
41
+ const unified = windows ? resolvedPath.replace(/\\/g, '/') : resolvedPath
42
+ const normalizedPath = stripTrailingSeparator(unified)
43
+ return {
44
+ entityId: windows ? normalizedPath.toLowerCase() : normalizedPath,
45
+ displayPath: path,
46
+ resolvedPath,
47
+ normalizedPath,
48
+ }
49
+ }
50
+
51
+ function scopeOf(...candidates) {
52
+ for (const candidate of candidates) {
53
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate
54
+ }
55
+ throw new TypeError('observation scope must be a non-empty string')
56
+ }
57
+
58
+ function structuredArguments(call) {
59
+ const args = call?.arguments ?? call?.args
60
+ if (args && typeof args === 'object' && !Array.isArray(args)) return args
61
+ if (typeof args === 'string') {
62
+ try {
63
+ const parsed = JSON.parse(args)
64
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
65
+ } catch {
66
+ return {}
67
+ }
68
+ }
69
+ return {}
70
+ }
71
+
72
+ function callIdOf(call) {
73
+ const id = call?.callId ?? call?.toolCallId ?? call?.id
74
+ return id === undefined || id === null || id === '' ? 'unknown' : String(id)
75
+ }
76
+
77
+ function finite(...values) {
78
+ return values.find((value) => Number.isFinite(value))
79
+ }
80
+
81
+ function toolOutcome(result) {
82
+ if (!result || typeof result !== 'object') return 'unknown'
83
+ const status = typeof result.status === 'string' ? result.status.toLowerCase() : null
84
+ if (result.isError === true || result.ok === false || result.success === false ||
85
+ result.error != null || status === 'error' || status === 'failure' || status === 'failed') {
86
+ return 'failure'
87
+ }
88
+ if (result.isError === false || result.ok === true || result.success === true ||
89
+ status === 'ok' || status === 'success' || status === 'succeeded') {
90
+ return 'success'
91
+ }
92
+ return 'unknown'
93
+ }
94
+
95
+ function measuredValue(result) {
96
+ if (!result || typeof result !== 'object') return {}
97
+ const nested = result.value && typeof result.value === 'object' && !Array.isArray(result.value)
98
+ ? result.value
99
+ : {}
100
+ const value = {}
101
+ const exists = nested.exists ?? result.exists
102
+ const size = nested.size ?? result.size
103
+ const mtime = nested.mtime ?? nested.mtimeMs ?? result.mtime ?? result.mtimeMs
104
+ const sha256 = nested.sha256 ?? result.sha256
105
+ if (typeof exists === 'boolean') value.exists = exists
106
+ if (Number.isFinite(size) && size >= 0) value.size = size
107
+ if (Number.isFinite(mtime)) value.mtime = mtime
108
+ if (typeof sha256 === 'string' && /^[0-9a-fA-F]{64}$/.test(sha256)) value.sha256 = sha256.toLowerCase()
109
+ return value
110
+ }
111
+
112
+ export function observationFromTool(input) {
113
+ const { call, result, sid, seq, recordedAt, cwd = null, platform = process.platform } = input
114
+ const name = String(call?.name ?? call?.toolName ?? call?.action ?? '')
115
+ const op = TOOL_OPERATIONS.get(name) ?? 'task'
116
+ const scope = scopeOf(input.scope, call?.scope, result?.scope)
117
+ const args = structuredArguments(call)
118
+ const callId = callIdOf(call)
119
+ const eventKey = `${String(sid)}:${String(seq)}:${callId}`
120
+ const subjectRef = fileEntityRef(args.path ?? args.filePath ?? args.file_path, { cwd, platform })
121
+ const targetRef = fileEntityRef(args.newPath ?? args.new_path ?? args.destination ?? args.targetPath,
122
+ { cwd, platform })
123
+ // A file operation without a structured path is not evidence about any file: it stays
124
+ // unknown and unplaced instead of falling back to a synthetic task entity that would
125
+ // later read as a real "current file" state.
126
+ const structured = op !== 'task' && subjectRef !== null && (op !== 'rename' || targetRef !== null)
127
+ const outcome = structured ? toolOutcome(result) : 'unknown'
128
+ const validAt = finite(result?.validAt, result?.valid_at, result?.ts,
129
+ call?.validAt, call?.valid_at, call?.ts, recordedAt)
130
+
131
+ return {
132
+ eventKey,
133
+ scope,
134
+ sid: String(sid),
135
+ seq,
136
+ validAt,
137
+ recordedAt,
138
+ op,
139
+ outcome,
140
+ subjectEntityId: op === 'task'
141
+ ? `task:${eventKey}`
142
+ : structured ? subjectRef.entityId : `unresolved:${eventKey}`,
143
+ targetEntityId: structured && op === 'rename' ? targetRef.entityId : null,
144
+ value: outcome === 'success' ? measuredValue(result) : {},
145
+ generationHints: { callId },
146
+ rawRef: eventKey,
147
+ rawPayload: { call, result },
148
+ source: {
149
+ kind: 'observation',
150
+ authority: 'host',
151
+ displayPath: structured ? subjectRef.displayPath : null,
152
+ resolvedPath: structured ? subjectRef.resolvedPath : null,
153
+ ...(structured && op === 'rename'
154
+ ? { targetDisplayPath: targetRef.displayPath, targetResolvedPath: targetRef.resolvedPath }
155
+ : {}),
156
+ },
157
+ extractorVersion: 'host-v1',
158
+ }
159
+ }
160
+
161
+ function errorPayload(error) {
162
+ const details = error && typeof error === 'object' ? { ...error } : {}
163
+ for (const field of ['name', 'message', 'code', 'errno', 'syscall', 'path']) {
164
+ if (error?.[field] !== undefined) details[field] = error[field]
165
+ }
166
+ if (details.name === undefined) details.name = 'Error'
167
+ if (details.message === undefined) details.message = String(error)
168
+ return details
169
+ }
170
+
171
+ function probeBase({ scope, probeId, validAt, recordedAt }, path, resolvedPath, platform) {
172
+ const reference = fileEntityRef(resolvedPath ?? path, { platform })
173
+ return {
174
+ eventKey: String(probeId),
175
+ scope,
176
+ sid: 'probe',
177
+ seq: 0,
178
+ validAt,
179
+ recordedAt,
180
+ subjectEntityId: reference?.entityId ?? String(resolvedPath ?? path),
181
+ targetEntityId: null,
182
+ generationHints: null,
183
+ rawRef: null,
184
+ source: {
185
+ kind: 'probe',
186
+ authority: 'probe',
187
+ displayPath: path,
188
+ resolvedPath: resolvedPath ?? path,
189
+ },
190
+ extractorVersion: 'probe-v1',
191
+ }
192
+ }
193
+
194
+ function probeError(base, path, resolvedPath, probeId, error) {
195
+ return {
196
+ ...base,
197
+ op: 'error',
198
+ outcome: 'failure',
199
+ value: {},
200
+ rawPayload: {
201
+ path,
202
+ resolvedPath: resolvedPath ?? path,
203
+ probeId,
204
+ error: errorPayload(error),
205
+ },
206
+ }
207
+ }
208
+
209
+ export async function observationFromProbe(input) {
210
+ const { path, probeId, io, cwd = null, platform = process.platform } = input
211
+ const scope = scopeOf(input.scope)
212
+ if (typeof path !== 'string' || path.trim() === '') {
213
+ throw new TypeError('probe path must be a non-empty string')
214
+ }
215
+ const api = platform === WINDOWS ? win32 : posix
216
+ // Relative probes are resolved against the caller's workspace cwd so a probe and
217
+ // a host tool call can never create two alias entities for the same file. A
218
+ // relative probe without a cwd has no stable identity and is refused.
219
+ if (!cwd && !api.isAbsolute(path)) {
220
+ throw new TypeError('probe relative path requires cwd')
221
+ }
222
+ const targetPath = cwd ? api.resolve(cwd, path) : api.resolve(path)
223
+ const probe = { scope, probeId, validAt: input.validAt, recordedAt: input.recordedAt }
224
+ let resolvedPath = targetPath
225
+ try {
226
+ if (typeof io?.realpath === 'function') resolvedPath = await io.realpath(targetPath)
227
+ } catch (error) {
228
+ const base = probeBase(probe, path, targetPath, platform)
229
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') {
230
+ return probeError(base, path, targetPath, probeId, error)
231
+ }
232
+ return {
233
+ ...base,
234
+ op: 'probe',
235
+ outcome: 'observed',
236
+ value: { exists: false },
237
+ rawPayload: { path, resolvedPath: targetPath, probeId, error: errorPayload(error) },
238
+ }
239
+ }
240
+
241
+ const base = probeBase(probe, path, resolvedPath, platform)
242
+ let stat
243
+ try {
244
+ stat = await io.stat(targetPath)
245
+ } catch (error) {
246
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
247
+ return {
248
+ ...base,
249
+ op: 'probe',
250
+ outcome: 'observed',
251
+ value: { exists: false },
252
+ rawPayload: { path, resolvedPath, probeId, error: errorPayload(error) },
253
+ }
254
+ }
255
+ return probeError(base, path, resolvedPath, probeId, error)
256
+ }
257
+
258
+ let bytes
259
+ try {
260
+ const contents = await io.readFile(targetPath)
261
+ bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents)
262
+ } catch (error) {
263
+ // A read-time failure is not evidence of absence, even if its code is ENOENT.
264
+ return probeError(base, path, resolvedPath, probeId, error)
265
+ }
266
+
267
+ const mtime = finite(stat?.mtimeMs,
268
+ stat?.mtime instanceof Date ? stat.mtime.getTime() : undefined)
269
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
270
+ const value = { exists: true, size: bytes.length, sha256 }
271
+ // Hash and size are the evidence; the bytes are not. Archiving the body made a
272
+ // probe of a secrets file copy its contents into the ledger, where every later
273
+ // reader and export could see them.
274
+ const rawPayload = {
275
+ path,
276
+ resolvedPath,
277
+ probeId,
278
+ size: bytes.length,
279
+ sha256,
280
+ }
281
+ // mtime is only observed when the host actually reported a finite one; an absent
282
+ // mtime must not become `undefined`, which canonical JSON and the archive reject.
283
+ if (mtime !== undefined) {
284
+ value.mtime = mtime
285
+ rawPayload.mtime = mtime
286
+ }
287
+ return {
288
+ ...base,
289
+ op: 'probe',
290
+ outcome: 'observed',
291
+ value,
292
+ rawPayload,
293
+ }
294
+ }
295
+
296
+ // A scope initialization opens a non-file project entity generation. It is an
297
+ // observation so the pure reducer can place it deterministically, but it derives no
298
+ // file.* facts: project assertions must never imply "this path exists".
299
+ export function observationFromScopeInit(input) {
300
+ const { scope, validAt, recordedAt, sid = 'scope-init', seq = 0 } = input
301
+ const scopeId = scopeOf(scope)
302
+ if (typeof input.entityId !== 'string' || input.entityId.trim() === '') {
303
+ throw new TypeError('scope init entityId must be a non-empty string')
304
+ }
305
+ const entityType = input.entityType ?? 'project'
306
+ if (entityType !== 'project') throw new TypeError('scope init supports only project entities')
307
+ const entityId = input.entityId.startsWith('project:')
308
+ ? input.entityId
309
+ : `project:${input.entityId}`
310
+ if (!Number.isFinite(validAt) || !Number.isFinite(recordedAt)) {
311
+ throw new TypeError('scope init requires finite validAt and recordedAt')
312
+ }
313
+ const eventKey = input.eventKey ?? `scope-init:${scopeId}:${entityId}:${String(sid)}:${String(seq)}`
314
+ const detail = input.rawPayload ?? { entityId, entityType }
315
+ return {
316
+ eventKey,
317
+ scope: scopeId,
318
+ sid: String(sid),
319
+ seq,
320
+ validAt,
321
+ recordedAt,
322
+ op: 'scope_init',
323
+ outcome: 'observed',
324
+ subjectEntityId: entityId,
325
+ targetEntityId: null,
326
+ value: { entityType },
327
+ generationHints: null,
328
+ rawRef: input.rawRef ?? null,
329
+ rawPayload: { ...detail, entityId, entityType },
330
+ source: {
331
+ kind: 'observation',
332
+ authority: 'host',
333
+ ...(input.source ?? {}),
334
+ },
335
+ extractorVersion: 'scope-init-v1',
336
+ }
337
+ }
338
+
339
+ // A user message is archival evidence, not a derived fact. The complete text is
340
+ // stored verbatim in rawPayload so proposal quotes can only be located in the
341
+ // archive. The host must name the speaker explicitly and it must be exactly `user`;
342
+ // an assistant/model/system source is refused rather than relabelled, so a caller
343
+ // cannot turn machine output into human evidence.
344
+ export function observationFromUserMessage(input) {
345
+ const { sid, seq, validAt, recordedAt, text, source, speaker } = input
346
+ const scope = scopeOf(input.scope)
347
+ if (speaker !== 'user') {
348
+ throw new TypeError('user message speaker must be exactly "user"')
349
+ }
350
+ if (source !== undefined && source !== null) {
351
+ if (typeof source !== 'object' || Array.isArray(source)) {
352
+ throw new TypeError('user message source must be an object when provided')
353
+ }
354
+ if (source.kind !== undefined && source.kind !== 'user') {
355
+ throw new TypeError('user message source kind must be exactly "user"')
356
+ }
357
+ if (source.authority !== undefined && source.authority !== 'user') {
358
+ throw new TypeError('user message source authority must be exactly "user"')
359
+ }
360
+ }
361
+ if (input.human !== undefined && input.human !== true) {
362
+ throw new TypeError('user message human classification must be true')
363
+ }
364
+ if (typeof sid !== 'string' || sid.trim() === '') {
365
+ throw new TypeError('user message sid must be a non-empty string')
366
+ }
367
+ if (!Number.isInteger(seq) || seq < 0) {
368
+ throw new TypeError('user message seq must be a non-negative integer')
369
+ }
370
+ if (!Number.isFinite(validAt) || !Number.isFinite(recordedAt)) {
371
+ throw new TypeError('user message requires finite validAt and recordedAt')
372
+ }
373
+ if (typeof text !== 'string' || text === '') {
374
+ throw new TypeError('user message text must be a non-empty string')
375
+ }
376
+ const eventKey = `${String(sid)}:${String(seq)}:user`
377
+ const channel = typeof source?.channel === 'string' && source.channel.trim() !== ''
378
+ ? source.channel
379
+ : null
380
+ return {
381
+ eventKey,
382
+ scope,
383
+ sid: String(sid),
384
+ seq,
385
+ validAt,
386
+ recordedAt,
387
+ op: 'user_message',
388
+ outcome: 'observed',
389
+ subjectEntityId: `message:${eventKey}`,
390
+ targetEntityId: null,
391
+ // Host-derived, not caller-derived: the archive layer is the only place that
392
+ // classifies a message as human, and the host speaker is required to be user.
393
+ value: { human: true, speaker: 'user' },
394
+ generationHints: null,
395
+ rawRef: channel === null ? null : `user-message:${channel}`,
396
+ rawPayload: { text },
397
+ source: { kind: 'user', authority: 'user', ...(channel === null ? {} : { channel }) },
398
+ extractorVersion: 'user-message-v1',
399
+ }
400
+ }
401
+
402
+ export function observationFromLegacy({ record, scope, rawRecord }) {
403
+ const completeRawRecord = rawRecord === undefined ? record : rawRecord
404
+ const value = record?.value && typeof record.value === 'object' ? record.value : {}
405
+ const scopeId = scopeOf(scope)
406
+ const identity = record?.rowid ?? record?.id ??
407
+ `${record?.sid ?? 'unknown'}:${record?.seq ?? 0}:${record?.kind ?? 'record'}:${record?.key ?? 'unknown'}`
408
+ const eventKey = `legacy:${scopeId}:${String(identity)}`
409
+ const validAt = finite(record?.updated_ts, record?.updatedAt, record?.ts,
410
+ record?.source?.ts, value?.updated_ts, value?.ts, 0)
411
+
412
+ return {
413
+ eventKey,
414
+ scope: scopeId,
415
+ sid: String(record?.sid ?? record?.source?.sid ?? 'legacy'),
416
+ seq: Number.isFinite(record?.seq) ? record.seq : (record?.source?.seq ?? 0),
417
+ validAt,
418
+ recordedAt: validAt,
419
+ op: 'legacy_snapshot',
420
+ outcome: 'observed',
421
+ subjectEntityId: String(value.path ?? record?.path ?? record?.key ?? `legacy:${identity}`),
422
+ targetEntityId: null,
423
+ value: {},
424
+ generationHints: null,
425
+ rawRef: null,
426
+ rawPayload: completeRawRecord,
427
+ source: { kind: 'legacy', authority: 'legacy' },
428
+ extractorVersion: 'legacy-v1',
429
+ }
430
+ }