@cyber-dash-tech/revela 0.10.0 → 0.11.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.
@@ -1,8 +1,24 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs"
2
2
  import { createHash } from "crypto"
3
- import { basename, dirname, join, resolve } from "path"
4
-
5
- export const DECKS_STATE_FILE = "DECKS.json"
3
+ import { basename, join, resolve } from "path"
4
+ import {
5
+ hasWorkspaceState,
6
+ readOrCreateWorkspaceState,
7
+ readWorkspaceState,
8
+ workspaceStatePath,
9
+ writeWorkspaceState,
10
+ } from "./workspace-state/repository"
11
+ import { ensureActiveHtmlDeckRenderTarget } from "./workspace-state/render-targets"
12
+ import {
13
+ activeReviewTargetId,
14
+ appendReviewSnapshot,
15
+ createReviewSnapshot,
16
+ isReviewSnapshotCurrent,
17
+ latestReviewSnapshotForTarget,
18
+ } from "./workspace-state/review-snapshots"
19
+ import { WORKSPACE_STATE_FILE, type RenderTarget, type ReviewSnapshot, type WorkspaceAction } from "./workspace-state/types"
20
+
21
+ export const DECKS_STATE_FILE = WORKSPACE_STATE_FILE
6
22
 
7
23
  export type DeckProductionStatus = "planning" | "blocked" | "ready" | "written"
8
24
  export type SlideProductionStatus = "planned" | "ready" | "written" | "qa_passed" | "qa_failed"
@@ -23,6 +39,9 @@ export interface DecksState {
23
39
  openQuestions: string[]
24
40
  }
25
41
  decks: Record<string, DeckSpec>
42
+ actions: WorkspaceAction[]
43
+ renderTargets: RenderTarget[]
44
+ reviews: ReviewSnapshot[]
26
45
  }
27
46
 
28
47
  export interface SourceMaterial {
@@ -249,11 +268,11 @@ export interface ReviewDeckStateOptions {
249
268
  }
250
269
 
251
270
  export function decksStatePath(workspaceRoot: string): string {
252
- return join(workspaceRoot, DECKS_STATE_FILE)
271
+ return workspaceStatePath(workspaceRoot, DECKS_STATE_FILE)
253
272
  }
254
273
 
255
274
  export function hasDecksState(workspaceRoot: string): boolean {
256
- return existsSync(decksStatePath(workspaceRoot))
275
+ return hasWorkspaceState(workspaceRoot, DECKS_STATE_FILE)
257
276
  }
258
277
 
259
278
  export function createEmptyDecksState(): DecksState {
@@ -266,6 +285,9 @@ export function createEmptyDecksState(): DecksState {
266
285
  openQuestions: [],
267
286
  },
268
287
  decks: {},
288
+ actions: [],
289
+ renderTargets: [],
290
+ reviews: [],
269
291
  }
270
292
  }
271
293
 
@@ -289,6 +311,7 @@ export function normalizeWorkspaceDeckState(state: DecksState, workspaceRoot: st
289
311
  delete normalized.decks[existingKey]
290
312
  normalized.decks[slug] = deck
291
313
  normalized.activeDeck = slug
314
+ ensureActiveHtmlDeckRenderTarget(normalized)
292
315
  return normalized
293
316
  }
294
317
 
@@ -326,21 +349,15 @@ export function createDeckSpec(input: Partial<DeckSpec> & { slug: string }): Dec
326
349
  }
327
350
 
328
351
  export function readDecksState(workspaceRoot: string): DecksState {
329
- const parsed = JSON.parse(readFileSync(decksStatePath(workspaceRoot), "utf-8")) as DecksState
330
- return normalizeDecksState(parsed)
352
+ return readWorkspaceState(workspaceRoot, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksState })
331
353
  }
332
354
 
333
355
  export function writeDecksState(workspaceRoot: string, state: DecksState): void {
334
- const filePath = decksStatePath(workspaceRoot)
335
- mkdirSync(dirname(filePath), { recursive: true })
336
- writeFileSync(filePath, JSON.stringify(normalizeDecksState(state), null, 2) + "\n", "utf-8")
356
+ writeWorkspaceState(workspaceRoot, state, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksState })
337
357
  }
338
358
 
339
359
  export function readOrCreateDecksState(workspaceRoot: string): DecksState {
340
- if (hasDecksState(workspaceRoot)) return readDecksState(workspaceRoot)
341
- const state = createEmptyDecksState()
342
- writeDecksState(workspaceRoot, state)
343
- return state
360
+ return readOrCreateWorkspaceState(workspaceRoot, createEmptyDecksState, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksState })
344
361
  }
345
362
 
346
363
  export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug: string }): DecksState {
@@ -354,6 +371,7 @@ export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug:
354
371
  const next = createDeckSpec({ ...existing, ...input, slug })
355
372
  normalized.decks[slug] = next
356
373
  normalized.activeDeck = slug
374
+ ensureActiveHtmlDeckRenderTarget(normalized)
357
375
  return normalized
358
376
  }
359
377
 
@@ -370,6 +388,7 @@ export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[
370
388
  deck.slides = [...byIndex.values()].sort((a, b) => a.index - b.index)
371
389
  normalized.decks[key] = deck
372
390
  normalized.activeDeck = key
391
+ ensureActiveHtmlDeckRenderTarget(normalized)
373
392
  return normalized
374
393
  }
375
394
 
@@ -459,26 +478,29 @@ export function reviewDeckState(state: DecksState, slug?: string, options: Revie
459
478
  const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
460
479
  const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
461
480
  const evidenceCandidates = issues.flatMap((issue) => issue.evidenceCandidates ?? [])
481
+ const reviewedAt = new Date().toISOString()
462
482
  deck.writeReadiness = {
463
483
  status: blockers.length === 0 ? "ready" : "blocked",
464
484
  blockers,
465
- lastReviewedAt: new Date().toISOString(),
485
+ lastReviewedAt: reviewedAt,
466
486
  }
467
487
  deck.status = blockers.length === 0 ? "ready" : "blocked"
468
488
  normalized.decks[deck.slug] = deck
469
489
  normalized.activeDeck = deck.slug
490
+ const result: DeckStateReadinessResult = {
491
+ ready: blockers.length === 0,
492
+ slug: deck.slug,
493
+ status: deck.writeReadiness.status,
494
+ blocker: blockers.join("; "),
495
+ blockers,
496
+ warnings,
497
+ issues,
498
+ evidenceCandidates,
499
+ }
500
+ appendReviewSnapshot(normalized, createReviewSnapshot(normalized, { slug: deck.slug, result, reviewedAt }))
470
501
  return {
471
502
  state: normalized,
472
- result: {
473
- ready: blockers.length === 0,
474
- slug: deck.slug,
475
- status: deck.writeReadiness.status,
476
- blocker: blockers.join("; "),
477
- blockers,
478
- warnings,
479
- issues,
480
- evidenceCandidates,
481
- },
503
+ result,
482
504
  }
483
505
  }
484
506
 
@@ -542,6 +564,38 @@ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: str
542
564
  suggestedAction: "Resolve the stored writeReadiness blockers and rerun /revela review.",
543
565
  })
544
566
  }
567
+ if (normalized.reviews.length > 0) {
568
+ const targetId = activeReviewTargetId(normalized)
569
+ const snapshot = latestReviewSnapshotForTarget(normalized, targetId)
570
+ if (!snapshot) {
571
+ const message = "No review snapshot exists for the active HTML render target"
572
+ blockers.unshift(message)
573
+ issues.unshift({
574
+ type: "missing_slide_spec",
575
+ severity: "blocker",
576
+ message,
577
+ suggestedAction: "Run /revela review so readiness is recorded against the current active render target.",
578
+ })
579
+ } else if (!isReviewSnapshotCurrent(normalized, snapshot, deck.slug)) {
580
+ const message = "Latest review snapshot is stale for the current deck, sources, evidence, narrative state, or render target"
581
+ blockers.unshift(message)
582
+ issues.unshift({
583
+ type: "missing_slide_spec",
584
+ severity: "blocker",
585
+ message,
586
+ suggestedAction: "Run /revela review again after the latest state changes before writing deck HTML.",
587
+ })
588
+ } else if (snapshot.status !== "ready") {
589
+ const message = `Latest review snapshot is ${snapshot.status}, not ready`
590
+ blockers.unshift(message)
591
+ issues.unshift({
592
+ type: "missing_slide_spec",
593
+ severity: "blocker",
594
+ message,
595
+ suggestedAction: "Resolve review blockers and rerun /revela review before writing deck HTML.",
596
+ })
597
+ }
598
+ }
545
599
 
546
600
  return {
547
601
  ready: blockers.length === 0,
@@ -579,11 +633,12 @@ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 140
579
633
  activeDeck: activeKey,
580
634
  workspace: compactWorkspaceForPrompt(state.workspace),
581
635
  deck: active ? compactDeckForPrompt(active) : undefined,
636
+ renderTargets: state.renderTargets,
637
+ reviews: compactReviewsForPrompt(state.reviews),
582
638
  }
583
639
  let text = JSON.stringify(compact, null, 2)
584
640
  if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
585
- return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
586
- return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, evidence, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- ${DECKS_STATE_FILE} deck slides use 1-based \`slides[].index\` values. Render every HTML \`<section class="slide">\` with a matching 1-based \`data-slide-index\` attribute, and do not use 0-based \`data-index\` as slide identity.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
641
+ return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, evidence, render targets, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- ${DECKS_STATE_FILE} deck slides use 1-based \`slides[].index\` values. Render every HTML \`<section class="slide">\` with a matching 1-based \`data-slide-index\` attribute, and do not use 0-based \`data-index\` as slide identity.\n- The active HTML deck is represented as a \`renderTarget\` of type \`html_deck\`; PDF/PPTX exports should be recorded as derived render targets, not as separate deck specs.\n- \`writeReadiness\` is a compatibility projection. When review snapshots exist, deck HTML writes require a current non-stale ready review snapshot for the active HTML render target.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
587
642
  }
588
643
 
589
644
  function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksState["workspace"] {
@@ -616,6 +671,20 @@ function compactDeckForPrompt(deck: DeckSpec): DeckSpec {
616
671
  }
617
672
  }
618
673
 
674
+ function compactReviewsForPrompt(reviews: ReviewSnapshot[]): ReviewSnapshot[] {
675
+ return reviews.slice(-5).map((review) => ({
676
+ id: review.id,
677
+ targetId: review.targetId,
678
+ inputHash: review.inputHash,
679
+ status: review.status,
680
+ blockers: review.blockers.slice(0, 5),
681
+ warnings: review.warnings.slice(0, 5),
682
+ issues: review.issues.slice(0, 10),
683
+ evidenceCandidates: review.evidenceCandidates?.slice(0, 10),
684
+ reviewedAt: review.reviewedAt,
685
+ }))
686
+ }
687
+
619
688
  function compactNarrativeBriefForPrompt(brief: NarrativeBrief | undefined): NarrativeBrief | undefined {
620
689
  if (!brief) return undefined
621
690
  return {
@@ -659,6 +728,9 @@ function normalizeDecksState(input: DecksState): DecksState {
659
728
  openQuestions: input.workspace?.openQuestions ?? [],
660
729
  },
661
730
  decks: {},
731
+ actions: input.actions ?? [],
732
+ renderTargets: input.renderTargets ?? [],
733
+ reviews: input.reviews ?? [],
662
734
  }
663
735
  for (const [slug, deck] of Object.entries(input.decks ?? {})) {
664
736
  const normalizedSlug = normalizeSlug(deck.slug || slug)
@@ -669,6 +741,7 @@ function normalizeDecksState(input: DecksState): DecksState {
669
741
  const keys = Object.keys(state.decks)
670
742
  if (keys.length === 1) state.activeDeck = keys[0]
671
743
  }
744
+ ensureActiveHtmlDeckRenderTarget(state)
672
745
  return state
673
746
  }
674
747
 
@@ -10,6 +10,7 @@ import { extractPptx } from "../read-hooks/extractors/pptx"
10
10
  import { extractXlsx } from "../read-hooks/extractors/xlsx"
11
11
  import { hasDecksState, readDecksState, writeDecksState } from "../decks-state"
12
12
  import { computeSourceFingerprint, sourceMaterialMetadata, upsertSourceMaterial } from "../source-materials"
13
+ import { recordWorkspaceAction } from "../workspace-state/actions"
13
14
 
14
15
  export type DocumentMaterial = {
15
16
  path: string
@@ -180,6 +181,25 @@ function updateDecksSourceMaterialIndex(
180
181
  : undefined,
181
182
  lastExtracted: extracted ? (result.cache_status === "hit" ? existing?.lastExtracted ?? now : now) : undefined,
182
183
  }, extracted ? "extracted" : "discovered")
184
+ recordWorkspaceAction(state, {
185
+ type: "source.extracted",
186
+ actor: "revela-extract-document-materials",
187
+ inputs: { file: base.path, type: base.type, fingerprint: base.fingerprint },
188
+ outputs: {
189
+ status: result.status,
190
+ cacheStatus: result.cache_status,
191
+ source: result.source,
192
+ manifestPath: result.manifest_path,
193
+ textPath: result.text_path,
194
+ cacheDir: result.cache_dir,
195
+ reason: result.reason,
196
+ imageCount: result.images?.length ?? 0,
197
+ tableCount: result.tables?.length ?? 0,
198
+ },
199
+ status: result.status === "failed" ? "failed" : result.status === "skipped" ? "skipped" : "success",
200
+ summary: extracted ? `Extracted reusable materials from ${base.path}.` : `Registered ${base.path} without reusable extraction output.`,
201
+ nodeIds: [`source:${base.path}`],
202
+ })
183
203
  writeDecksState(workspaceDir, state)
184
204
  }
185
205
 
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readdirSync } from "fs"
2
2
  import { relative, resolve, sep } from "path"
3
- import { DECKS_STATE_FILE, isDeckHtmlPath, workspaceDeckSlug } from "../decks-state"
3
+ import { DECKS_STATE_FILE, hasDecksState, isDeckHtmlPath, readDecksState, workspaceDeckSlug } from "../decks-state"
4
+ import { resolveActiveHtmlDeckPath } from "../workspace-state/render-targets"
4
5
 
5
6
  export interface EditableDeck {
6
7
  slug: string
7
8
  file: string
8
9
  absoluteFile: string
9
- source: "decks-state" | "fallback" | "file-path"
10
+ source: "render-target" | "decks-state" | "fallback" | "file-path"
10
11
  }
11
12
 
12
13
  export function resolveEditableDeck(workspaceRoot: string, input = ""): EditableDeck {
@@ -14,6 +15,16 @@ export function resolveEditableDeck(workspaceRoot: string, input = ""): Editable
14
15
  throw new Error("/revela edit no longer accepts a target. It opens the only HTML deck in decks/.")
15
16
  }
16
17
 
18
+ if (hasDecksState(workspaceRoot)) {
19
+ const state = readDecksState(workspaceRoot)
20
+ const deckPath = resolveActiveHtmlDeckPath(state)
21
+ const source = state.renderTargets.some((target) => target.type === "html_deck" && target.outputPath === deckPath) ? "render-target" : "decks-state"
22
+ if (deckPath && isDeckHtmlPath(deckPath)) {
23
+ const absoluteFile = resolve(workspaceRoot, deckPath)
24
+ if (existsSync(absoluteFile)) return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), deckPath, source)
25
+ }
26
+ }
27
+
17
28
  const htmlFiles = listDeckHtmlFiles(workspaceRoot)
18
29
  if (htmlFiles.length === 0) {
19
30
  throw new Error("No deck HTML found in decks/. Generate a deck first.")
@@ -2,6 +2,7 @@ import { existsSync } from "fs"
2
2
  import { ACTIVE_PROMPT_FILE } from "../config"
3
3
  import { ctx } from "../ctx"
4
4
  import { seedBuiltinDesigns } from "../design/designs"
5
+ import { assertDeckHtmlContractValid } from "../deck-html/contract"
5
6
  import { seedBuiltinDomains } from "../domain/domains"
6
7
  import { ensureEditableDeckState } from "../edit/deck-state"
7
8
  import { openUrl } from "../edit/open"
@@ -30,6 +31,7 @@ export interface OpenInspectDeckOptions {
30
31
  export function openInspectDeck(target: string, options: OpenInspectDeckOptions): OpenInspectDeckResult {
31
32
  const deck = resolveEditableDeck(options.workspaceRoot, target)
32
33
  const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
34
+ assertDeckHtmlContractValid(options.workspaceRoot, deck.absoluteFile)
33
35
 
34
36
  ctx.enabled = true
35
37
  if (!existsSync(ACTIVE_PROMPT_FILE)) {
@@ -52,7 +54,7 @@ export function openInspectDeck(target: string, options: OpenInspectDeckOptions)
52
54
  return {
53
55
  deck,
54
56
  url,
55
- source: deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
57
+ source: deck.source === "render-target" ? "render target" : deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
56
58
  stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for inspection." : "Deck state already points to this inspection target.",
57
59
  preflightChanged: preflight.changed,
58
60
  reusedSession: session.reused,
@@ -1,6 +1,13 @@
1
1
  import { formatReport, runQA } from "./index"
2
+ import { assertDeckHtmlContractValid } from "../deck-html/contract"
3
+
4
+ export interface ExportQAGateOptions {
5
+ workspaceRoot?: string
6
+ }
7
+
8
+ export async function assertExportQAPassed(filePath: string, options: ExportQAGateOptions = {}): Promise<void> {
9
+ if (options.workspaceRoot) assertDeckHtmlContractValid(options.workspaceRoot, filePath)
2
10
 
3
- export async function assertExportQAPassed(filePath: string): Promise<void> {
4
11
  const report = await runQA(filePath)
5
12
  if (report.totalIssues === 0) return
6
13
 
@@ -2,6 +2,7 @@ import { existsSync } from "fs"
2
2
  import { ACTIVE_PROMPT_FILE } from "../config"
3
3
  import { ctx } from "../ctx"
4
4
  import { seedBuiltinDesigns } from "../design/designs"
5
+ import { assertDeckHtmlContractValid } from "../deck-html/contract"
5
6
  import { seedBuiltinDomains } from "../domain/domains"
6
7
  import { ensureEditableDeckState } from "../edit/deck-state"
7
8
  import { openUrl } from "../edit/open"
@@ -33,6 +34,7 @@ export interface OpenRefineDeckOptions {
33
34
  export function openRefineDeck(target: string, options: OpenRefineDeckOptions): OpenRefineDeckResult {
34
35
  const deck = resolveEditableDeck(options.workspaceRoot, target)
35
36
  const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
37
+ assertDeckHtmlContractValid(options.workspaceRoot, deck.absoluteFile)
36
38
  const mode = options.mode ?? "edit"
37
39
 
38
40
  ctx.enabled = true
@@ -57,7 +59,7 @@ export function openRefineDeck(target: string, options: OpenRefineDeckOptions):
57
59
  return {
58
60
  deck,
59
61
  url,
60
- source: deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
62
+ source: deck.source === "render-target" ? "render target" : deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
61
63
  stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for refinement." : "Deck state already points to this refinement target.",
62
64
  preflightChanged: preflight.changed,
63
65
  reusedSession: session.reused,
@@ -0,0 +1,71 @@
1
+ import { createHash } from "crypto"
2
+ import type { DecksState } from "../decks-state"
3
+ import type { WorkspaceAction, WorkspaceActionType } from "./types"
4
+
5
+ export const MAX_WORKSPACE_ACTIONS = 500
6
+
7
+ export interface WorkspaceActionInput {
8
+ type: WorkspaceActionType
9
+ actor?: string
10
+ inputs?: Record<string, unknown>
11
+ outputs?: Record<string, unknown>
12
+ status?: WorkspaceAction["status"]
13
+ summary?: string
14
+ nodeIds?: string[]
15
+ timestamp?: string
16
+ }
17
+
18
+ export function recordWorkspaceAction(state: DecksState, input: WorkspaceActionInput): DecksState {
19
+ const actions = state.actions ?? []
20
+ const timestamp = input.timestamp ?? new Date().toISOString()
21
+ const action: WorkspaceAction = {
22
+ id: workspaceActionId(input.type, timestamp, actions.length, input),
23
+ type: input.type,
24
+ timestamp,
25
+ status: input.status ?? "success",
26
+ ...(input.actor ? { actor: input.actor } : {}),
27
+ ...(input.inputs ? { inputs: compactActionPayload(input.inputs) } : {}),
28
+ ...(input.outputs ? { outputs: compactActionPayload(input.outputs) } : {}),
29
+ ...(input.summary ? { summary: input.summary } : {}),
30
+ ...(input.nodeIds && input.nodeIds.length > 0 ? { nodeIds: [...new Set(input.nodeIds)].sort() } : {}),
31
+ }
32
+
33
+ state.actions = [...actions, action].slice(-MAX_WORKSPACE_ACTIONS)
34
+ return state
35
+ }
36
+
37
+ export function compactActionPayload(input: Record<string, unknown>): Record<string, unknown> {
38
+ const output: Record<string, unknown> = {}
39
+ for (const [key, value] of Object.entries(input)) {
40
+ const compacted = compactActionValue(value)
41
+ if (compacted !== undefined) output[key] = compacted
42
+ }
43
+ return output
44
+ }
45
+
46
+ export function workspaceActionId(type: WorkspaceActionType, timestamp: string, sequence: number, input: Omit<WorkspaceActionInput, "timestamp">): string {
47
+ return `action:${timestamp}:${type}:${stableHash(JSON.stringify({ sequence, input: compactActionPayload(input as Record<string, unknown>) }))}`
48
+ }
49
+
50
+ function compactActionValue(value: unknown): unknown {
51
+ if (value === undefined || value === null) return undefined
52
+ if (typeof value === "string") {
53
+ const trimmed = value.trim()
54
+ if (!trimmed) return undefined
55
+ return trimmed.length > 500 ? `${trimmed.slice(0, 500).trimEnd()}... [truncated]` : trimmed
56
+ }
57
+ if (typeof value === "number" || typeof value === "boolean") return value
58
+ if (Array.isArray(value)) {
59
+ const items = value.map(compactActionValue).filter((item) => item !== undefined)
60
+ return items.length > 0 ? items.slice(0, 50) : undefined
61
+ }
62
+ if (typeof value === "object") {
63
+ const compacted = compactActionPayload(value as Record<string, unknown>)
64
+ return Object.keys(compacted).length > 0 ? compacted : undefined
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ function stableHash(value: string): string {
70
+ return createHash("sha1").update(value).digest("hex").slice(0, 10)
71
+ }
@@ -0,0 +1,10 @@
1
+ import type { DecksState } from "../decks-state"
2
+ import type { DecksStateV1Projection, WorkspaceState } from "./types"
3
+
4
+ export function isDecksStateV1(state: WorkspaceState): state is DecksState {
5
+ return state.version === 1
6
+ }
7
+
8
+ export function asDecksStateV1Projection(state: DecksState): DecksStateV1Projection {
9
+ return state
10
+ }