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.
- package/LICENSE.txt +21 -0
- package/README.md +194 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +66 -4
- package/toolfold/toolfold.mjs +513 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -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
|
+
}
|