coaia-visualizer 1.6.4 → 1.6.5
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 +2 -1
- package/components/asterion-foundation-card.tsx +175 -0
- package/components/asterion-session-context-badge.tsx +122 -0
- package/components/asterion-session-lineage-card.tsx +119 -0
- package/components/chart-detail.tsx +3 -0
- package/components/metadata-projections.tsx +50 -16
- package/components/narrative-beats.tsx +16 -3
- package/index.tsx +13 -2
- package/lib/asterion-metadata.ts +673 -0
- package/lib/chart-editor.ts +5 -0
- package/lib/jsonl-parser.ts +5 -0
- package/lib/types.ts +97 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Chart,
|
|
3
|
+
EntityRecord,
|
|
4
|
+
FoundationMetadata,
|
|
5
|
+
FoundationProjection,
|
|
6
|
+
MetadataWarning,
|
|
7
|
+
ParsedIssueReference,
|
|
8
|
+
SessionContextMetadata,
|
|
9
|
+
SessionContextProjection,
|
|
10
|
+
SessionLineageBranchProjection,
|
|
11
|
+
SessionLineageGroupProjection,
|
|
12
|
+
SessionLineageMetadata,
|
|
13
|
+
SessionLineageProjection,
|
|
14
|
+
} from "./types.ts"
|
|
15
|
+
|
|
16
|
+
const FOUNDATION_KEYS: Array<keyof FoundationMetadata> = [
|
|
17
|
+
"packetRoot",
|
|
18
|
+
"foundationType",
|
|
19
|
+
"parentIssue",
|
|
20
|
+
"baselineIssue",
|
|
21
|
+
"inquiryIssue",
|
|
22
|
+
"protocolIssue",
|
|
23
|
+
"schemaIssue",
|
|
24
|
+
"visualizerIssue",
|
|
25
|
+
"expectedArtifacts",
|
|
26
|
+
"producedArtifacts",
|
|
27
|
+
"evaluationStatus",
|
|
28
|
+
"privacyClass",
|
|
29
|
+
"publicationStatus",
|
|
30
|
+
"commitHandles",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const FOUNDATION_REQUIRED_FIELDS: Array<keyof FoundationMetadata> = [
|
|
34
|
+
"packetRoot",
|
|
35
|
+
"foundationType",
|
|
36
|
+
"parentIssue",
|
|
37
|
+
"baselineIssue",
|
|
38
|
+
"inquiryIssue",
|
|
39
|
+
"protocolIssue",
|
|
40
|
+
"schemaIssue",
|
|
41
|
+
"visualizerIssue",
|
|
42
|
+
"expectedArtifacts",
|
|
43
|
+
"evaluationStatus",
|
|
44
|
+
"privacyClass",
|
|
45
|
+
"publicationStatus",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const SESSION_LINEAGE_KEYS: Array<keyof SessionLineageMetadata> = [
|
|
49
|
+
"platform",
|
|
50
|
+
"parentChartId",
|
|
51
|
+
"sourceBeat",
|
|
52
|
+
"originalSessionId",
|
|
53
|
+
"branchSessionId",
|
|
54
|
+
"branchIndex",
|
|
55
|
+
"copiedMessageCount",
|
|
56
|
+
"branchPurpose",
|
|
57
|
+
"relatedIssues",
|
|
58
|
+
"handoffState",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const SESSION_LINEAGE_REQUIRED_FIELDS: Array<keyof SessionLineageMetadata> = [
|
|
62
|
+
"platform",
|
|
63
|
+
"parentChartId",
|
|
64
|
+
"sourceBeat",
|
|
65
|
+
"originalSessionId",
|
|
66
|
+
"branchSessionId",
|
|
67
|
+
"branchIndex",
|
|
68
|
+
"copiedMessageCount",
|
|
69
|
+
"branchPurpose",
|
|
70
|
+
"handoffState",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const SESSION_CONTEXT_KEYS: Array<keyof SessionContextMetadata> = [
|
|
74
|
+
"mode",
|
|
75
|
+
"setting",
|
|
76
|
+
"captureQuality",
|
|
77
|
+
"continuationKind",
|
|
78
|
+
"privateChroniclePath",
|
|
79
|
+
"handle",
|
|
80
|
+
"publicSummaryAllowed",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const SESSION_CONTEXT_REQUIRED_FIELDS: Array<keyof SessionContextMetadata> = [
|
|
84
|
+
"mode",
|
|
85
|
+
"setting",
|
|
86
|
+
"captureQuality",
|
|
87
|
+
"continuationKind",
|
|
88
|
+
"publicSummaryAllowed",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
const FOUNDATION_EVALUATION_STATUSES = ["expected", "delegated", "produced", "evaluated"] as const
|
|
92
|
+
const FOUNDATION_PRIVACY_CLASSES = ["public-safe", "private", "mixed"] as const
|
|
93
|
+
const FOUNDATION_PUBLICATION_STATUSES = ["planned", "draft", "reviewed", "published"] as const
|
|
94
|
+
const SESSION_HANDOFF_STATES = [
|
|
95
|
+
"requirements-created",
|
|
96
|
+
"implementation-ready",
|
|
97
|
+
"returned-to-parent",
|
|
98
|
+
] as const
|
|
99
|
+
|
|
100
|
+
const SESSION_CAPTURE_QUALITIES = ["complete", "partial", "noisy", "transcript-only"] as const
|
|
101
|
+
const SESSION_CONTINUATION_KINDS = ["new", "resumption", "branch-return", "daily-chronicle"] as const
|
|
102
|
+
|
|
103
|
+
function isObject(value: unknown): value is Record<string, any> {
|
|
104
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function asTrimmedString(value: unknown): string | undefined {
|
|
108
|
+
if (typeof value !== "string") return undefined
|
|
109
|
+
const trimmed = value.trim()
|
|
110
|
+
return trimmed ? trimmed : undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeList(value: unknown): string[] {
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
return value
|
|
116
|
+
.map((item) => asTrimmedString(typeof item === "string" ? item : JSON.stringify(item)))
|
|
117
|
+
.filter((item): item is string => Boolean(item))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
return value
|
|
122
|
+
.split(/[\n,]/)
|
|
123
|
+
.map((item) => item.trim())
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return []
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function asNumber(value: unknown): number | undefined {
|
|
131
|
+
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
132
|
+
if (typeof value === "string" && value.trim()) {
|
|
133
|
+
const parsed = Number(value)
|
|
134
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
135
|
+
}
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hasAnyValue(record: Record<string, any>, keys: readonly string[]): boolean {
|
|
140
|
+
return keys.some((key) => record[key] !== undefined && record[key] !== null && record[key] !== "")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toLookupKey(value: string): string {
|
|
144
|
+
return value.trim().toLowerCase()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function slugify(value: string): string {
|
|
148
|
+
return value
|
|
149
|
+
.trim()
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
152
|
+
.replace(/^-+|-+$/g, "") || "beat"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pushPayloadCandidate(candidates: Record<string, any>[], candidate: unknown, keys: readonly string[]): void {
|
|
156
|
+
if (isObject(candidate) && hasAnyValue(candidate, keys)) {
|
|
157
|
+
candidates.push(candidate)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function foundationPayloads(metadata: Record<string, any>): Record<string, any>[] {
|
|
162
|
+
const candidates: Record<string, any>[] = []
|
|
163
|
+
pushPayloadCandidate(candidates, metadata.asterion?.foundation, FOUNDATION_KEYS)
|
|
164
|
+
pushPayloadCandidate(candidates, metadata.asterionFoundation, FOUNDATION_KEYS)
|
|
165
|
+
pushPayloadCandidate(candidates, metadata.foundationMetadata, FOUNDATION_KEYS)
|
|
166
|
+
pushPayloadCandidate(candidates, metadata.foundation, FOUNDATION_KEYS)
|
|
167
|
+
pushPayloadCandidate(candidates, metadata.asterionMetadata?.foundation, FOUNDATION_KEYS)
|
|
168
|
+
pushPayloadCandidate(candidates, metadata.asterion, FOUNDATION_KEYS)
|
|
169
|
+
pushPayloadCandidate(candidates, metadata, FOUNDATION_KEYS)
|
|
170
|
+
return candidates
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function lineagePayloads(metadata: Record<string, any>): Record<string, any>[] {
|
|
174
|
+
const candidates: Record<string, any>[] = []
|
|
175
|
+
pushPayloadCandidate(candidates, metadata.asterion?.sessionLineage, SESSION_LINEAGE_KEYS)
|
|
176
|
+
pushPayloadCandidate(candidates, metadata.sessionLineageMetadata, SESSION_LINEAGE_KEYS)
|
|
177
|
+
pushPayloadCandidate(candidates, metadata.sessionLineage, SESSION_LINEAGE_KEYS)
|
|
178
|
+
pushPayloadCandidate(candidates, metadata.asterionMetadata?.sessionLineage, SESSION_LINEAGE_KEYS)
|
|
179
|
+
pushPayloadCandidate(candidates, metadata.asterion, SESSION_LINEAGE_KEYS)
|
|
180
|
+
pushPayloadCandidate(candidates, metadata, SESSION_LINEAGE_KEYS)
|
|
181
|
+
return candidates
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sessionContextPayloads(metadata: Record<string, any>): Record<string, any>[] {
|
|
185
|
+
const candidates: Record<string, any>[] = []
|
|
186
|
+
pushPayloadCandidate(candidates, metadata.asterion?.sessionContext, SESSION_CONTEXT_KEYS)
|
|
187
|
+
pushPayloadCandidate(candidates, metadata.sessionContextMetadata, SESSION_CONTEXT_KEYS)
|
|
188
|
+
pushPayloadCandidate(candidates, metadata.sessionContext, SESSION_CONTEXT_KEYS)
|
|
189
|
+
pushPayloadCandidate(candidates, metadata.asterionMetadata?.sessionContext, SESSION_CONTEXT_KEYS)
|
|
190
|
+
pushPayloadCandidate(candidates, metadata.livedContext, SESSION_CONTEXT_KEYS)
|
|
191
|
+
pushPayloadCandidate(candidates, metadata.asterion, SESSION_CONTEXT_KEYS)
|
|
192
|
+
pushPayloadCandidate(candidates, metadata, SESSION_CONTEXT_KEYS)
|
|
193
|
+
return candidates
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildWarning(message: string, record?: Pick<EntityRecord, "name" | "entityType">): MetadataWarning {
|
|
197
|
+
return {
|
|
198
|
+
severity: "warning",
|
|
199
|
+
recordName: record?.name,
|
|
200
|
+
recordType: record?.entityType,
|
|
201
|
+
message,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function dedupeWarnings(warnings: MetadataWarning[]): MetadataWarning[] {
|
|
206
|
+
const seen = new Set<string>()
|
|
207
|
+
return warnings.filter((warning) => {
|
|
208
|
+
const key = `${warning.recordName || ""}|${warning.recordType || ""}|${warning.message}`
|
|
209
|
+
if (seen.has(key)) return false
|
|
210
|
+
seen.add(key)
|
|
211
|
+
return true
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeFoundationPayload(payload: Record<string, any>, record?: EntityRecord): {
|
|
216
|
+
foundation: Partial<FoundationMetadata>
|
|
217
|
+
warnings: MetadataWarning[]
|
|
218
|
+
} {
|
|
219
|
+
const warnings: MetadataWarning[] = []
|
|
220
|
+
const foundation: Partial<FoundationMetadata> = {}
|
|
221
|
+
|
|
222
|
+
foundation.packetRoot = asTrimmedString(payload.packetRoot)
|
|
223
|
+
foundation.foundationType = asTrimmedString(payload.foundationType)
|
|
224
|
+
foundation.parentIssue = asTrimmedString(payload.parentIssue)
|
|
225
|
+
foundation.baselineIssue = asTrimmedString(payload.baselineIssue)
|
|
226
|
+
foundation.inquiryIssue = asTrimmedString(payload.inquiryIssue)
|
|
227
|
+
foundation.protocolIssue = asTrimmedString(payload.protocolIssue)
|
|
228
|
+
foundation.schemaIssue = asTrimmedString(payload.schemaIssue)
|
|
229
|
+
foundation.visualizerIssue = asTrimmedString(payload.visualizerIssue)
|
|
230
|
+
|
|
231
|
+
if (payload.expectedArtifacts !== undefined) {
|
|
232
|
+
foundation.expectedArtifacts = normalizeList(payload.expectedArtifacts)
|
|
233
|
+
}
|
|
234
|
+
if (payload.producedArtifacts !== undefined) {
|
|
235
|
+
foundation.producedArtifacts = normalizeList(payload.producedArtifacts)
|
|
236
|
+
}
|
|
237
|
+
if (payload.commitHandles !== undefined) {
|
|
238
|
+
foundation.commitHandles = normalizeList(payload.commitHandles)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const evaluationStatus = asTrimmedString(payload.evaluationStatus)
|
|
242
|
+
if (evaluationStatus) {
|
|
243
|
+
if (FOUNDATION_EVALUATION_STATUSES.includes(evaluationStatus as (typeof FOUNDATION_EVALUATION_STATUSES)[number])) {
|
|
244
|
+
foundation.evaluationStatus = evaluationStatus as FoundationMetadata["evaluationStatus"]
|
|
245
|
+
} else {
|
|
246
|
+
warnings.push(buildWarning(`Foundation evaluationStatus \"${evaluationStatus}\" is not recognized.`, record))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const privacyClass = asTrimmedString(payload.privacyClass)
|
|
251
|
+
if (privacyClass) {
|
|
252
|
+
if (FOUNDATION_PRIVACY_CLASSES.includes(privacyClass as (typeof FOUNDATION_PRIVACY_CLASSES)[number])) {
|
|
253
|
+
foundation.privacyClass = privacyClass as FoundationMetadata["privacyClass"]
|
|
254
|
+
} else {
|
|
255
|
+
warnings.push(buildWarning(`Foundation privacyClass \"${privacyClass}\" is not recognized.`, record))
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const publicationStatus = asTrimmedString(payload.publicationStatus)
|
|
260
|
+
if (publicationStatus) {
|
|
261
|
+
if (FOUNDATION_PUBLICATION_STATUSES.includes(publicationStatus as (typeof FOUNDATION_PUBLICATION_STATUSES)[number])) {
|
|
262
|
+
foundation.publicationStatus = publicationStatus as FoundationMetadata["publicationStatus"]
|
|
263
|
+
} else {
|
|
264
|
+
warnings.push(buildWarning(`Foundation publicationStatus \"${publicationStatus}\" is not recognized.`, record))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
foundation: Object.fromEntries(Object.entries(foundation).filter(([, value]) => value !== undefined)) as Partial<FoundationMetadata>,
|
|
270
|
+
warnings,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function normalizeSessionLineagePayload(payload: Record<string, any>, record?: EntityRecord): {
|
|
275
|
+
lineage: Partial<SessionLineageMetadata>
|
|
276
|
+
warnings: MetadataWarning[]
|
|
277
|
+
} {
|
|
278
|
+
const warnings: MetadataWarning[] = []
|
|
279
|
+
const lineage: Partial<SessionLineageMetadata> = {}
|
|
280
|
+
|
|
281
|
+
lineage.platform = asTrimmedString(payload.platform)
|
|
282
|
+
lineage.parentChartId = asTrimmedString(payload.parentChartId)
|
|
283
|
+
lineage.sourceBeat = asTrimmedString(payload.sourceBeat)
|
|
284
|
+
lineage.originalSessionId = asTrimmedString(payload.originalSessionId)
|
|
285
|
+
lineage.branchSessionId = asTrimmedString(payload.branchSessionId)
|
|
286
|
+
lineage.branchIndex = asNumber(payload.branchIndex)
|
|
287
|
+
lineage.copiedMessageCount = asNumber(payload.copiedMessageCount)
|
|
288
|
+
lineage.branchPurpose = asTrimmedString(payload.branchPurpose)
|
|
289
|
+
|
|
290
|
+
if (payload.relatedIssues !== undefined) {
|
|
291
|
+
lineage.relatedIssues = normalizeList(payload.relatedIssues)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const handoffState = asTrimmedString(payload.handoffState)
|
|
295
|
+
if (handoffState) {
|
|
296
|
+
if (SESSION_HANDOFF_STATES.includes(handoffState as (typeof SESSION_HANDOFF_STATES)[number])) {
|
|
297
|
+
lineage.handoffState = handoffState as SessionLineageMetadata["handoffState"]
|
|
298
|
+
} else {
|
|
299
|
+
warnings.push(buildWarning(`Session lineage handoffState \"${handoffState}\" is not recognized.`, record))
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
lineage: Object.fromEntries(Object.entries(lineage).filter(([, value]) => value !== undefined)) as Partial<SessionLineageMetadata>,
|
|
305
|
+
warnings,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeSessionContextPayload(payload: Record<string, any>, record?: EntityRecord): {
|
|
310
|
+
context: Partial<SessionContextMetadata>
|
|
311
|
+
warnings: MetadataWarning[]
|
|
312
|
+
} {
|
|
313
|
+
const warnings: MetadataWarning[] = []
|
|
314
|
+
const context: Partial<SessionContextMetadata> = {}
|
|
315
|
+
|
|
316
|
+
context.mode = asTrimmedString(payload.mode)
|
|
317
|
+
context.setting = asTrimmedString(payload.setting)
|
|
318
|
+
context.privateChroniclePath = asTrimmedString(payload.privateChroniclePath)
|
|
319
|
+
context.handle = asTrimmedString(payload.handle)
|
|
320
|
+
|
|
321
|
+
const publicSummaryAllowed = payload.publicSummaryAllowed
|
|
322
|
+
if (typeof publicSummaryAllowed === "boolean") {
|
|
323
|
+
context.publicSummaryAllowed = publicSummaryAllowed
|
|
324
|
+
} else if (typeof publicSummaryAllowed === "string") {
|
|
325
|
+
const normalized = publicSummaryAllowed.toLowerCase().trim()
|
|
326
|
+
if (normalized === "true" || normalized === "yes") {
|
|
327
|
+
context.publicSummaryAllowed = true
|
|
328
|
+
} else if (normalized === "false" || normalized === "no") {
|
|
329
|
+
context.publicSummaryAllowed = false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const captureQuality = asTrimmedString(payload.captureQuality)
|
|
334
|
+
if (captureQuality) {
|
|
335
|
+
if (SESSION_CAPTURE_QUALITIES.includes(captureQuality as (typeof SESSION_CAPTURE_QUALITIES)[number])) {
|
|
336
|
+
context.captureQuality = captureQuality as SessionContextMetadata["captureQuality"]
|
|
337
|
+
} else {
|
|
338
|
+
warnings.push(buildWarning(`Session context captureQuality "${captureQuality}" is not recognized.`, record))
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const continuationKind = asTrimmedString(payload.continuationKind)
|
|
343
|
+
if (continuationKind) {
|
|
344
|
+
if (SESSION_CONTINUATION_KINDS.includes(continuationKind as (typeof SESSION_CONTINUATION_KINDS)[number])) {
|
|
345
|
+
context.continuationKind = continuationKind as SessionContextMetadata["continuationKind"]
|
|
346
|
+
} else {
|
|
347
|
+
warnings.push(buildWarning(`Session context continuationKind "${continuationKind}" is not recognized.`, record))
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
context: Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined)) as Partial<SessionContextMetadata>,
|
|
353
|
+
warnings,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseIssueReference(value: string, key: string): ParsedIssueReference {
|
|
358
|
+
const raw = value.trim()
|
|
359
|
+
const urlMatch = raw.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)/i)
|
|
360
|
+
if (urlMatch) {
|
|
361
|
+
const [, owner, repo, , number] = urlMatch
|
|
362
|
+
return {
|
|
363
|
+
key,
|
|
364
|
+
raw,
|
|
365
|
+
label: `${owner}/${repo}#${number}`,
|
|
366
|
+
href: raw,
|
|
367
|
+
owner,
|
|
368
|
+
repo,
|
|
369
|
+
number: Number(number),
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const repoMatch = raw.match(/^([^/\s]+)\/([^#\s]+)#(\d+)$/)
|
|
374
|
+
if (repoMatch) {
|
|
375
|
+
const [, owner, repo, number] = repoMatch
|
|
376
|
+
return {
|
|
377
|
+
key,
|
|
378
|
+
raw,
|
|
379
|
+
label: `${owner}/${repo}#${number}`,
|
|
380
|
+
href: `https://github.com/${owner}/${repo}/issues/${number}`,
|
|
381
|
+
owner,
|
|
382
|
+
repo,
|
|
383
|
+
number: Number(number),
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const shortMatch = raw.match(/^#?(\d+)$/)
|
|
388
|
+
if (shortMatch) {
|
|
389
|
+
const [, number] = shortMatch
|
|
390
|
+
return {
|
|
391
|
+
key,
|
|
392
|
+
raw,
|
|
393
|
+
label: `#${number}`,
|
|
394
|
+
number: Number(number),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
key,
|
|
400
|
+
raw,
|
|
401
|
+
label: raw,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function getNarrativeBeatAnchorId(beat: Pick<EntityRecord, "name" | "metadata">, index = 0): string {
|
|
406
|
+
const seed =
|
|
407
|
+
asTrimmedString(beat.metadata?.beatId) ||
|
|
408
|
+
asTrimmedString(beat.metadata?.sourceBeat) ||
|
|
409
|
+
asTrimmedString(beat.metadata?.beatKey) ||
|
|
410
|
+
beat.name ||
|
|
411
|
+
`beat-${index + 1}`
|
|
412
|
+
|
|
413
|
+
return `beat-${slugify(seed)}`
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function buildBeatAnchorLookup(beats: EntityRecord[]): Map<string, string> {
|
|
417
|
+
const lookup = new Map<string, string>()
|
|
418
|
+
|
|
419
|
+
beats.forEach((beat, index) => {
|
|
420
|
+
const anchor = getNarrativeBeatAnchorId(beat, index)
|
|
421
|
+
const candidates = [
|
|
422
|
+
beat.name,
|
|
423
|
+
asTrimmedString(beat.metadata?.beatId),
|
|
424
|
+
asTrimmedString(beat.metadata?.sourceBeat),
|
|
425
|
+
asTrimmedString(beat.metadata?.beatKey),
|
|
426
|
+
asTrimmedString(beat.observations?.[0]),
|
|
427
|
+
anchor.replace(/^beat-/, ""),
|
|
428
|
+
].filter((value): value is string => Boolean(value))
|
|
429
|
+
|
|
430
|
+
for (const candidate of candidates) {
|
|
431
|
+
lookup.set(toLookupKey(candidate), anchor)
|
|
432
|
+
lookup.set(slugify(candidate), anchor)
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
return lookup
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function buildFoundationIssueRefs(foundation: Partial<FoundationMetadata>): ParsedIssueReference[] {
|
|
440
|
+
const issueFields: Array<keyof Pick<
|
|
441
|
+
FoundationMetadata,
|
|
442
|
+
"parentIssue" | "baselineIssue" | "inquiryIssue" | "protocolIssue" | "schemaIssue" | "visualizerIssue"
|
|
443
|
+
>> = ["parentIssue", "baselineIssue", "inquiryIssue", "protocolIssue", "schemaIssue", "visualizerIssue"]
|
|
444
|
+
|
|
445
|
+
return issueFields
|
|
446
|
+
.map((field) => foundation[field] ? parseIssueReference(foundation[field]!, field) : undefined)
|
|
447
|
+
.filter((value): value is ParsedIssueReference => Boolean(value))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function parseFoundationMetadata(records: EntityRecord[]): FoundationProjection | undefined {
|
|
451
|
+
const foundation: Partial<FoundationMetadata> = {}
|
|
452
|
+
const warnings: MetadataWarning[] = []
|
|
453
|
+
const sourceRecords = new Set<string>()
|
|
454
|
+
let foundFoundationMetadata = false
|
|
455
|
+
|
|
456
|
+
for (const record of records) {
|
|
457
|
+
if (!isObject(record.metadata)) continue
|
|
458
|
+
|
|
459
|
+
for (const payload of foundationPayloads(record.metadata)) {
|
|
460
|
+
foundFoundationMetadata = true
|
|
461
|
+
sourceRecords.add(record.name)
|
|
462
|
+
const normalized = normalizeFoundationPayload(payload, record)
|
|
463
|
+
warnings.push(...normalized.warnings)
|
|
464
|
+
|
|
465
|
+
for (const [key, value] of Object.entries(normalized.foundation) as Array<[keyof FoundationMetadata, FoundationMetadata[keyof FoundationMetadata]]>) {
|
|
466
|
+
if (foundation[key] === undefined) {
|
|
467
|
+
foundation[key] = value
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!foundFoundationMetadata) {
|
|
474
|
+
return undefined
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const field of FOUNDATION_REQUIRED_FIELDS) {
|
|
478
|
+
const value = foundation[field]
|
|
479
|
+
const isMissing = value === undefined || (Array.isArray(value) && value.length === 0)
|
|
480
|
+
if (isMissing) {
|
|
481
|
+
warnings.push(buildWarning(`Foundation metadata is missing required field \"${field}\".`))
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const artifactDiff = Array.from(
|
|
486
|
+
new Set([...(foundation.expectedArtifacts || []), ...(foundation.producedArtifacts || [])]),
|
|
487
|
+
)
|
|
488
|
+
.sort((left, right) => left.localeCompare(right))
|
|
489
|
+
.map((artifact) => ({
|
|
490
|
+
artifact,
|
|
491
|
+
expected: (foundation.expectedArtifacts || []).includes(artifact),
|
|
492
|
+
produced: (foundation.producedArtifacts || []).includes(artifact),
|
|
493
|
+
status: (foundation.expectedArtifacts || []).includes(artifact)
|
|
494
|
+
? (foundation.producedArtifacts || []).includes(artifact)
|
|
495
|
+
? "matched"
|
|
496
|
+
: "missing"
|
|
497
|
+
: "unexpected",
|
|
498
|
+
}))
|
|
499
|
+
|
|
500
|
+
for (const artifact of artifactDiff) {
|
|
501
|
+
if (artifact.status === "missing") {
|
|
502
|
+
warnings.push(buildWarning(`Expected artifact \"${artifact.artifact}\" has not been produced yet.`))
|
|
503
|
+
}
|
|
504
|
+
if (artifact.status === "unexpected") {
|
|
505
|
+
warnings.push(buildWarning(`Produced artifact \"${artifact.artifact}\" is not listed in expectedArtifacts.`))
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
foundation,
|
|
511
|
+
warnings: dedupeWarnings(warnings),
|
|
512
|
+
issueRefs: buildFoundationIssueRefs(foundation),
|
|
513
|
+
artifactDiff,
|
|
514
|
+
sourceRecords: Array.from(sourceRecords),
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function parseSessionLineageMetadata(
|
|
519
|
+
records: EntityRecord[],
|
|
520
|
+
beats: EntityRecord[] = [],
|
|
521
|
+
): SessionLineageProjection | undefined {
|
|
522
|
+
const beatAnchors = buildBeatAnchorLookup(beats)
|
|
523
|
+
const branches: SessionLineageBranchProjection[] = []
|
|
524
|
+
|
|
525
|
+
for (const record of records) {
|
|
526
|
+
if (!isObject(record.metadata)) continue
|
|
527
|
+
|
|
528
|
+
for (const payload of lineagePayloads(record.metadata)) {
|
|
529
|
+
const normalized = normalizeSessionLineagePayload(payload, record)
|
|
530
|
+
if (Object.keys(normalized.lineage).length === 0 && normalized.warnings.length === 0) {
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const entryWarnings = [...normalized.warnings]
|
|
535
|
+
for (const field of SESSION_LINEAGE_REQUIRED_FIELDS) {
|
|
536
|
+
const value = normalized.lineage[field]
|
|
537
|
+
const isMissing = value === undefined || (Array.isArray(value) && value.length === 0)
|
|
538
|
+
if (isMissing) {
|
|
539
|
+
entryWarnings.push(buildWarning(`Session lineage metadata is missing required field \"${field}\".`, record))
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const sourceBeat = normalized.lineage.sourceBeat
|
|
544
|
+
const sourceBeatAnchor = sourceBeat
|
|
545
|
+
? beatAnchors.get(toLookupKey(sourceBeat)) || beatAnchors.get(slugify(sourceBeat))
|
|
546
|
+
: undefined
|
|
547
|
+
|
|
548
|
+
branches.push({
|
|
549
|
+
metadata: normalized.lineage,
|
|
550
|
+
sourceRecordName: record.name,
|
|
551
|
+
sourceRecordType: record.entityType,
|
|
552
|
+
warnings: dedupeWarnings(entryWarnings),
|
|
553
|
+
relatedIssueRefs: (normalized.lineage.relatedIssues || []).map((issue, index) =>
|
|
554
|
+
parseIssueReference(issue, `relatedIssues.${index}`),
|
|
555
|
+
),
|
|
556
|
+
sourceBeatAnchor,
|
|
557
|
+
sourceBeatExistsInChart: Boolean(sourceBeatAnchor),
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (branches.length === 0) {
|
|
563
|
+
return undefined
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
branches.sort((left, right) => {
|
|
567
|
+
const sourceBeatCompare = (left.metadata.sourceBeat || "").localeCompare(right.metadata.sourceBeat || "")
|
|
568
|
+
if (sourceBeatCompare !== 0) return sourceBeatCompare
|
|
569
|
+
|
|
570
|
+
const indexCompare = (left.metadata.branchIndex ?? Number.MAX_SAFE_INTEGER) - (right.metadata.branchIndex ?? Number.MAX_SAFE_INTEGER)
|
|
571
|
+
if (indexCompare !== 0) return indexCompare
|
|
572
|
+
|
|
573
|
+
return (left.sourceRecordName || "").localeCompare(right.sourceRecordName || "")
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
const grouped = new Map<string, SessionLineageGroupProjection>()
|
|
577
|
+
for (const branch of branches) {
|
|
578
|
+
const sourceBeat = branch.metadata.sourceBeat || "Unassigned source beat"
|
|
579
|
+
const existing = grouped.get(sourceBeat)
|
|
580
|
+
if (existing) {
|
|
581
|
+
existing.branches.push(branch)
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
grouped.set(sourceBeat, {
|
|
586
|
+
sourceBeat,
|
|
587
|
+
sourceBeatAnchor: branch.sourceBeatAnchor,
|
|
588
|
+
sourceBeatExistsInChart: branch.sourceBeatExistsInChart,
|
|
589
|
+
branches: [branch],
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
branches,
|
|
595
|
+
groupedBranches: Array.from(grouped.values()),
|
|
596
|
+
warnings: dedupeWarnings(branches.flatMap((branch) => branch.warnings)),
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function parseSessionContextMetadata(beat: EntityRecord, beatIndex: number): SessionContextProjection | undefined {
|
|
601
|
+
if (!isObject(beat.metadata)) return undefined
|
|
602
|
+
|
|
603
|
+
let context: Partial<SessionContextMetadata> = {}
|
|
604
|
+
let warnings: MetadataWarning[] = []
|
|
605
|
+
let foundSessionContext = false
|
|
606
|
+
|
|
607
|
+
for (const payload of sessionContextPayloads(beat.metadata)) {
|
|
608
|
+
foundSessionContext = true
|
|
609
|
+
const normalized = normalizeSessionContextPayload(payload, beat)
|
|
610
|
+
warnings.push(...normalized.warnings)
|
|
611
|
+
|
|
612
|
+
for (const [key, value] of Object.entries(normalized.context) as Array<[keyof SessionContextMetadata, SessionContextMetadata[keyof SessionContextMetadata]]>) {
|
|
613
|
+
if (context[key] === undefined) {
|
|
614
|
+
context[key] = value
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (!foundSessionContext) {
|
|
620
|
+
return undefined
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
for (const field of SESSION_CONTEXT_REQUIRED_FIELDS) {
|
|
624
|
+
const value = context[field]
|
|
625
|
+
const isMissing = value === undefined || (Array.isArray(value) && value.length === 0)
|
|
626
|
+
if (isMissing) {
|
|
627
|
+
warnings.push(buildWarning(`Session context is missing required field "${field}".`, beat))
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (context.captureQuality === "partial" || context.captureQuality === "noisy") {
|
|
632
|
+
warnings.push({
|
|
633
|
+
severity: "warning",
|
|
634
|
+
recordName: beat.name,
|
|
635
|
+
recordType: beat.entityType,
|
|
636
|
+
message: `Capture quality is "${context.captureQuality}" - record may be incomplete or contain transcription errors.`,
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (context.privateChroniclePath && !context.handle) {
|
|
641
|
+
warnings.push({
|
|
642
|
+
severity: "warning",
|
|
643
|
+
recordName: beat.name,
|
|
644
|
+
recordType: beat.entityType,
|
|
645
|
+
message: "privateChroniclePath is present but no handle is specified - Chronicle reference may not be navigable.",
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
context,
|
|
651
|
+
warnings: dedupeWarnings(warnings),
|
|
652
|
+
beatName: beat.name,
|
|
653
|
+
beatIndex,
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function getAsterionSourceRecords(chart: Chart): EntityRecord[] {
|
|
658
|
+
return [
|
|
659
|
+
chart.chartEntity,
|
|
660
|
+
...chart.actions,
|
|
661
|
+
...chart.narrativeBeats,
|
|
662
|
+
chart.desiredOutcome,
|
|
663
|
+
chart.currentReality,
|
|
664
|
+
].filter((record): record is EntityRecord => Boolean(record))
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function extractAsterionMetadata(chart: Chart): Pick<Chart, "foundationProjection" | "sessionLineageProjection"> {
|
|
668
|
+
const records = getAsterionSourceRecords(chart)
|
|
669
|
+
return {
|
|
670
|
+
foundationProjection: parseFoundationMetadata(records),
|
|
671
|
+
sessionLineageProjection: parseSessionLineageMetadata(records, chart.narrativeBeats),
|
|
672
|
+
}
|
|
673
|
+
}
|
package/lib/chart-editor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lib/chart-editor.ts - Chart editing logic
|
|
2
2
|
|
|
3
3
|
import type { EntityRecord, RelationRecord, ParsedData } from "./types.ts"
|
|
4
|
+
import { extractAsterionMetadata } from "./asterion-metadata.ts"
|
|
4
5
|
import { buildMetadataWarnings, buildRecordProjections, serializeParsedData } from "./jsonl-preservation.ts"
|
|
5
6
|
import { buildRiseSpecs } from "./jsonl-parser.ts"
|
|
6
7
|
|
|
@@ -604,6 +605,10 @@ export class ChartEditor {
|
|
|
604
605
|
}
|
|
605
606
|
}
|
|
606
607
|
|
|
608
|
+
chartsArray.forEach((chart) => {
|
|
609
|
+
Object.assign(chart, extractAsterionMetadata(chart))
|
|
610
|
+
})
|
|
611
|
+
|
|
607
612
|
const rootCharts = chartsArray.filter((c) => !c.parentChart)
|
|
608
613
|
|
|
609
614
|
return {
|
package/lib/jsonl-parser.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Chart, EntityRecord, JSONLRecord, ParsedData, RawJSONLRecord, RelationRecord, RiseSpecProjection } from "./types.ts"
|
|
2
|
+
import { extractAsterionMetadata } from "./asterion-metadata.ts"
|
|
2
3
|
import { buildMetadataWarnings, buildRecordProjections, normalizeJsonlRecord } from "./jsonl-preservation.ts"
|
|
3
4
|
|
|
4
5
|
export function parseJSONL(content: string): RawJSONLRecord[] {
|
|
@@ -192,6 +193,10 @@ export function organizeData(records: RawJSONLRecord[]): ParsedData {
|
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
for (const chart of charts.values()) {
|
|
197
|
+
Object.assign(chart, extractAsterionMetadata(chart))
|
|
198
|
+
}
|
|
199
|
+
|
|
195
200
|
// Identify root charts (level 0 or no parent)
|
|
196
201
|
const rootCharts = Array.from(charts.values()).filter((chart) => chart.level === 0 || !chart.parentChart)
|
|
197
202
|
|