dreamcontext 0.5.3 → 0.6.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.
Files changed (109) hide show
  1. package/LICENSE +201 -21
  2. package/NOTICE +7 -0
  3. package/README.md +77 -12
  4. package/agents/dreamcontext-explore.md +1 -1
  5. package/agents/sleep-product.md +29 -8
  6. package/agents/sleep-tasks.md +44 -9
  7. package/dist/agents/dreamcontext-explore.md +1 -1
  8. package/dist/agents/sleep-product.md +29 -8
  9. package/dist/agents/sleep-tasks.md +44 -9
  10. package/dist/dashboard/assets/{BrainCanvas3D-1HMiA14F.js → BrainCanvas3D-LLqVeXtb.js} +1 -1
  11. package/dist/dashboard/assets/{_baseUniq-C7pfRmXz.js → _baseUniq-DW0uA0ty.js} +1 -1
  12. package/dist/dashboard/assets/{arc-B5x_GF9Q.js → arc-fVUGtlbL.js} +1 -1
  13. package/dist/dashboard/assets/{architectureDiagram-Q4EWVU46-CWmjaqno.js → architectureDiagram-Q4EWVU46-Dz2PKBsS.js} +1 -1
  14. package/dist/dashboard/assets/{blockDiagram-DXYQGD6D-B7SyUIdV.js → blockDiagram-DXYQGD6D-ByWKxzhL.js} +1 -1
  15. package/dist/dashboard/assets/{c4Diagram-AHTNJAMY-OeJBWXHU.js → c4Diagram-AHTNJAMY-dpHsVM3D.js} +1 -1
  16. package/dist/dashboard/assets/channel-zrLwggBX.js +1 -0
  17. package/dist/dashboard/assets/{chunk-4BX2VUAB-2Oop1B0M.js → chunk-4BX2VUAB-HEXb6Yg5.js} +1 -1
  18. package/dist/dashboard/assets/{chunk-4TB4RGXK-Bh7qSu_X.js → chunk-4TB4RGXK-DeVy5g6H.js} +1 -1
  19. package/dist/dashboard/assets/{chunk-55IACEB6-gNeHRO_i.js → chunk-55IACEB6-DGy3ZgDZ.js} +1 -1
  20. package/dist/dashboard/assets/{chunk-EDXVE4YY-BRniAH53.js → chunk-EDXVE4YY-CYJehjz4.js} +1 -1
  21. package/dist/dashboard/assets/{chunk-FMBD7UC4-B5r8U2gJ.js → chunk-FMBD7UC4-DKTOmvgZ.js} +1 -1
  22. package/dist/dashboard/assets/{chunk-OYMX7WX6-Dd1c17fm.js → chunk-OYMX7WX6-mmtWyiDA.js} +1 -1
  23. package/dist/dashboard/assets/{chunk-QZHKN3VN-BIw-OdTn.js → chunk-QZHKN3VN-DSdV0iwY.js} +1 -1
  24. package/dist/dashboard/assets/{chunk-YZCP3GAM-Dl9MlwCW.js → chunk-YZCP3GAM-DbY-KoDn.js} +1 -1
  25. package/dist/dashboard/assets/classDiagram-6PBFFD2Q-BvejNiwH.js +1 -0
  26. package/dist/dashboard/assets/classDiagram-v2-HSJHXN6E-BvejNiwH.js +1 -0
  27. package/dist/dashboard/assets/clone-BFVdml6g.js +1 -0
  28. package/dist/dashboard/assets/{cose-bilkent-S5V4N54A-DlrROH5K.js → cose-bilkent-S5V4N54A-p0t1DY88.js} +1 -1
  29. package/dist/dashboard/assets/{dagre-KV5264BT-B0GpPXKL.js → dagre-KV5264BT-DYE0WzHM.js} +1 -1
  30. package/dist/dashboard/assets/{diagram-5BDNPKRD-BBYcDW2Z.js → diagram-5BDNPKRD-D9EiQCOP.js} +1 -1
  31. package/dist/dashboard/assets/{diagram-G4DWMVQ6-BxHxugdp.js → diagram-G4DWMVQ6-poDXSAfu.js} +1 -1
  32. package/dist/dashboard/assets/{diagram-MMDJMWI5-C9Jc19Wo.js → diagram-MMDJMWI5-CHZ7wgN1.js} +1 -1
  33. package/dist/dashboard/assets/{diagram-TYMM5635-Vmo5GAAU.js → diagram-TYMM5635-DVk6fYG7.js} +1 -1
  34. package/dist/dashboard/assets/{erDiagram-SMLLAGMA-pskr9x-V.js → erDiagram-SMLLAGMA-D2sqkGin.js} +1 -1
  35. package/dist/dashboard/assets/{flowDiagram-DWJPFMVM-B1D7Ky0W.js → flowDiagram-DWJPFMVM-DrMlKBYA.js} +1 -1
  36. package/dist/dashboard/assets/{ganttDiagram-T4ZO3ILL-BVJZZV1M.js → ganttDiagram-T4ZO3ILL-Cf4FbEFp.js} +1 -1
  37. package/dist/dashboard/assets/{gitGraphDiagram-UUTBAWPF-BiOGN6We.js → gitGraphDiagram-UUTBAWPF-BsbOq1C9.js} +1 -1
  38. package/dist/dashboard/assets/{graph-D58deEXr.js → graph-CUxxgCtS.js} +1 -1
  39. package/dist/dashboard/assets/index-CqSkXBSu.css +1 -0
  40. package/dist/dashboard/assets/index-flRpQtDj.js +476 -0
  41. package/dist/dashboard/assets/{infoDiagram-42DDH7IO-DgJYg1X1.js → infoDiagram-42DDH7IO-DTkMnZiD.js} +1 -1
  42. package/dist/dashboard/assets/{ishikawaDiagram-UXIWVN3A-arJgr-8P.js → ishikawaDiagram-UXIWVN3A-CahQ348K.js} +1 -1
  43. package/dist/dashboard/assets/{journeyDiagram-VCZTEJTY-B3DCvb8p.js → journeyDiagram-VCZTEJTY-BDY2Nslc.js} +1 -1
  44. package/dist/dashboard/assets/{kanban-definition-6JOO6SKY-B--dN6Ma.js → kanban-definition-6JOO6SKY-CgrwoTjI.js} +1 -1
  45. package/dist/dashboard/assets/{layout-z2qEbZWC.js → layout-IaUxkFkm.js} +1 -1
  46. package/dist/dashboard/assets/{linear-BQu1dwrM.js → linear-shc0iNFn.js} +1 -1
  47. package/dist/dashboard/assets/{min-DkF5b2sh.js → min-BIL7YgTN.js} +1 -1
  48. package/dist/dashboard/assets/{mindmap-definition-QFDTVHPH-Bw04koyq.js → mindmap-definition-QFDTVHPH-GmO0JRtA.js} +1 -1
  49. package/dist/dashboard/assets/{pieDiagram-DEJITSTG-vNqz64o8.js → pieDiagram-DEJITSTG-Bfm_toYA.js} +1 -1
  50. package/dist/dashboard/assets/{quadrantDiagram-34T5L4WZ-TuVP5Gi6.js → quadrantDiagram-34T5L4WZ-DIjTM3lm.js} +1 -1
  51. package/dist/dashboard/assets/{requirementDiagram-MS252O5E-D3mS8_ke.js → requirementDiagram-MS252O5E-B9AEoGYh.js} +1 -1
  52. package/dist/dashboard/assets/{sankeyDiagram-XADWPNL6-BmVRers6.js → sankeyDiagram-XADWPNL6-C_O5XLXm.js} +1 -1
  53. package/dist/dashboard/assets/{sequenceDiagram-FGHM5R23-CTYCgk5P.js → sequenceDiagram-FGHM5R23-CHSxSvmJ.js} +1 -1
  54. package/dist/dashboard/assets/{stateDiagram-FHFEXIEX-DNbP2aCg.js → stateDiagram-FHFEXIEX-B0URrhHw.js} +1 -1
  55. package/dist/dashboard/assets/stateDiagram-v2-QKLJ7IA2-FdAb9MBo.js +1 -0
  56. package/dist/dashboard/assets/{timeline-definition-GMOUNBTQ-DKHJd4z8.js → timeline-definition-GMOUNBTQ-CycU2gVC.js} +1 -1
  57. package/dist/dashboard/assets/{vennDiagram-DHZGUBPP-ChotNMNq.js → vennDiagram-DHZGUBPP-DcU2536G.js} +1 -1
  58. package/dist/dashboard/assets/{wardley-RL74JXVD-Dmo2_ksP.js → wardley-RL74JXVD-BWqqaX3S.js} +1 -1
  59. package/dist/dashboard/assets/{wardleyDiagram-NUSXRM2D-Z6Wbiv2H.js → wardleyDiagram-NUSXRM2D-CUnIHJgd.js} +1 -1
  60. package/dist/dashboard/assets/{xychartDiagram-5P7HB3ND-BJa0AHtN.js → xychartDiagram-5P7HB3ND-b9fcOYTj.js} +1 -1
  61. package/dist/dashboard/index.html +2 -2
  62. package/dist/dashboard/media/README.md +29 -0
  63. package/dist/dashboard/media/brain-hero.png +0 -0
  64. package/dist/dashboard/media/brain.mp4 +0 -0
  65. package/dist/dashboard/media/brain.webm +0 -0
  66. package/dist/dashboard/media/shot-disabled.png +0 -0
  67. package/dist/dashboard/media/shot-enabled.png +0 -0
  68. package/dist/index.js +3408 -2051
  69. package/dist/skill-packs/catalog.json +44 -0
  70. package/dist/skill-packs/excalidraw/SKILL.md +123 -0
  71. package/dist/skill-packs/excalidraw/examples/hello.spec.json +14 -0
  72. package/dist/skill-packs/excalidraw/examples/sample.png +0 -0
  73. package/dist/skill-packs/excalidraw/examples/style_board.js +31 -0
  74. package/dist/skill-packs/excalidraw/package.json +5 -0
  75. package/dist/skill-packs/excalidraw/reference/format.md +93 -0
  76. package/dist/skill-packs/excalidraw/scripts/build_excalidraw.js +357 -0
  77. package/dist/skill-packs/excalidraw/scripts/lib/fractional-indexing.LICENSE +121 -0
  78. package/dist/skill-packs/excalidraw/scripts/lib/fractional-indexing.js +311 -0
  79. package/dist/skill-packs/excalidraw/scripts/lib/imagesize.js +65 -0
  80. package/dist/skill-packs/excalidraw/scripts/lib/style.js +130 -0
  81. package/dist/skill-packs/video-watching/SKILL.md +151 -0
  82. package/dist/skill-packs/video-watching/scripts/build_frame_index.py +95 -0
  83. package/dist/skill-packs/video-watching/scripts/gap_fill.py +45 -0
  84. package/dist/skill-packs/video-watching/scripts/transcribe.sh +136 -0
  85. package/package.json +4 -2
  86. package/skill/SKILL.md +5 -0
  87. package/skill-packs/catalog.json +44 -0
  88. package/skill-packs/excalidraw/SKILL.md +123 -0
  89. package/skill-packs/excalidraw/examples/hello.spec.json +14 -0
  90. package/skill-packs/excalidraw/examples/sample.png +0 -0
  91. package/skill-packs/excalidraw/examples/style_board.js +31 -0
  92. package/skill-packs/excalidraw/package.json +5 -0
  93. package/skill-packs/excalidraw/reference/format.md +93 -0
  94. package/skill-packs/excalidraw/scripts/build_excalidraw.js +357 -0
  95. package/skill-packs/excalidraw/scripts/lib/fractional-indexing.LICENSE +121 -0
  96. package/skill-packs/excalidraw/scripts/lib/fractional-indexing.js +311 -0
  97. package/skill-packs/excalidraw/scripts/lib/imagesize.js +65 -0
  98. package/skill-packs/excalidraw/scripts/lib/style.js +130 -0
  99. package/skill-packs/video-watching/SKILL.md +151 -0
  100. package/skill-packs/video-watching/scripts/build_frame_index.py +95 -0
  101. package/skill-packs/video-watching/scripts/gap_fill.py +45 -0
  102. package/skill-packs/video-watching/scripts/transcribe.sh +136 -0
  103. package/dist/dashboard/assets/channel-CbuTKKsM.js +0 -1
  104. package/dist/dashboard/assets/classDiagram-6PBFFD2Q-HXl3A_sM.js +0 -1
  105. package/dist/dashboard/assets/classDiagram-v2-HSJHXN6E-HXl3A_sM.js +0 -1
  106. package/dist/dashboard/assets/clone-NKHOtdi8.js +0 -1
  107. package/dist/dashboard/assets/index-DK_2-eWY.css +0 -1
  108. package/dist/dashboard/assets/index-Ds-JXWtr.js +0 -476
  109. package/dist/dashboard/assets/stateDiagram-v2-QKLJ7IA2-CfN6kjo8.js +0 -1
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env python3
2
+ """Build frames.json: map each extracted frame to its pts_time on the video timeline.
3
+
4
+ Reads the per-frame pts_time dumps (scene_times.txt from ffmpeg metadata, gap_times.txt
5
+ written by transcribe.sh) alongside the frames, pairs each timestamp with its
6
+ zero-padded frame file in selection order, adds the first/last anchor frames, drops
7
+ near-duplicate timestamps, and emits frames.json sorted by time.
8
+
9
+ Stdlib only — no venv needed.
10
+ Usage: build_frame_index.py <OUT_DIR> [DURATION_SEC]
11
+ Env: DEDUPE_SEC=0.4 collapse non-anchor frames closer than this to the kept one.
12
+ """
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ # metadata=print header line, e.g.: "frame:0 pts:321024 pts_time:12.852500"
20
+ FRAME_RE = re.compile(r"frame:(\d+)\b.*?pts_time:([\d.]+)", re.DOTALL)
21
+ DEDUPE_SEC = float(os.environ.get("DEDUPE_SEC", "0.4"))
22
+
23
+
24
+ def parse_times(meta_file: Path) -> dict[int, float]:
25
+ """frame index (0-based, as ffmpeg numbers kept frames) -> pts_time seconds."""
26
+ if not meta_file.exists():
27
+ return {}
28
+ text = meta_file.read_text(errors="replace")
29
+ return {int(n): round(float(t), 2) for n, t in FRAME_RE.findall(text)}
30
+
31
+
32
+ def collect(out_dir: Path, prefix: str, meta_name: str, kind: str) -> list[dict]:
33
+ times = parse_times(out_dir / "frames" / meta_name)
34
+ frames = sorted((out_dir / "frames").glob(f"{prefix}_*.jpg"))
35
+ # transcribe.sh names files 1-based (%04d starts at 0001); metadata frame: is 0-based.
36
+ return [{"file": f"frames/{f.name}", "t": times.get(i), "type": kind}
37
+ for i, f in enumerate(frames)]
38
+
39
+
40
+ def anchors(out_dir: Path, duration: float) -> list[dict]:
41
+ rows = []
42
+ if (out_dir / "frames" / "anchor_first.jpg").exists():
43
+ rows.append({"file": "frames/anchor_first.jpg", "t": 0.0, "type": "anchor"})
44
+ if (out_dir / "frames" / "anchor_last.jpg").exists():
45
+ t = round(duration, 2) if duration > 0 else None
46
+ rows.append({"file": "frames/anchor_last.jpg", "t": t, "type": "anchor"})
47
+ return rows
48
+
49
+
50
+ def fmt(t):
51
+ if t is None:
52
+ return "??:??"
53
+ m, s = divmod(int(t), 60)
54
+ return f"{m:02d}:{s:02d}"
55
+
56
+
57
+ def dedupe(rows: list[dict]) -> list[dict]:
58
+ """Keep all anchors; drop a non-anchor frame within DEDUPE_SEC of the last kept."""
59
+ kept: list[dict] = []
60
+ last_t = None
61
+ for r in rows:
62
+ is_anchor = r["type"] == "anchor"
63
+ if (not is_anchor and last_t is not None and r["t"] is not None
64
+ and abs(r["t"] - last_t) < DEDUPE_SEC):
65
+ continue
66
+ kept.append(r)
67
+ if r["t"] is not None:
68
+ last_t = r["t"]
69
+ return kept
70
+
71
+
72
+ def main() -> int:
73
+ if len(sys.argv) < 2:
74
+ print("usage: build_frame_index.py <OUT_DIR> [DURATION_SEC]", file=sys.stderr)
75
+ return 2
76
+ out_dir = Path(sys.argv[1])
77
+ duration = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0
78
+
79
+ rows = collect(out_dir, "scene", "scene_times.txt", "scene")
80
+ rows += collect(out_dir, "gap", "gap_times.txt", "gap")
81
+ rows += anchors(out_dir, duration)
82
+ # Sort by time; unknown timestamps sink to the end.
83
+ rows.sort(key=lambda r: (r["t"] is None, r["t"] or 0.0))
84
+ rows = dedupe(rows)
85
+ for r in rows:
86
+ r["at"] = fmt(r["t"])
87
+
88
+ (out_dir / "frames.json").write_text(json.dumps(rows, ensure_ascii=False, indent=2))
89
+ n_anchor = sum(r["type"] == "anchor" for r in rows)
90
+ print(f" frames.json: {len(rows)} frames indexed ({n_anchor} anchors, dedupe<{DEDUPE_SEC}s)")
91
+ return 0
92
+
93
+
94
+ if __name__ == "__main__":
95
+ raise SystemExit(main())
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python3
2
+ """Print timestamps that keep every unsampled stretch of the video <= MAX_GAP.
3
+
4
+ Scene-detect fires on motion, not on meaning — so static-but-information-rich
5
+ sections (app demos, slides, talking heads) get no frame. This reads the scene
6
+ timestamps, bounds them with 0 and the video duration, and for any gap larger than
7
+ MAX_GAP emits evenly-spaced fill timestamps so no stretch goes unsampled.
8
+
9
+ Stdlib only. Usage: gap_fill.py <OUT_DIR> <DURATION_SEC> [MAX_GAP_SEC]
10
+ Prints one timestamp (seconds, 2dp) per line — transcribe.sh grabs each frame.
11
+ """
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ FRAME_RE = re.compile(r"frame:(\d+)\b.*?pts_time:([\d.]+)", re.DOTALL)
17
+
18
+
19
+ def main() -> int:
20
+ if len(sys.argv) < 3:
21
+ print("usage: gap_fill.py <OUT_DIR> <DURATION_SEC> [MAX_GAP_SEC]", file=sys.stderr)
22
+ return 2
23
+ out_dir = Path(sys.argv[1])
24
+ duration = float(sys.argv[2])
25
+ max_gap = float(sys.argv[3]) if len(sys.argv) > 3 else 10.0
26
+ if duration <= 0 or max_gap <= 0:
27
+ return 0
28
+
29
+ meta = out_dir / "frames" / "scene_times.txt"
30
+ scene_times = []
31
+ if meta.exists():
32
+ scene_times = [round(float(t), 2)
33
+ for _, t in FRAME_RE.findall(meta.read_text(errors="replace"))]
34
+
35
+ bounds = sorted(set([0.0] + scene_times + [round(duration, 2)]))
36
+ for a, b in zip(bounds, bounds[1:]):
37
+ gap = b - a
38
+ n = int(gap // max_gap) # inserts that split the gap into <= max_gap pieces
39
+ for k in range(1, n + 1):
40
+ print(f"{a + gap * k / (n + 1):.2f}")
41
+ return 0
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bash
2
+ # Video -> time-mapped transcript + significant frames (with per-frame timestamps).
3
+ # whisper.cpp (large-v3-turbo by default) for the transcript, ffmpeg for frames.
4
+ #
5
+ # This is the MECHANICAL step. It produces artifacts; it does NOT understand the
6
+ # video. Claude reads the outputs (transcript + frames.json), decides which frames
7
+ # matter, views them, and writes the curated <video>.transcript.md (see SKILL.md).
8
+ #
9
+ # Usage: ./transcribe.sh "/abs/path/to/video.mov" [lang]
10
+ # lang: whisper language code. DEFAULT 'auto' — whisper detects it, no need to pass.
11
+ # Force only if auto mislabels a short/ambiguous clip (e.g. 'tr', 'en').
12
+ #
13
+ # Env overrides:
14
+ # WHISPER_MODEL=/abs/path/ggml-*.bin force a specific model
15
+ # OUT_DIR=/abs/path where artifacts go (default: <video_dir>/<slug>.media)
16
+ # SCENE_THRESHOLD=0.15 ffmpeg scene-change sensitivity (lower = more frames)
17
+ # MAX_GAP=10 guarantee a frame at least every N seconds (fills static sections)
18
+ #
19
+ # Outputs (in OUT_DIR):
20
+ # transcript.srt timestamped segments (human-friendly)
21
+ # transcript.json/.txt/.vtt
22
+ # frames/scene_*.jpg frames at scene-change > SCENE_THRESHOLD
23
+ # frames/gap_*.jpg fill frames where scene-detect left a stretch > MAX_GAP unsampled
24
+ # frames/anchor_first.jpg / anchor_last.jpg always-captured first + last frame
25
+ # frames.json [{file, t, at, type}] — each frame's pts_time, sorted (Claude reads this)
26
+ # source-meta.txt ffprobe dump (audio.wav is created then deleted after transcription)
27
+ set -euo pipefail
28
+
29
+ VIDEO="${1:?Usage: transcribe.sh <video> [lang]}"
30
+ LANG_CODE="${2:-auto}" # whisper auto-detects the spoken language unless overridden
31
+ SCENE_THRESHOLD="${SCENE_THRESHOLD:-0.15}"
32
+ MAX_GAP="${MAX_GAP:-10}"
33
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+
35
+ # --- remote link? download with yt-dlp (local files skip this) ------------------
36
+ if [[ "$VIDEO" =~ ^https?:// ]]; then
37
+ command -v yt-dlp >/dev/null || { echo "remote link needs yt-dlp: brew install yt-dlp" >&2; exit 1; }
38
+ DL_DIR="${OUT_DIR:-$PWD}/_download"; mkdir -p "$DL_DIR"
39
+ echo "==> downloading remote video with yt-dlp"
40
+ yt-dlp -o "$DL_DIR/%(title)s.%(ext)s" "$VIDEO"
41
+ VIDEO="$(ls -t "$DL_DIR"/* | head -1)"
42
+ echo "==> downloaded: $VIDEO"
43
+ fi
44
+ [ -f "$VIDEO" ] || { echo "video not found: $VIDEO" >&2; exit 1; }
45
+
46
+ # --- model: prefer turbo, then large-v3, then medium (override with WHISPER_MODEL)
47
+ pick_model() {
48
+ if [ -n "${WHISPER_MODEL:-}" ]; then printf '%s' "$WHISPER_MODEL"; return; fi
49
+ local p
50
+ for p in \
51
+ "$HOME/.cache/whisper.cpp/models/ggml-large-v3-turbo.bin" \
52
+ "$HOME/.cache/openwhispr/whisper-models/ggml-large-v3-turbo.bin" \
53
+ "$HOME/.cache/whisper.cpp/models/ggml-large-v3.bin" \
54
+ "$HOME/.cache/whisper.cpp/models/ggml-medium.bin" \
55
+ "$HOME/.cache/openwhispr/whisper-models/ggml-medium.bin"; do
56
+ [ -f "$p" ] && { printf '%s' "$p"; return; }
57
+ done
58
+ printf ''
59
+ }
60
+ MODEL="$(pick_model)"
61
+ [ -n "$MODEL" ] && [ -f "$MODEL" ] || {
62
+ echo "no whisper model found. Install one, e.g.:" >&2
63
+ echo " curl -L -o ~/.cache/whisper.cpp/models/ggml-large-v3-turbo.bin \\" >&2
64
+ echo " https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin" >&2
65
+ echo " or set WHISPER_MODEL=/abs/path/ggml-*.bin" >&2
66
+ exit 1
67
+ }
68
+
69
+ WHISPER_BIN="$(command -v whisper-cli || command -v whisper-cpp || command -v main || true)"
70
+ [ -n "$WHISPER_BIN" ] || { echo "whisper.cpp binary not found (brew install whisper-cpp)" >&2; exit 1; }
71
+ command -v ffmpeg >/dev/null || { echo "ffmpeg not found (brew install ffmpeg)" >&2; exit 1; }
72
+
73
+ # --- where artifacts go: next to the video by default ---------------------------
74
+ SRC_DIR="$(cd "$(dirname "$VIDEO")" && pwd)"
75
+ SLUG="$(basename "$VIDEO")"; SLUG="${SLUG%.*}"
76
+ SLUG="$(echo "$SLUG" | tr ' ' '-' | tr -cd '[:alnum:]._-' | sed 's/--*/-/g; s/^-//; s/-$//')"
77
+ OUT="${OUT_DIR:-$SRC_DIR/$SLUG.media}"
78
+ mkdir -p "$OUT/frames"
79
+ # even-dim scaling keeps the mjpeg encoder happy on odd-width sources
80
+ SCALE="scale=trunc(iw/2)*2:trunc(ih/2)*2"
81
+
82
+ echo "==> [$SLUG] probing"
83
+ ffprobe -v error -show_entries format=duration,size:stream=codec_type,codec_name,width,height \
84
+ -of default=noprint_wrappers=1 "$VIDEO" | tee "$OUT/source-meta.txt"
85
+ DURATION="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VIDEO" 2>/dev/null | head -1)"
86
+ DURATION="${DURATION:-0}"
87
+
88
+ echo "==> [$SLUG] extracting 16kHz mono audio"
89
+ ffmpeg -y -loglevel error -i "$VIDEO" -ar 16000 -ac 1 -c:a pcm_s16le "$OUT/audio.wav"
90
+
91
+ echo "==> [$SLUG] transcribing with $(basename "$MODEL") (lang=$LANG_CODE)"
92
+ "$WHISPER_BIN" -m "$MODEL" -f "$OUT/audio.wav" -l "$LANG_CODE" \
93
+ --output-txt --output-srt --output-vtt --output-json -of "$OUT/transcript" -pp
94
+ # audio.wav is a pure whisper intermediate (~1.9MB/min) — drop it once the transcript exists.
95
+ rm -f "$OUT/audio.wav"
96
+
97
+ echo "==> [$SLUG] extracting scene-change frames (scene > $SCENE_THRESHOLD)"
98
+ # pix_fmt yuvj420p avoids mjpeg "non full-range YUV" failures on screen recordings;
99
+ # even-dim scaling keeps the encoder happy on odd-width sources.
100
+ # metadata=print dumps each kept frame's pts_time so frames map back to the timeline.
101
+ ffmpeg -y -loglevel error -i "$VIDEO" \
102
+ -vf "select='gt(scene,$SCENE_THRESHOLD)',metadata=print:file=$OUT/frames/scene_times.txt,scale=trunc(iw/2)*2:trunc(ih/2)*2" \
103
+ -fps_mode vfr -pix_fmt yuvj420p -q:v 3 "$OUT/frames/scene_%04d.jpg" || true
104
+
105
+ echo "==> [$SLUG] capturing first + last anchor frames"
106
+ # Short, cut-heavy creatives carry the most information in the opening hook and the
107
+ # closing CTA — but scene-detect rarely fires at t=0 or t=end. Always grab both.
108
+ ffmpeg -y -loglevel error -i "$VIDEO" -vf "$SCALE" -frames:v 1 -q:v 3 \
109
+ "$OUT/frames/anchor_first.jpg" || true
110
+ ffmpeg -y -loglevel error -sseof -0.5 -i "$VIDEO" -vf "$SCALE" -update 1 -frames:v 1 -q:v 3 \
111
+ "$OUT/frames/anchor_last.jpg" || true
112
+
113
+ echo "==> [$SLUG] filling gaps > ${MAX_GAP}s (static sections scene-detect misses)"
114
+ # scene-detect fires on motion, not meaning; a static-but-information-rich stretch
115
+ # (app demo, slide, talking head) gets no frame. gap_fill.py returns timestamps that
116
+ # keep every unsampled stretch <= MAX_GAP; we grab each and log its known pts_time.
117
+ : > "$OUT/frames/gap_times.txt"
118
+ gi=0
119
+ while IFS= read -r t; do
120
+ [ -z "$t" ] && continue
121
+ printf -v fn "gap_%04d.jpg" "$((gi + 1))"
122
+ if ffmpeg -y -loglevel error -ss "$t" -i "$VIDEO" -vf "$SCALE" -frames:v 1 -q:v 3 "$OUT/frames/$fn"; then
123
+ echo "frame:$gi pts_time:$t" >> "$OUT/frames/gap_times.txt"
124
+ gi=$((gi + 1))
125
+ fi
126
+ done < <(python3 "$SCRIPT_DIR/gap_fill.py" "$OUT" "$DURATION" "$MAX_GAP")
127
+ echo " gap frames added: $gi"
128
+
129
+ echo "==> [$SLUG] building frames.json (timestamp index, dedup near-dups)"
130
+ python3 "$SCRIPT_DIR/build_frame_index.py" "$OUT" "$DURATION"
131
+
132
+ NFRAMES="$(ls -1 "$OUT/frames"/*.jpg 2>/dev/null | wc -l | tr -d ' ')"
133
+ echo "==> [$SLUG] DONE"
134
+ echo " transcript: $OUT/transcript.srt"
135
+ echo " frames: $NFRAMES (index: $OUT/frames.json)"
136
+ echo " next: Claude reads transcript + frames.json, views key frames, writes the curated transcript.md"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamcontext",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "dreamcontext — the persistent brain for your AI agents. Remembers what you built, knows how your project works.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "agents",
14
14
  "README.md",
15
15
  "LICENSE",
16
+ "NOTICE",
16
17
  "install.sh"
17
18
  ],
18
19
  "scripts": {
@@ -23,6 +24,7 @@
23
24
  "dev:dashboard": "cd dashboard && npm run dev",
24
25
  "test": "vitest",
25
26
  "test:e2e": "playwright test",
27
+ "diagrams": "node scripts/diagrams/build-all.mjs",
26
28
  "prepublishOnly": "npm run build"
27
29
  },
28
30
  "keywords": [
@@ -36,7 +38,7 @@
36
38
  "gemini",
37
39
  "llm"
38
40
  ],
39
- "license": "MIT",
41
+ "license": "Apache-2.0",
40
42
  "engines": {
41
43
  "node": ">=18"
42
44
  },
package/skill/SKILL.md CHANGED
@@ -318,7 +318,9 @@ Sleep debt reminders are injected on every user message (via UserPromptSubmit ho
318
318
  - user hint mentions knowledge or a feature
319
319
  - When unsure, **over-fire** `sleep-product` — it no-ops cheaply.
320
320
  - Pass each specialist a small text brief in its prompt: epoch, session IDs, active task slugs, planning version, signals relevant to that specialist, optional user hint. Do **not** include transcript content — specialists call `dreamcontext transcript distill <id>` themselves.
321
+ - **Consolidation discipline (remind both specialists in the brief):** prefer *updating/extending* an existing entity over creating a new one. `sleep-tasks` folds a smaller slice into the task that already covers it (broaden its title + insert sub-items) rather than forking a duplicate or a needless sub-task; `sleep-product` keeps similar verticals/brands/topics in the fewest knowledge files, splitting only on a sharp topical boundary that sharpens tags. Duplicate tasks and fragmented near-duplicate knowledge are the top consolidation failure modes — but genuinely separate concerns/topics still get their own task/file.
321
322
  5. Wait for all specialist reports. Each returns a short structured report.
323
+ 5a. Run `dreamcontext reflect` — each candidate is a term seen across multiple sessions not yet in soul/user/memory/knowledge; promote into `2.memory.md` or a knowledge file ONLY if genuinely load-bearing; most are noise, discard; NEVER auto-promote.
322
324
  6. Marketing pass if `_dream_context/marketing/` exists: `dreamcontext mk rem-sleep`.
323
325
  7. Council promote check: `dreamcontext council list --unpromoted` — promote if user engaged positively.
324
326
  8. `dreamcontext sleep done "<one-paragraph summary stitched from specialist reports>"` — clears pre-epoch state, resets debt.
@@ -505,6 +507,7 @@ All commands prefixed with `dreamcontext`. For reading/searching, use native too
505
507
  | `trigger add "<when>" "<remind>" [-m max_fires] [-s source]` | Create contextual trigger |
506
508
  | `trigger list` | Show active triggers |
507
509
  | `trigger remove <id>` | Remove a trigger |
510
+ | `reflect [--min-sessions N] [--max N] [--write]` | Detect recurring cross-session terms not yet in knowledge/memory as CANDIDATES; `--write` persists to `state/.reflection.md` |
508
511
  | `transcript distill <session_id>` | Extract high-signal content from session transcript |
509
512
  | `sleep status` | Show sleep state and history |
510
513
  | `sleep add <score> "<desc>"` | Manual debt add |
@@ -523,6 +526,8 @@ All commands prefixed with `dreamcontext`. For reading/searching, use native too
523
526
  | `snapshot --tokens` | Estimate snapshot token count |
524
527
  | `doctor` | Validate `_dream_context/` structure |
525
528
  | `install-skill` | Install skill + hooks |
529
+ | `config show` | Print project config (platforms, packs, native-memory state) |
530
+ | `config native-memory <enable\|disable>` | Toggle Claude's native auto-memory; disabled by default so dreamcontext owns project memory |
526
531
  | `upgrade [--check]` | Update the dreamcontext CLI itself to the latest npm release |
527
532
 
528
533
  Feature insert sections: `changelog`, `notes`, `technical_details`, `constraints`, `user_stories`, `acceptance_criteria`, `why`
@@ -352,6 +352,50 @@
352
352
  "security"
353
353
  ],
354
354
  "alwaysApply": false
355
+ },
356
+ {
357
+ "name": "excalidraw",
358
+ "file": "excalidraw/SKILL.md",
359
+ "description": "Generate or extend Obsidian Excalidraw (.excalidraw.md) boards — lay out images/screenshots, text, shapes, arrows, frames, lanes and grids — by writing a small JSON spec and running a deterministic script that emits valid plugin markup (near-zero tokens, always renders). Embeds local images via the plugin's sha1 wikilink trick (no base64). Triggers: 'draw this in excalidraw', 'make an excalidraw board', 'excalidraw diagram', 'funnel map board', 'embed screenshots into excalidraw', 'excalidraw çiz', 'excalidraw board oluştur'.",
360
+ "tags": [
361
+ "excalidraw",
362
+ "obsidian",
363
+ "diagram",
364
+ "board",
365
+ "visualization",
366
+ "screenshots",
367
+ "funnel-map",
368
+ "drawing"
369
+ ],
370
+ "alwaysApply": false,
371
+ "bundleDir": true
372
+ },
373
+ {
374
+ "name": "video-watching",
375
+ "file": "video-watching/SKILL.md",
376
+ "description": "Load when the user hands over a video file/link or asks to watch/analyze a video, extract a transcript, or 'bu videoda ne var'. Turns a video into a time-mapped transcript with on-screen visuals (slides, UI, charts, text) described inline, then reasons about it. Triggers: 'watch this video', 'videoyu izle/analiz et', 'transkript çıkar', 'video transcript'.",
377
+ "tags": [
378
+ "video",
379
+ "transcript",
380
+ "whisper",
381
+ "ffmpeg",
382
+ "frames",
383
+ "ocr",
384
+ "marketing-teardown",
385
+ "media"
386
+ ],
387
+ "alwaysApply": false,
388
+ "bundleDir": true,
389
+ "requiresBinaries": [
390
+ "whisper-cpp",
391
+ "ffmpeg",
392
+ "yt-dlp (remote links only)"
393
+ ],
394
+ "crossPackDeps": [
395
+ "growth (marketing-video teardown handoff)",
396
+ "meta-marketing (ad-creative teardown handoff)",
397
+ "design/onboarding-design (app-demo / FTE reconstruction handoff)"
398
+ ]
355
399
  }
356
400
  ],
357
401
  "agents": [
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: excalidraw
3
+ description: >-
4
+ Generate or extend Obsidian Excalidraw (.excalidraw.md) boards in this vault — lay out
5
+ images/screenshots, text labels, shapes, arrows, frames, lanes and grids — by writing a small
6
+ JSON spec and letting a deterministic script emit valid plugin markup (so it costs ~no tokens and
7
+ always renders). Embeds local images via the plugin's sha1 wikilink trick (no base64). Triggers:
8
+ '/excalidraw', 'draw this in excalidraw', 'make an excalidraw board', 'excalidraw diagram',
9
+ 'funnel map board', 'embed screenshots into excalidraw', 'add images to an excalidraw file',
10
+ 'excalidraw çiz', 'excalidraw board oluştur', 'ekran görüntülerini excalidraw a ekle'.
11
+ ---
12
+
13
+ # Excalidraw board generator (Obsidian)
14
+
15
+ Write `.excalidraw.md` files that render natively in this vault's Obsidian Excalidraw plugin.
16
+ **Do not hand-author the scene JSON.** Build a small spec and run the generator — it produces the
17
+ frontmatter, `## Text Elements`, `## Embedded Files`, and the `%% ## Drawing … %%` JSON, with
18
+ correct sha1 image links, fractional z-indices, and deterministic seeds (clean git diffs).
19
+
20
+ ## The one trick that makes images work
21
+ An image element references `fileId` = **sha1 of the image file**. The `## Embedded Files` section
22
+ maps `<sha1>: [[image.png]]`. The plugin resolves the picture from the vault via that wikilink —
23
+ **no base64 needed.** The generator computes the sha1 and writes both sides for you.
24
+
25
+ ## Usage
26
+
27
+ ### CLI (most common)
28
+ ```bash
29
+ node .claude/skills/excalidraw/scripts/build_excalidraw.js <spec.json> [--out <path.excalidraw.md>]
30
+ ```
31
+ Write a spec JSON, then run it. The script prints `elements/images/texts` counts on success.
32
+
33
+ ### JS API (for pipelines that generate many boards)
34
+ ```js
35
+ const { buildExcalidraw, lane, grid } = require('.../scripts/build_excalidraw.js');
36
+ buildExcalidraw({ out, elements: [ ...lane({ title, images, x, y, thumbW }) ] });
37
+ ```
38
+
39
+ ## Spec schema
40
+ ```jsonc
41
+ {
42
+ "out": "/abs/path/Board.excalidraw.md", // required (or pass --out)
43
+ "vaultRoot": "/abs/vault", // optional; auto-detected by walking up to `.obsidian`
44
+ "attachDir": "Attachments", // external images get copied here (relative to board dir)
45
+ "wikilinkMode": "basename", // "basename" (default) or "path" (vault-relative)
46
+ "background": "#ffffff",
47
+ "elements": [ /* ElementSpec… */ ]
48
+ }
49
+ ```
50
+
51
+ ### Element types
52
+ - `text` — `{ x, y, text, fontSize?, color?, width?, align?, fontFamily? }` (fontFamily 1=hand, 2=normal, 3=code). **Set `width` for any caption/label that must stay inside a column or card** → the text WRAPS to that width (autoResize off) and its height is computed from the wrapped line count. Omit `width` only for short single-line text you want sized to content (it renders on one line and will overlap neighbours if long).
53
+ - `image` — `{ x, y, path, width? , height? }` — give ONE of width/height; the other is derived from aspect. `path` is an absolute file path.
54
+ - `rectangle`/`ellipse`/`diamond` — `{ x, y, width, height, strokeColor?, backgroundColor?, fillStyle?(solid|hachure|cross-hatch), strokeWidth?, roundness?(true=rounded) }`
55
+ - `line`/`arrow` — `{ points: [[x1,y1],[x2,y2],…], strokeColor?, strokeWidth?, endArrow?, startArrow? }` (coords are absolute; arrows default to an end arrowhead)
56
+ - `frame` — `{ x, y, width, height, name }`
57
+
58
+ ### Layout helpers (JS API) — dimension-aware, never gap/overlap
59
+ Both read each image's real pixel size at layout time, so every caption **hugs its own image's bottom**
60
+ (no fixed-aspect guess → no big gap under a short screenshot, no overlap under a tall one). They return
61
+ an `ElementSpec[]` you spread with `...`, and that array also carries `.height`, `.width`, and **`.nextY`**
62
+ so you can stack lanes/grids/sections without doing height math by hand.
63
+ - `lane({ title, images:[{path, caption}], x, y, thumbW, gap, captionSize, captionGap })` → titled horizontal
64
+ strip; caption wraps to `thumbW` under each thumb. Ideal for **funnel step maps** (one lane per funnel).
65
+ - `grid({ images:[{path, caption}], x, y, cols, thumbW, gapX, gapY, captionSize })` → wrapping grid; each
66
+ row sizes to its tallest image, each caption hugs its image.
67
+ - `thumbHeight(path, thumbW)` → the on-board height a thumbnail of width `thumbW` will get (exported for
68
+ custom layouts that need to place captions/rows themselves).
69
+ ```js
70
+ const a = lane({ title:'Pilates', images, x:60, y:120, thumbW:150 });
71
+ const b = lane({ title:'Fasting', images2, x:60, y:a.nextY, thumbW:150 }); // stacks under `a`, no overlap
72
+ buildExcalidraw({ out, elements:[ ...a, ...b ] });
73
+ ```
74
+ House-style `card()` labels are also vertically centered correctly even when the label **wraps** to the card width.
75
+
76
+ ## House style (`scripts/lib/style.js`) — learned from the vault's presentation boards
77
+ Match the team's look by default. The builder now defaults text to **Excalifont (fontFamily 5)**.
78
+ `style.js` exports the palette + ready-made builders so boards look hand-drawn and consistent:
79
+
80
+ - **`PALETTE`** (semantic, = Excalidraw's native swatches): `green` benefit/go · `red` pain/risk ·
81
+ `blue` system/flow · `purple` core service · `yellow` processing · `mint` result · `gray` muted.
82
+ Each is `{ fill, stroke }`. `INK` = `#1e1e1e` (the default 2px card outline). Fills are **solid**,
83
+ rects are **always rounded**.
84
+ - **`card({x,y,w,h,text,color,fontSize})`** → rounded filled rect + centered label, grouped (move as one).
85
+ - **`node(...)`** → smaller card for flow steps.
86
+ - **`sectionTitle({x,y,text,fontSize})`** → big plain header (no box), like "Configuration" / "Flow".
87
+ - **`connector({from,to,label,double,dashed})`** → labeled arrow between two points.
88
+ - **`annotate({from,to,text})`** → short arrow to a side-spec note (the "90sec / deepseek…" pattern).
89
+ - **`column({x,y,items,color})`** → vertical stack of cards (benefit/risk lists). `items` may be
90
+ strings or `{text,color}`.
91
+ - **`hub({cx,cy,label,spokes})`** → central node + satellites with arrows (the "Layer + APIs" pattern).
92
+ - **`bullets({x,y,items})`** → one left-aligned bulleted text block (their spec-list pattern).
93
+ - Edge helpers `center/rightOf/leftOf/topOf/bottomOf(x,y,w,h)` to wire connectors to card edges.
94
+
95
+ Compose these into a `buildExcalidraw({ out, elements })` call. Runnable example: `examples/style_board.js`.
96
+ Conventions: color nodes by ROLE, label every arrow, put specs in side-annotations, use big plain
97
+ section titles to break the board into "slides", keep one idea per card.
98
+
99
+ ## Rules & gotchas
100
+ - **Unique image filenames.** Obsidian resolves `[[name.png]]` by basename; if two different files
101
+ share a name anywhere in the vault the link is ambiguous. Name screenshots uniquely
102
+ (e.g. `bm-pilates-2707-step-03.png`) or use `"wikilinkMode": "path"`.
103
+ - Images must live inside the vault. External paths are auto-copied into `attachDir`.
104
+ - Output is uncompressed JSON inside `%% … %%`. The plugin reads it; on its first save it may
105
+ re-compress — that's expected and harmless.
106
+ - Re-running with the same `out` + same spec yields a byte-stable file (seeds are derived from the
107
+ output path), so boards diff cleanly in git.
108
+ - **Self-contained / portable.** No `npm install` — `build_excalidraw.js` uses only Node builtins +
109
+ the vendored `scripts/lib/*` (fractional-indexing, imagesize, style). Copy `.claude/skills/excalidraw/`
110
+ into any project and it works. `vaultRoot` auto-detects `.obsidian`; with none it falls back to the
111
+ board's own folder, so the `.excalidraw.md` is still written (it just renders inside Obsidian).
112
+
113
+ ## Verify
114
+ Run the bundled examples from the skill folder (they use skill-relative paths + a bundled `sample.png`,
115
+ so they work in any project):
116
+ ```bash
117
+ cd .claude/skills/excalidraw
118
+ node scripts/build_excalidraw.js examples/hello.spec.json # spec-driven: text/shapes/image → examples/Hello Excalidraw.excalidraw.md
119
+ node examples/style_board.js # JS API: card/connector/lane house style → examples/Style Demo.excalidraw.md
120
+ ```
121
+ Then open the resulting board in Obsidian (Excalidraw view) to confirm it renders.
122
+
123
+ See `reference/format.md` for the exact `.excalidraw.md` anatomy reverse-engineered from this vault.
@@ -0,0 +1,14 @@
1
+ {
2
+ "out": "examples/Hello Excalidraw.excalidraw.md",
3
+ "background": "#ffffff",
4
+ "elements": [
5
+ { "type": "text", "x": 0, "y": -70, "text": "excalidraw skill — portable smoke test", "fontSize": 28, "color": "#1971c2" },
6
+ { "type": "text", "x": 0, "y": -28, "text": "Give a text a width and it wraps to that width (no overlap). The image embeds via sha1 wikilink — no base64. Only skill-relative paths, so this runs in any project.", "fontSize": 15, "color": "#868e96", "width": 540 },
7
+ { "type": "rectangle", "x": 0, "y": 40, "width": 260, "height": 140, "strokeColor": "#1e1e1e", "backgroundColor": "#a5d8ff", "fillStyle": "solid", "strokeWidth": 2, "roundness": true },
8
+ { "type": "text", "x": 24, "y": 96, "text": "rounded rect\n+ fill", "fontSize": 20, "color": "#1e1e1e" },
9
+ { "type": "ellipse", "x": 340, "y": 40, "width": 160, "height": 140, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "fillStyle": "hachure" },
10
+ { "type": "arrow", "points": [[260, 110], [340, 110]], "strokeColor": "#e8590c", "strokeWidth": 3 },
11
+ { "type": "text", "x": 0, "y": 220, "text": "embedded image (resolved from vault by sha1 wikilink):", "fontSize": 16, "color": "#555", "width": 420 },
12
+ { "type": "image", "x": 0, "y": 250, "path": "examples/sample.png", "width": 320 }
13
+ ]
14
+ }
@@ -0,0 +1,31 @@
1
+ // style_board.js — house-style demo. Run from anywhere: node examples/style_board.js
2
+ // Shows card/connector/sectionTitle + the dimension-aware lane() (captions hug images, .nextY stacks).
3
+ const path = require('path');
4
+ const { buildExcalidraw, lane } = require(path.join(__dirname, '..', 'scripts', 'build_excalidraw.js'));
5
+ const { card, connector, sectionTitle, rightOf, leftOf, bottomOf, topOf } = require(path.join(__dirname, '..', 'scripts', 'lib', 'style.js'));
6
+
7
+ const SAMPLE = path.join(__dirname, 'sample.png');
8
+ const els = [];
9
+ const P = (...a) => els.push(...a.flat());
10
+
11
+ // --- flow row with labeled connectors (color nodes by role) ---
12
+ P(sectionTitle({ x: 0, y: 0, text: 'House style', fontSize: 40 }));
13
+ const A = { x: 0, y: 90, w: 220, h: 84 }, B = { x: 360, y: 90, w: 220, h: 84 }, C = { x: 720, y: 90, w: 220, h: 84 };
14
+ P(card({ ...A, text: 'input', color: 'blue' }));
15
+ P(card({ ...B, text: 'core service', color: 'purple' }));
16
+ P(card({ ...C, text: 'result', color: 'mint' }));
17
+ P(connector({ from: rightOf(A.x, A.y, A.w, A.h), to: leftOf(B.x, B.y, B.w, B.h), label: 'request' }));
18
+ P(connector({ from: rightOf(B.x, B.y, B.w, B.h), to: leftOf(C.x, C.y, C.w, C.h), label: 'response' }));
19
+
20
+ // --- a dimension-aware lane: captions hug each image, .nextY gives the next free row ---
21
+ const imgs = [
22
+ { path: SAMPLE, caption: 'lane() reads the real image size, so this caption hugs the bottom and wraps to the thumb width — no gap, no overlap.' },
23
+ { path: SAMPLE, caption: 'second thumb' },
24
+ { path: SAMPLE, caption: 'third thumb' },
25
+ ];
26
+ const strip = lane({ title: 'lane() — funnel-step style strip', images: imgs, x: 0, y: 220, thumbW: 200 });
27
+ P(strip);
28
+ P(sectionTitle({ x: 0, y: strip.nextY, text: 'stacked below via strip.nextY', fontSize: 22, color: '#868e96' }));
29
+
30
+ buildExcalidraw({ out: path.join(__dirname, 'Style Demo.excalidraw.md'), elements: els, background: '#fbfbfb' });
31
+ console.log('style demo →', path.join(__dirname, 'Style Demo.excalidraw.md'));
@@ -0,0 +1,5 @@
1
+ {
2
+ "type": "commonjs",
3
+ "private": true,
4
+ "description": "Scopes the excalidraw skill's vendored scripts to CommonJS so `require()` works even when the host project sets \"type\": \"module\". No dependencies — scripts use only Node builtins + vendored lib/."
5
+ }