@cyber-dash-tech/revela 0.9.0 → 0.10.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,4 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"
2
+ import { createHash } from "crypto"
2
3
  import { basename, dirname, join, resolve } from "path"
3
4
 
4
5
  export const DECKS_STATE_FILE = "DECKS.json"
@@ -161,6 +162,7 @@ export interface DeckStateReadinessResult {
161
162
  blockers: string[]
162
163
  warnings: string[]
163
164
  issues: ReadinessIssue[]
165
+ evidenceCandidates?: EvidenceBindingCandidate[]
164
166
  }
165
167
 
166
168
  export type ReadinessSeverity = "blocker" | "warning"
@@ -182,10 +184,70 @@ export interface ReadinessIssue {
182
184
  slideIndex?: number
183
185
  slideTitle?: string
184
186
  claimText?: string
187
+ evidenceCandidates?: EvidenceBindingCandidate[]
188
+ evidenceCandidateSearch?: EvidenceCandidateSearchDiagnostic
189
+ }
190
+
191
+ export interface EvidenceBindingCandidate {
192
+ candidateId: string
193
+ slideIndex: number
194
+ slideTitle?: string
195
+ claimText?: string
196
+ source: string
197
+ findingsFile?: string
198
+ sourcePath?: string
199
+ location?: string
200
+ quote?: string
201
+ caveat?: string
202
+ supportScope: string[]
203
+ supportStrength: "partial" | "strong"
204
+ sourceKind?: "researchPlan" | "researchesFallback"
205
+ evidenceDraft?: EvidenceRef
206
+ unsupportedScope?: string[]
207
+ recommendedRewrite?: string
208
+ }
209
+
210
+ export interface EvidenceCandidateSearchDiagnostic {
211
+ queryTokens: string[]
212
+ researchPlanFindingsSearched: string[]
213
+ fallbackResearchFilesSearched: string[]
214
+ fallbackResearchFilesSkipped: string[]
215
+ nearMisses: EvidenceCandidateNearMiss[]
216
+ }
217
+
218
+ export interface EvidenceCandidateNearMiss {
219
+ findingsFile: string
220
+ sourceKind: "researchPlan" | "researchesFallback"
221
+ bestScore: number
222
+ threshold: number
223
+ supportScope: string[]
224
+ quote?: string
225
+ reason: string
226
+ }
227
+
228
+ export interface ApplyEvidenceCandidatesResult {
229
+ applied: AppliedEvidenceCandidate[]
230
+ skipped: SkippedEvidenceCandidate[]
231
+ nextReviewNeeded: boolean
232
+ }
233
+
234
+ export interface AppliedEvidenceCandidate {
235
+ candidateId: string
236
+ slideIndex: number
237
+ evidence: EvidenceRef
238
+ }
239
+
240
+ export interface SkippedEvidenceCandidate {
241
+ candidateId: string
242
+ reason: string
185
243
  }
186
244
 
187
245
  const SOURCE_TRACE_ACTION = "Add slide evidence with source plus source trace such as findingsFile or sourcePath, and quote, location, url, or caveat where available; otherwise reframe the claim as an explicit assumption/opinion."
188
246
 
247
+ export interface ReviewDeckStateOptions {
248
+ workspaceRoot?: string
249
+ }
250
+
189
251
  export function decksStatePath(workspaceRoot: string): string {
190
252
  return join(workspaceRoot, DECKS_STATE_FILE)
191
253
  }
@@ -311,7 +373,65 @@ export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[
311
373
  return normalized
312
374
  }
313
375
 
314
- export function reviewDeckState(state: DecksState, slug?: string): { state: DecksState; result: DeckStateReadinessResult } {
376
+ export function applyEvidenceCandidates(state: DecksState, candidateIds: string[], options: ReviewDeckStateOptions = {}): { state: DecksState; result: ApplyEvidenceCandidatesResult } {
377
+ const normalized = normalizeDecksState(state)
378
+ const ids = [...new Set(candidateIds.map((id) => id.trim()).filter(Boolean))]
379
+ const applied: AppliedEvidenceCandidate[] = []
380
+ const skipped: SkippedEvidenceCandidate[] = []
381
+ const key = currentDeckKey(normalized)
382
+ const deck = key ? normalized.decks[key] : undefined
383
+
384
+ if (!deck) {
385
+ return {
386
+ state: normalized,
387
+ result: {
388
+ applied,
389
+ skipped: ids.map((candidateId) => ({ candidateId, reason: `No active deck exists in ${DECKS_STATE_FILE}.` })),
390
+ nextReviewNeeded: false,
391
+ },
392
+ }
393
+ }
394
+
395
+ const review = reviewDeckState(normalized, deck.slug, options)
396
+ const byId = new Map((review.result.evidenceCandidates ?? []).map((candidate) => [candidate.candidateId, candidate]))
397
+ const next = normalizeDecksState(review.state)
398
+ const nextDeck = next.decks[deck.slug]
399
+
400
+ for (const candidateId of ids) {
401
+ const candidate = byId.get(candidateId)
402
+ if (!candidate) {
403
+ skipped.push({ candidateId, reason: "Candidate was not found in the current review result." })
404
+ continue
405
+ }
406
+ if (!candidate.evidenceDraft) {
407
+ skipped.push({ candidateId, reason: "Candidate has no evidenceDraft to apply." })
408
+ continue
409
+ }
410
+ const slide = nextDeck.slides.find((item) => item.index === candidate.slideIndex)
411
+ if (!slide) {
412
+ skipped.push({ candidateId, reason: `Slide ${candidate.slideIndex} no longer exists.` })
413
+ continue
414
+ }
415
+ const evidence = cleanEvidenceRef(candidate.evidenceDraft)
416
+ if (slide.evidence.some((item) => sameEvidenceRef(item, evidence))) {
417
+ skipped.push({ candidateId, reason: `Slide ${candidate.slideIndex} already has this evidence record.` })
418
+ continue
419
+ }
420
+ slide.evidence.push(evidence)
421
+ applied.push({ candidateId, slideIndex: candidate.slideIndex, evidence })
422
+ }
423
+
424
+ return {
425
+ state: next,
426
+ result: {
427
+ applied,
428
+ skipped,
429
+ nextReviewNeeded: applied.length > 0,
430
+ },
431
+ }
432
+ }
433
+
434
+ export function reviewDeckState(state: DecksState, slug?: string, options: ReviewDeckStateOptions = {}): { state: DecksState; result: DeckStateReadinessResult } {
315
435
  const normalized = normalizeDecksState(state)
316
436
  const key = normalizeSlug(slug || currentDeckKey(normalized) || "")
317
437
  const deck = key ? normalized.decks[key] : undefined
@@ -335,9 +455,10 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
335
455
  }
336
456
  }
337
457
 
338
- const issues = computeDeckReadinessIssues(deck, normalized.workspace)
458
+ const issues = computeDeckReadinessIssues(deck, normalized.workspace, options)
339
459
  const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
340
460
  const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
461
+ const evidenceCandidates = issues.flatMap((issue) => issue.evidenceCandidates ?? [])
341
462
  deck.writeReadiness = {
342
463
  status: blockers.length === 0 ? "ready" : "blocked",
343
464
  blockers,
@@ -356,6 +477,7 @@ export function reviewDeckState(state: DecksState, slug?: string): { state: Deck
356
477
  blockers,
357
478
  warnings,
358
479
  issues,
480
+ evidenceCandidates,
359
481
  },
360
482
  }
361
483
  }
@@ -461,6 +583,7 @@ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 140
461
583
  let text = JSON.stringify(compact, null, 2)
462
584
  if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
463
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.`
464
587
  }
465
588
 
466
589
  function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksState["workspace"] {
@@ -562,7 +685,7 @@ function currentDeckBlocker(state: DecksState): string {
562
685
  return `${DECKS_STATE_FILE} contains multiple deck records and no activeDeck. Select one current deck explicitly or move extra decks to separate workspaces.`
563
686
  }
564
687
 
565
- function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"]): ReadinessIssue[] {
688
+ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["workspace"], options: ReviewDeckStateOptions = {}): ReadinessIssue[] {
566
689
  const issues: ReadinessIssue[] = []
567
690
  if (!deck.goal.trim()) issues.push(blockerIssue("missing_slide_spec", "Deck goal is missing", "Set the deck goal through revela-decks upsertDeck."))
568
691
  if (!isDeckHtmlPath(deck.outputPath)) {
@@ -592,12 +715,18 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
592
715
  if (!hasSlideContent(slide)) issues.push(blockerIssue("missing_slide_spec", `Slide ${slide.index} content is missing`, "Add structured headline/body/bullets/data content to the slide spec.", slideRef))
593
716
 
594
717
  const claim = findEvidenceSensitiveClaim(slide)
595
- if (claim && slide.evidence.length === 0) {
718
+ if (claim && slide.evidence.length === 0 && !isNavigationSlide(slide)) {
719
+ const { candidates: evidenceCandidates, search: evidenceCandidateSearch } = findEvidenceBindingCandidates(deck, slide, claim, options)
596
720
  issues.push(blockerIssue(
597
721
  "missing_evidence",
598
722
  `Slide ${slide.index} has an evidence-sensitive claim without evidence: ${claim}`,
599
723
  SOURCE_TRACE_ACTION,
600
- { ...slideRef, claimText: claim },
724
+ {
725
+ ...slideRef,
726
+ claimText: claim,
727
+ evidenceCandidates: evidenceCandidates.length > 0 ? evidenceCandidates : undefined,
728
+ evidenceCandidateSearch,
729
+ },
601
730
  ))
602
731
  } else if (claim && slide.evidence.some((item) => !hasEvidenceDetail(item))) {
603
732
  issues.push(warningIssue(
@@ -624,6 +753,7 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
624
753
  const hasNeededResearch = deck.researchPlan.some((axis) => axis.needed && axis.status !== "skipped")
625
754
  for (const material of workspace.sourceMaterials ?? []) {
626
755
  if (material.status !== "discovered") continue
756
+ if (isIgnorableSourceMaterial(material.path)) continue
627
757
  const message = `Source material ${material.path} has been identified but not extracted, summarized, or researched`
628
758
  if (hasNeededResearch) {
629
759
  issues.push(blockerIssue(
@@ -643,6 +773,297 @@ function computeDeckReadinessIssues(deck: DeckSpec, workspace: DecksState["works
643
773
  return issues
644
774
  }
645
775
 
776
+ function findEvidenceBindingCandidates(deck: DeckSpec, slide: SlideSpec, claimText: string, options: ReviewDeckStateOptions): { candidates: EvidenceBindingCandidate[]; search?: EvidenceCandidateSearchDiagnostic } {
777
+ if (!options.workspaceRoot) return { candidates: [] }
778
+ const queryText = slideSearchText(slide)
779
+ const queryTokens = meaningfulTokens(queryText)
780
+ if (queryTokens.length === 0) return { candidates: [] }
781
+
782
+ const candidates: EvidenceBindingCandidate[] = []
783
+ const search: EvidenceCandidateSearchDiagnostic = {
784
+ queryTokens,
785
+ researchPlanFindingsSearched: [],
786
+ fallbackResearchFilesSearched: [],
787
+ fallbackResearchFilesSkipped: [],
788
+ nearMisses: [],
789
+ }
790
+ const planFindings = new Set<string>()
791
+ for (const axis of deck.researchPlan) {
792
+ if (!axis.needed || (axis.status !== "done" && axis.status !== "read") || !axis.findingsFile?.trim()) continue
793
+ const normalizedFindingsFile = normalizePath(axis.findingsFile)
794
+ planFindings.add(normalizedFindingsFile)
795
+ search.researchPlanFindingsSearched.push(normalizedFindingsFile)
796
+ const findingsPath = safeWorkspacePath(options.workspaceRoot, axis.findingsFile)
797
+ if (!findingsPath || !existsSync(findingsPath)) continue
798
+ const text = readTextPrefix(findingsPath, 100_000)
799
+ if (!text.trim()) continue
800
+ const result = candidateFromFindingsFile({
801
+ slide,
802
+ claimText,
803
+ queryTokens,
804
+ findingsFile: normalizedFindingsFile,
805
+ text,
806
+ sourceKind: "researchPlan",
807
+ })
808
+ if (result.candidate) candidates.push(result.candidate)
809
+ else if (result.nearMiss) search.nearMisses.push(result.nearMiss)
810
+ }
811
+
812
+ if (candidates.length === 0) {
813
+ for (const findingsFile of listWorkspaceResearchFindings(options.workspaceRoot, planFindings)) {
814
+ search.fallbackResearchFilesSearched.push(findingsFile)
815
+ const findingsPath = safeWorkspacePath(options.workspaceRoot, findingsFile)
816
+ if (!findingsPath || !existsSync(findingsPath)) {
817
+ search.fallbackResearchFilesSkipped.push(findingsFile)
818
+ continue
819
+ }
820
+ const text = readTextPrefix(findingsPath, 100_000)
821
+ if (!text.trim()) {
822
+ search.fallbackResearchFilesSkipped.push(findingsFile)
823
+ continue
824
+ }
825
+ const result = candidateFromFindingsFile({
826
+ slide,
827
+ claimText,
828
+ queryTokens,
829
+ findingsFile,
830
+ text,
831
+ sourceKind: "researchesFallback",
832
+ })
833
+ if (result.candidate) candidates.push(result.candidate)
834
+ else if (result.nearMiss) search.nearMisses.push(result.nearMiss)
835
+ }
836
+ }
837
+
838
+ search.nearMisses = search.nearMisses
839
+ .sort((a, b) => b.bestScore - a.bestScore)
840
+ .slice(0, 5)
841
+ return {
842
+ candidates: candidates
843
+ .sort((a, b) => b.supportScope.length - a.supportScope.length)
844
+ .slice(0, 3),
845
+ search,
846
+ }
847
+ }
848
+
849
+ function candidateFromFindingsFile({
850
+ slide,
851
+ claimText,
852
+ queryTokens,
853
+ findingsFile,
854
+ text,
855
+ sourceKind,
856
+ }: {
857
+ slide: SlideSpec
858
+ claimText: string
859
+ queryTokens: string[]
860
+ findingsFile: string
861
+ text: string
862
+ sourceKind: "researchPlan" | "researchesFallback"
863
+ }): { candidate?: EvidenceBindingCandidate; nearMiss?: EvidenceCandidateNearMiss } {
864
+ const lines = extractFindingsLines(text)
865
+ let best: { line: string; scope: string[]; score: number } | undefined
866
+ for (const line of lines) {
867
+ const normalizedLine = line.toLowerCase()
868
+ const scope = queryTokens.filter((token) => normalizedLine.includes(token))
869
+ const phraseScore = importantPhrases(slide).filter((phrase) => normalizedLine.includes(phrase)).length * 2
870
+ const score = scope.length + phraseScore
871
+ if (!best || score > best.score) best = { line, scope, score }
872
+ }
873
+ if (!best || best.score <= 0) return {}
874
+
875
+ const threshold = 2
876
+ const supportScope = [...new Set(best.scope)].slice(0, 8)
877
+ if (best.score < threshold) {
878
+ return {
879
+ nearMiss: {
880
+ findingsFile,
881
+ sourceKind,
882
+ bestScore: best.score,
883
+ threshold,
884
+ supportScope,
885
+ quote: best.line,
886
+ reason: `Best matching line scored ${best.score}, below binding threshold ${threshold}.`,
887
+ },
888
+ }
889
+ }
890
+
891
+ const sourcePath = extractSourcePath(text)
892
+ const coverage = supportScope.length / Math.max(1, queryTokens.length)
893
+ const supportStrength = best.score >= Math.min(5, Math.max(3, queryTokens.length)) && coverage >= 0.5 ? "strong" : "partial"
894
+ const unsupportedScope = unsupportedClaimScope(slide, best.line).slice(0, 5)
895
+ const caveats = []
896
+ if (supportStrength === "partial") {
897
+ caveats.push("Candidate support is partial. Bind only the matched claim scope; do not use it to support unrelated future-state or recommendation claims on the same slide.")
898
+ }
899
+ if (sourceKind === "researchesFallback") {
900
+ caveats.push("Candidate was discovered from researches/ fallback and is not referenced by researchPlan; confirm relevance before binding it into slide evidence.")
901
+ }
902
+ if (unsupportedScope.length > 0) {
903
+ caveats.push(`Unsupported claim scope: ${unsupportedScope.join("; ")}.`)
904
+ }
905
+ const caveat = caveats.length > 0 ? caveats.join(" ") : undefined
906
+ const evidenceDraft: EvidenceRef = {
907
+ source: sourcePath || findingsFile,
908
+ findingsFile,
909
+ sourcePath,
910
+ location: "research findings excerpt",
911
+ quote: best.line,
912
+ caveat,
913
+ }
914
+ return {
915
+ candidate: {
916
+ candidateId: evidenceCandidateId(slide.index, findingsFile, best.line, supportScope),
917
+ slideIndex: slide.index,
918
+ slideTitle: slide.title,
919
+ claimText,
920
+ source: sourcePath || findingsFile,
921
+ findingsFile,
922
+ sourcePath,
923
+ location: "research findings excerpt",
924
+ quote: best.line,
925
+ caveat,
926
+ supportScope,
927
+ supportStrength,
928
+ sourceKind,
929
+ evidenceDraft,
930
+ unsupportedScope,
931
+ recommendedRewrite: recommendedEvidenceRewrite(supportScope, unsupportedScope),
932
+ },
933
+ }
934
+ }
935
+
936
+ function unsupportedClaimScope(slide: SlideSpec, supportedLine: string): string[] {
937
+ const normalizedLine = supportedLine.toLowerCase()
938
+ const phrases = [slide.purpose, slide.content?.headline, ...(slide.content?.bullets ?? [])]
939
+ .map((item) => cleanMarkdownText(item ?? ""))
940
+ .filter((item) => item.length >= 8)
941
+
942
+ return [...new Set(phrases.filter((phrase) => {
943
+ const normalizedPhrase = phrase.toLowerCase()
944
+ return FUTURE_STATE_SCOPE_PATTERN.test(normalizedPhrase) && !normalizedLine.includes(normalizedPhrase)
945
+ }))]
946
+ }
947
+
948
+ function recommendedEvidenceRewrite(supportScope: string[], unsupportedScope: string[]): string | undefined {
949
+ if (unsupportedScope.length === 0) return undefined
950
+ const supported = supportScope.length > 0 ? supportScope.join(", ") : "the quoted current-state support"
951
+ return `Bind this evidence only to the supported scope (${supported}). Reframe unsupported scope as internal synthesis, target-state hypothesis, or a separately sourced claim: ${unsupportedScope.join("; ")}.`
952
+ }
953
+
954
+ function evidenceCandidateId(slideIndex: number, findingsFile: string, quote: string, supportScope: string[]): string {
955
+ const hash = createHash("sha1")
956
+ .update(JSON.stringify({ slideIndex, findingsFile, quote, supportScope }))
957
+ .digest("hex")
958
+ .slice(0, 8)
959
+ return `s${slideIndex}-${hash}`
960
+ }
961
+
962
+ function cleanEvidenceRef(evidence: EvidenceRef): EvidenceRef {
963
+ const cleaned: EvidenceRef = { source: cleanMarkdownText(evidence.source) }
964
+ for (const key of ["quote", "page", "url", "sourcePath", "location", "findingsFile", "caveat", "extractedTextPath", "extractedManifestPath"] as const) {
965
+ const value = cleanOptionalText(evidence[key])
966
+ if (value) cleaned[key] = value
967
+ }
968
+ return cleaned
969
+ }
970
+
971
+ function sameEvidenceRef(a: EvidenceRef, b: EvidenceRef): boolean {
972
+ return normalizeEvidenceComparable(a) === normalizeEvidenceComparable(b)
973
+ }
974
+
975
+ function normalizeEvidenceComparable(evidence: EvidenceRef): string {
976
+ const cleaned = cleanEvidenceRef(evidence)
977
+ return JSON.stringify({
978
+ source: cleaned.source,
979
+ findingsFile: cleaned.findingsFile,
980
+ sourcePath: cleaned.sourcePath,
981
+ quote: cleaned.quote,
982
+ location: cleaned.location,
983
+ caveat: cleaned.caveat,
984
+ })
985
+ }
986
+
987
+ function listWorkspaceResearchFindings(workspaceRoot: string, exclude: Set<string>): string[] {
988
+ const researchRoot = safeWorkspacePath(workspaceRoot, "researches")
989
+ if (!researchRoot || !existsSync(researchRoot)) return []
990
+ const files: string[] = []
991
+ collectMarkdownFiles(researchRoot, files, 0)
992
+ return files
993
+ .map((file) => normalizePath(file.slice(resolve(workspaceRoot).length + 1)))
994
+ .filter((file) => file.startsWith("researches/") && !exclude.has(file))
995
+ .slice(0, 50)
996
+ }
997
+
998
+ function collectMarkdownFiles(dir: string, output: string[], depth: number): void {
999
+ if (depth > 4) return
1000
+ let entries: string[]
1001
+ try {
1002
+ entries = readdirSync(dir)
1003
+ } catch {
1004
+ return
1005
+ }
1006
+ for (const entry of entries) {
1007
+ const fullPath = join(dir, entry)
1008
+ let stat
1009
+ try {
1010
+ stat = statSync(fullPath)
1011
+ } catch {
1012
+ continue
1013
+ }
1014
+ if (stat.isDirectory()) {
1015
+ collectMarkdownFiles(fullPath, output, depth + 1)
1016
+ } else if (stat.isFile() && entry.endsWith(".md")) {
1017
+ output.push(fullPath)
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ function safeWorkspacePath(workspaceRoot: string, relativePath: string): string | undefined {
1023
+ const root = resolve(workspaceRoot)
1024
+ const target = resolve(root, relativePath)
1025
+ if (target !== root && !target.startsWith(root + "/")) return undefined
1026
+ return target
1027
+ }
1028
+
1029
+ function readTextPrefix(filePath: string, maxChars: number): string {
1030
+ try {
1031
+ return readFileSync(filePath, "utf-8").slice(0, maxChars)
1032
+ } catch {
1033
+ return ""
1034
+ }
1035
+ }
1036
+
1037
+ function extractFindingsLines(text: string): string[] {
1038
+ return text
1039
+ .split(/\r?\n/)
1040
+ .map((line) => line.replace(/^\s*(?:[-*+]\s+|\d+\.\s+|>\s*)/, "").trim())
1041
+ .filter((line) => line.length >= 24 && !/^---$/.test(line) && !/^#/.test(line))
1042
+ .slice(0, 300)
1043
+ }
1044
+
1045
+ function extractSourcePath(text: string): string | undefined {
1046
+ const sourceLine = text.split(/\r?\n/).find((line) => /^\s*(?:[-*+]\s*)?(?:source|来源)\s*:/i.test(line))
1047
+ if (!sourceLine) return undefined
1048
+ return cleanMarkdownText(sourceLine.replace(/^\s*(?:[-*+]\s*)?(?:source|来源)\s*:\s*/i, "")) || undefined
1049
+ }
1050
+
1051
+ function meaningfulTokens(text: string): string[] {
1052
+ const normalized = text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, " ")
1053
+ const latin = normalized
1054
+ .split(/\s+/)
1055
+ .map((token) => token.trim())
1056
+ .filter((token) => token.length >= 4 && !EVIDENCE_BINDING_STOPWORDS.has(token))
1057
+ const chinese = Array.from(normalized.matchAll(/[\u4e00-\u9fa5]{2,}/g), (match) => match[0])
1058
+ return [...new Set([...latin, ...chinese])].slice(0, 40)
1059
+ }
1060
+
1061
+ function importantPhrases(slide: SlideSpec): string[] {
1062
+ return [slide.title, slide.content?.headline, ...(slide.content?.bullets ?? [])]
1063
+ .map((item) => item?.trim().toLowerCase())
1064
+ .filter((item): item is string => Boolean(item && item.length >= 8 && item.length <= 80))
1065
+ }
1066
+
646
1067
  function computeNarrativeReadinessIssues(deck: DeckSpec): ReadinessIssue[] {
647
1068
  const issues: ReadinessIssue[] = []
648
1069
  const slides = deck.slides.filter((slide) => slide.index > 0).sort((a, b) => a.index - b.index)
@@ -842,6 +1263,25 @@ function hasClearEnding(slides: SlideSpec[]): boolean {
842
1263
  return finalSlides.some((slide) => slide.narrativeRole === "recommendation" || slide.narrativeRole === "ask" || slide.narrativeRole === "close" || /\b(so what|takeaway|recommend(?:ation)?|decision|ask|next step|conclusion|close)\b|结论|建议|决策|请求|下一步|收尾|总结/.test(slideSearchText(slide)))
843
1264
  }
844
1265
 
1266
+ function isNavigationSlide(slide: SlideSpec): boolean {
1267
+ const text = slideSearchText(slide)
1268
+ return slide.layout === "toc" || /\b(table of contents|agenda|contents|outline|section guide)\b|目录|议程|大纲/.test(text)
1269
+ }
1270
+
1271
+ function isIgnorableSourceMaterial(path: string): boolean {
1272
+ const normalized = normalizePath(path).replace(/^\.\//, "")
1273
+ const name = basename(normalized)
1274
+ return Boolean(
1275
+ name.startsWith("~$") ||
1276
+ /^(AGENTS|README(?:\.zh-CN)?|DECKS)\.md$/.test(name) ||
1277
+ name === DECKS_STATE_FILE ||
1278
+ normalized.startsWith("decks/") ||
1279
+ normalized.startsWith("researches/") ||
1280
+ normalized.startsWith("assets/") ||
1281
+ normalized.startsWith(".opencode/"),
1282
+ )
1283
+ }
1284
+
845
1285
  function slideSearchText(slide: SlideSpec): string {
846
1286
  return [
847
1287
  slide.title,
@@ -918,6 +1358,36 @@ const EVIDENCE_SENSITIVE_TERMS = [
918
1358
  /可扩展/,
919
1359
  ]
920
1360
 
1361
+ const FUTURE_STATE_SCOPE_PATTERN = /\b(20\d{2}|future|target-state|end state|roadmap|pathway|architecture|capabilit(?:y|ies)|autonomy|autonomous|self-organizing|ecosystem|ai manufacturing os|ai brain|digital workers|closed-loop|orchestration)\b|未来|目标态|路线图|架构|能力|自治|自组织|生态|智能体|闭环/
1362
+
1363
+ const EVIDENCE_BINDING_STOPWORDS = new Set([
1364
+ "about",
1365
+ "after",
1366
+ "again",
1367
+ "also",
1368
+ "before",
1369
+ "between",
1370
+ "could",
1371
+ "deck",
1372
+ "from",
1373
+ "have",
1374
+ "into",
1375
+ "must",
1376
+ "only",
1377
+ "over",
1378
+ "page",
1379
+ "roadmap",
1380
+ "show",
1381
+ "slide",
1382
+ "that",
1383
+ "their",
1384
+ "there",
1385
+ "this",
1386
+ "through",
1387
+ "with",
1388
+ "would",
1389
+ ])
1390
+
921
1391
  function normalizeSlides(slides: SlideSpec[]): SlideSpec[] {
922
1392
  return slides
923
1393
  .map((slide) => ({
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "fs"
2
+ import { ACTIVE_PROMPT_FILE } from "../config"
3
+ import { ctx } from "../ctx"
4
+ import { seedBuiltinDesigns } from "../design/designs"
5
+ import { seedBuiltinDomains } from "../domain/domains"
6
+ import { ensureEditableDeckState } from "../edit/deck-state"
7
+ import { openUrl } from "../edit/open"
8
+ import { resolveEditableDeck, type EditableDeck } from "../edit/resolve-deck"
9
+ import { buildPrompt } from "../prompt-builder"
10
+ import { startInspectServer } from "./server"
11
+
12
+ export interface OpenInspectDeckResult {
13
+ deck: EditableDeck
14
+ url: string
15
+ source: string
16
+ stateNote: string
17
+ preflightChanged: boolean
18
+ reusedSession: boolean
19
+ openedBrowser: boolean
20
+ }
21
+
22
+ export interface OpenInspectDeckOptions {
23
+ client: any
24
+ sessionID: string
25
+ workspaceRoot: string
26
+ openBrowser?: boolean
27
+ openUrl?: (url: string) => void
28
+ }
29
+
30
+ export function openInspectDeck(target: string, options: OpenInspectDeckOptions): OpenInspectDeckResult {
31
+ const deck = resolveEditableDeck(options.workspaceRoot, target)
32
+ const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
33
+
34
+ ctx.enabled = true
35
+ if (!existsSync(ACTIVE_PROMPT_FILE)) {
36
+ seedBuiltinDesigns()
37
+ seedBuiltinDomains()
38
+ buildPrompt()
39
+ }
40
+
41
+ const inspectServer = startInspectServer()
42
+ const session = inspectServer.getOrCreateSession({
43
+ client: options.client,
44
+ sessionID: options.sessionID,
45
+ workspaceRoot: options.workspaceRoot,
46
+ deck,
47
+ })
48
+ const url = `${inspectServer.baseUrl}/inspect?token=${encodeURIComponent(session.token)}`
49
+ const shouldOpen = options.openBrowser !== false
50
+ if (shouldOpen) (options.openUrl ?? openUrl)(url)
51
+
52
+ return {
53
+ deck,
54
+ url,
55
+ source: deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
56
+ stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for inspection." : "Deck state already points to this inspection target.",
57
+ preflightChanged: preflight.changed,
58
+ reusedSession: session.reused,
59
+ openedBrowser: shouldOpen,
60
+ }
61
+ }
@@ -0,0 +1,32 @@
1
+ import type { InspectionPromptProjection } from "../inspection-context/project"
2
+
3
+ export function buildInspectionPrompt(input: {
4
+ requestId: string
5
+ file: string
6
+ projection: InspectionPromptProjection
7
+ }): string {
8
+ return `A user selected slide content in Revela Evidence Inspector. The selection may contain one referenced element, a whole slide, or multiple referenced elements selected with Cmd/Ctrl-click.
9
+
10
+ Target file: ${input.file}
11
+ Inspection request id: ${input.requestId}
12
+
13
+ Use the structured projection below to produce the final inspector cards. This is LLM judgment with grounded boundaries: answer the selected object's purpose and source credibility only. Do not edit files. Do not mutate DECKS.json. Do not invent sources, quotes, URLs, page references, caveats, or evidence not present in the projection.
14
+
15
+ Return the result only by calling the \`revela-inspection-result\` tool with this request id. Do not answer in chat.
16
+
17
+ Required card model:
18
+ - Purpose: explain why this selected content appears here, what job it serves in the slide purpose, narrative role, deck goal, audience, or narrative brief, and why it matters.
19
+ - Source: if the selection contains a factual claim, number, comparison, conclusion, or recommendation, judge source credibility. Use not_needed for structural, transitional, or purely explanatory content that does not need evidence. Include source trace, warnings, gaps, and caveats here.
20
+
21
+ Boundaries:
22
+ - Do not hunt for problems. If it works, say it works.
23
+ - Do not recommend edits or fixes; this inspector view only explains purpose and source credibility.
24
+ - Do not turn every caveat into a problem.
25
+ - If confidence is low, use unclear or unknown instead of pretending certainty.
26
+
27
+ Projection JSON:
28
+
29
+ \`\`\`json
30
+ ${JSON.stringify(input.projection, null, 2)}
31
+ \`\`\``
32
+ }