Zayne Rea Sprague Claude Opus 4.6 commited on
Commit
e3f9f04
·
1 Parent(s): fa4af69

Add experiment notes feature — attach research documents to experiments

Browse files

New data type (experiment_notes) with CRUD endpoints, a Notes tab in
experiment detail view, and a full-page markdown reader. Import script
reads from note_sources directories configured in experiment.yaml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

backend/api/experiments.py CHANGED
@@ -15,7 +15,7 @@ _cache: dict[str, list[dict]] = {}
15
  _cache_loaded: set[str] = set()
16
  _lock = threading.Lock()
17
 
18
- FILES = ["experiments", "runs", "sub_experiments"]
19
 
20
 
21
  def _ensure_local_dir():
@@ -102,16 +102,19 @@ def list_experiments():
102
  experiments = _get("experiments")
103
  runs = _get("runs")
104
  subs = _get("sub_experiments")
 
105
 
106
  # Enrich with counts
107
  result = []
108
  for exp in experiments:
109
  exp_runs = [r for r in runs if r.get("experiment_id") == exp["id"]]
110
  exp_subs = [s for s in subs if s.get("experiment_id") == exp["id"]]
 
111
  result.append({
112
  **exp,
113
  "run_count": len(exp_runs),
114
  "sub_count": len(exp_subs),
 
115
  })
116
  return jsonify(result)
117
 
@@ -165,8 +168,9 @@ def get_experiment(exp_id):
165
 
166
  runs = [r for r in _get("runs") if r.get("experiment_id") == exp_id]
167
  subs = [s for s in _get("sub_experiments") if s.get("experiment_id") == exp_id]
 
168
 
169
- return jsonify({**exp, "runs": runs, "sub_experiments": subs})
170
 
171
 
172
  @bp.route("/<exp_id>", methods=["PUT"])
@@ -194,11 +198,13 @@ def delete_experiment(exp_id):
194
  experiments = [e for e in experiments if e["id"] != exp_id]
195
  _set("experiments", experiments)
196
 
197
- # Also delete associated runs and subs
198
  runs = [r for r in _get("runs") if r.get("experiment_id") != exp_id]
199
  _set("runs", runs)
200
  subs = [s for s in _get("sub_experiments") if s.get("experiment_id") != exp_id]
201
  _set("sub_experiments", subs)
 
 
202
 
203
  return jsonify({"status": "ok"})
204
 
@@ -328,6 +334,71 @@ def delete_sub(exp_id, sub_id):
328
  return jsonify({"status": "ok"})
329
 
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  # --- Sync & Import ---
332
 
333
  @bp.route("/sync", methods=["POST"])
 
15
  _cache_loaded: set[str] = set()
16
  _lock = threading.Lock()
17
 
18
+ FILES = ["experiments", "runs", "sub_experiments", "experiment_notes"]
19
 
20
 
21
  def _ensure_local_dir():
 
102
  experiments = _get("experiments")
103
  runs = _get("runs")
104
  subs = _get("sub_experiments")
105
+ notes = _get("experiment_notes")
106
 
107
  # Enrich with counts
108
  result = []
109
  for exp in experiments:
110
  exp_runs = [r for r in runs if r.get("experiment_id") == exp["id"]]
111
  exp_subs = [s for s in subs if s.get("experiment_id") == exp["id"]]
112
+ exp_notes = [n for n in notes if n.get("experiment_id") == exp["id"]]
113
  result.append({
114
  **exp,
115
  "run_count": len(exp_runs),
116
  "sub_count": len(exp_subs),
117
+ "note_count": len(exp_notes),
118
  })
119
  return jsonify(result)
120
 
 
168
 
169
  runs = [r for r in _get("runs") if r.get("experiment_id") == exp_id]
170
  subs = [s for s in _get("sub_experiments") if s.get("experiment_id") == exp_id]
171
+ notes = [n for n in _get("experiment_notes") if n.get("experiment_id") == exp_id]
172
 
173
+ return jsonify({**exp, "runs": runs, "sub_experiments": subs, "experiment_notes": notes})
174
 
175
 
176
  @bp.route("/<exp_id>", methods=["PUT"])
 
198
  experiments = [e for e in experiments if e["id"] != exp_id]
199
  _set("experiments", experiments)
200
 
201
+ # Also delete associated runs, subs, and notes
202
  runs = [r for r in _get("runs") if r.get("experiment_id") != exp_id]
203
  _set("runs", runs)
204
  subs = [s for s in _get("sub_experiments") if s.get("experiment_id") != exp_id]
205
  _set("sub_experiments", subs)
206
+ notes = [n for n in _get("experiment_notes") if n.get("experiment_id") != exp_id]
207
+ _set("experiment_notes", notes)
208
 
209
  return jsonify({"status": "ok"})
210
 
 
334
  return jsonify({"status": "ok"})
335
 
336
 
337
+ # --- Experiment Notes ---
338
+
339
+ @bp.route("/<exp_id>/notes", methods=["POST"])
340
+ def create_note(exp_id):
341
+ experiments = _get("experiments")
342
+ if not any(e["id"] == exp_id for e in experiments):
343
+ return jsonify({"error": "experiment not found"}), 404
344
+
345
+ data = request.get_json()
346
+ title = data.get("title", "").strip()
347
+ if not title:
348
+ return jsonify({"error": "title is required"}), 400
349
+
350
+ note_id = data.get("id", f"{exp_id}__note_{uuid.uuid4().hex[:8]}")
351
+
352
+ note = {
353
+ "id": note_id,
354
+ "experiment_id": exp_id,
355
+ "title": title,
356
+ "filename": data.get("filename", ""),
357
+ "content_md": data.get("content_md", ""),
358
+ "created": _now(),
359
+ "updated": _now(),
360
+ }
361
+
362
+ notes = _get("experiment_notes")
363
+ notes.append(note)
364
+ _set("experiment_notes", notes)
365
+ return jsonify(note), 201
366
+
367
+
368
+ @bp.route("/<exp_id>/notes/<note_id>", methods=["GET"])
369
+ def get_note(exp_id, note_id):
370
+ notes = _get("experiment_notes")
371
+ note = next((n for n in notes if n["id"] == note_id and n["experiment_id"] == exp_id), None)
372
+ if not note:
373
+ return jsonify({"error": "not found"}), 404
374
+ return jsonify(note)
375
+
376
+
377
+ @bp.route("/<exp_id>/notes/<note_id>", methods=["PUT"])
378
+ def update_note(exp_id, note_id):
379
+ data = request.get_json()
380
+ notes = _get("experiment_notes")
381
+
382
+ for note in notes:
383
+ if note["id"] == note_id and note["experiment_id"] == exp_id:
384
+ for key in ["title", "content_md"]:
385
+ if key in data:
386
+ note[key] = data[key]
387
+ note["updated"] = _now()
388
+ _set("experiment_notes", notes)
389
+ return jsonify(note)
390
+
391
+ return jsonify({"error": "not found"}), 404
392
+
393
+
394
+ @bp.route("/<exp_id>/notes/<note_id>", methods=["DELETE"])
395
+ def delete_note(exp_id, note_id):
396
+ notes = _get("experiment_notes")
397
+ notes = [n for n in notes if not (n["id"] == note_id and n["experiment_id"] == exp_id)]
398
+ _set("experiment_notes", notes)
399
+ return jsonify({"status": "ok"})
400
+
401
+
402
  # --- Sync & Import ---
403
 
404
  @bp.route("/sync", methods=["POST"])
frontend/src/experiments/ExperimentsApp.tsx CHANGED
@@ -2,6 +2,7 @@ import { useExperimentsState } from "./store";
2
  import ExperimentList from "./components/ExperimentList";
3
  import ExperimentDetail from "./components/ExperimentDetail";
4
  import SubExperimentView from "./components/SubExperimentView";
 
5
 
6
  export default function ExperimentsApp() {
7
  const state = useExperimentsState();
@@ -28,6 +29,16 @@ export default function ExperimentsApp() {
28
  );
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
31
  if (state.view.kind === "sub" && state.currentSub && state.currentDetail) {
32
  return (
33
  <SubExperimentView
@@ -45,6 +56,7 @@ export default function ExperimentsApp() {
45
  experiment={state.currentDetail}
46
  onBack={state.navigateToList}
47
  onSelectSub={(subId) => state.navigateToSub(state.view.kind === "detail" ? state.view.expId : "", subId)}
 
48
  onRefresh={state.refreshDetail}
49
  />
50
  );
 
2
  import ExperimentList from "./components/ExperimentList";
3
  import ExperimentDetail from "./components/ExperimentDetail";
4
  import SubExperimentView from "./components/SubExperimentView";
5
+ import NoteView from "./components/NoteView";
6
 
7
  export default function ExperimentsApp() {
8
  const state = useExperimentsState();
 
29
  );
30
  }
31
 
32
+ if (state.view.kind === "note" && state.currentNote && state.currentDetail) {
33
+ return (
34
+ <NoteView
35
+ note={state.currentNote}
36
+ experimentName={state.currentDetail.name}
37
+ onBack={() => state.navigateToDetail(state.view.kind === "note" ? state.view.expId : "")}
38
+ />
39
+ );
40
+ }
41
+
42
  if (state.view.kind === "sub" && state.currentSub && state.currentDetail) {
43
  return (
44
  <SubExperimentView
 
56
  experiment={state.currentDetail}
57
  onBack={state.navigateToList}
58
  onSelectSub={(subId) => state.navigateToSub(state.view.kind === "detail" ? state.view.expId : "", subId)}
59
+ onSelectNote={(noteId) => state.navigateToNote(state.view.kind === "detail" ? state.view.expId : "", noteId)}
60
  onRefresh={state.refreshDetail}
61
  />
62
  );
frontend/src/experiments/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Experiment, ExperimentDetail, RunRecord, SubExperiment } from "./types";
2
 
3
  const BASE = "/api/experiments";
4
 
@@ -77,6 +77,25 @@ export const experimentsApi = {
77
  return fetchJSON<{ status: string }>(`${BASE}/${expId}/subs/${subId}`, { method: "DELETE" });
78
  },
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  sync() {
81
  return fetchJSON<{ status: string }>(`${BASE}/sync`, { method: "POST" });
82
  },
 
1
+ import type { Experiment, ExperimentDetail, RunRecord, SubExperiment, ExperimentNote } from "./types";
2
 
3
  const BASE = "/api/experiments";
4
 
 
77
  return fetchJSON<{ status: string }>(`${BASE}/${expId}/subs/${subId}`, { method: "DELETE" });
78
  },
79
 
80
+ // Notes
81
+ createNote(expId: string, data: Partial<ExperimentNote>) {
82
+ return fetchJSON<ExperimentNote>(`${BASE}/${expId}/notes`, {
83
+ method: "POST",
84
+ body: JSON.stringify(data),
85
+ });
86
+ },
87
+
88
+ updateNote(expId: string, noteId: string, data: Partial<ExperimentNote>) {
89
+ return fetchJSON<ExperimentNote>(`${BASE}/${expId}/notes/${noteId}`, {
90
+ method: "PUT",
91
+ body: JSON.stringify(data),
92
+ });
93
+ },
94
+
95
+ deleteNote(expId: string, noteId: string) {
96
+ return fetchJSON<{ status: string }>(`${BASE}/${expId}/notes/${noteId}`, { method: "DELETE" });
97
+ },
98
+
99
  sync() {
100
  return fetchJSON<{ status: string }>(`${BASE}/sync`, { method: "POST" });
101
  },
frontend/src/experiments/components/ExperimentDetail.tsx CHANGED
@@ -18,16 +18,17 @@ const RUN_STATUS_COLORS: Record<string, string> = {
18
  failed: "text-red-400",
19
  };
20
 
21
- type Tab = "overview" | "runs" | "datasets" | "subs";
22
 
23
  interface Props {
24
  experiment: ExperimentDetailType;
25
  onBack: () => void;
26
  onSelectSub: (subId: string) => void;
 
27
  onRefresh: () => void;
28
  }
29
 
30
- export default function ExperimentDetail({ experiment, onBack, onSelectSub, onRefresh }: Props) {
31
  const [tab, setTab] = useState<Tab>("overview");
32
  const [editing, setEditing] = useState(false);
33
  const [notes, setNotes] = useState(experiment.notes || "");
@@ -95,6 +96,7 @@ export default function ExperimentDetail({ experiment, onBack, onSelectSub, onRe
95
  { id: "runs", label: "Runs", count: experiment.runs?.length || 0 },
96
  { id: "datasets", label: "Datasets", count: experiment.hf_repos?.length || 0 },
97
  { id: "subs", label: "Sub-experiments", count: experiment.sub_experiments?.length || 0 },
 
98
  ];
99
 
100
  return (
@@ -479,6 +481,40 @@ export default function ExperimentDetail({ experiment, onBack, onSelectSub, onRe
479
  )}
480
  </div>
481
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  </div>
483
  </div>
484
  );
 
18
  failed: "text-red-400",
19
  };
20
 
21
+ type Tab = "overview" | "runs" | "datasets" | "subs" | "notes";
22
 
23
  interface Props {
24
  experiment: ExperimentDetailType;
25
  onBack: () => void;
26
  onSelectSub: (subId: string) => void;
27
+ onSelectNote: (noteId: string) => void;
28
  onRefresh: () => void;
29
  }
30
 
31
+ export default function ExperimentDetail({ experiment, onBack, onSelectSub, onSelectNote, onRefresh }: Props) {
32
  const [tab, setTab] = useState<Tab>("overview");
33
  const [editing, setEditing] = useState(false);
34
  const [notes, setNotes] = useState(experiment.notes || "");
 
96
  { id: "runs", label: "Runs", count: experiment.runs?.length || 0 },
97
  { id: "datasets", label: "Datasets", count: experiment.hf_repos?.length || 0 },
98
  { id: "subs", label: "Sub-experiments", count: experiment.sub_experiments?.length || 0 },
99
+ { id: "notes", label: "Notes", count: experiment.experiment_notes?.length || 0 },
100
  ];
101
 
102
  return (
 
481
  )}
482
  </div>
483
  )}
484
+
485
+ {tab === "notes" && (
486
+ <div>
487
+ <div className="flex justify-between items-center mb-4">
488
+ <h2 className="text-sm font-medium text-gray-300">Research Notes</h2>
489
+ </div>
490
+
491
+ {(experiment.experiment_notes || []).length === 0 ? (
492
+ <p className="text-sm text-gray-500">No research notes attached.</p>
493
+ ) : (
494
+ <div className="grid gap-2">
495
+ {experiment.experiment_notes.map((note) => (
496
+ <button
497
+ key={note.id}
498
+ onClick={() => onSelectNote(note.id)}
499
+ className="w-full text-left bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded p-3 transition-colors"
500
+ >
501
+ <div className="flex items-center justify-between">
502
+ <div>
503
+ <span className="text-sm text-gray-200">{note.title}</span>
504
+ {note.filename && (
505
+ <p className="text-xs text-gray-500 mt-0.5 font-mono">{note.filename}</p>
506
+ )}
507
+ </div>
508
+ <span className="text-xs text-gray-600">
509
+ {note.updated ? new Date(note.updated).toLocaleDateString() : ""}
510
+ </span>
511
+ </div>
512
+ </button>
513
+ ))}
514
+ </div>
515
+ )}
516
+ </div>
517
+ )}
518
  </div>
519
  </div>
520
  );
frontend/src/experiments/components/NoteView.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ExperimentNote } from "../types";
2
+ import Markdown from "./Markdown";
3
+
4
+ interface Props {
5
+ note: ExperimentNote;
6
+ experimentName: string;
7
+ onBack: () => void;
8
+ }
9
+
10
+ export default function NoteView({ note, experimentName, onBack }: Props) {
11
+ return (
12
+ <div className="h-full flex flex-col">
13
+ {/* Breadcrumb + header */}
14
+ <div className="px-6 py-4 border-b border-gray-800">
15
+ <div className="flex items-center gap-2 text-sm mb-3">
16
+ <button onClick={onBack} className="text-gray-400 hover:text-gray-200 transition-colors">
17
+ &larr; {experimentName}
18
+ </button>
19
+ <span className="text-gray-600">/</span>
20
+ <span className="text-gray-300">{note.title}</span>
21
+ </div>
22
+ <h1 className="text-lg font-semibold text-gray-200">{note.title}</h1>
23
+ {note.filename && (
24
+ <p className="text-xs text-gray-500 mt-1 font-mono">{note.filename}</p>
25
+ )}
26
+ </div>
27
+
28
+ {/* Content */}
29
+ <div className="flex-1 overflow-y-auto p-6">
30
+ <div className="max-w-4xl">
31
+ <div className="bg-gray-900 rounded p-6">
32
+ {note.content_md ? (
33
+ <Markdown content={note.content_md} />
34
+ ) : (
35
+ <span className="text-sm text-gray-600 italic">No content.</span>
36
+ )}
37
+ </div>
38
+
39
+ <div className="mt-6 flex gap-4 text-xs text-gray-600">
40
+ {note.created && <span>Created: {new Date(note.created).toLocaleDateString()}</span>}
41
+ {note.updated && <span>Updated: {new Date(note.updated).toLocaleDateString()}</span>}
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
frontend/src/experiments/store.ts CHANGED
@@ -1,16 +1,18 @@
1
  import { useState, useCallback, useEffect } from "react";
2
- import type { Experiment, ExperimentDetail, SubExperiment } from "./types";
3
  import { experimentsApi } from "./api";
4
 
5
  export type View =
6
  | { kind: "list" }
7
  | { kind: "detail"; expId: string }
8
- | { kind: "sub"; expId: string; subId: string };
 
9
 
10
  export function useExperimentsState() {
11
  const [experiments, setExperiments] = useState<Experiment[]>([]);
12
  const [currentDetail, setCurrentDetail] = useState<ExperimentDetail | null>(null);
13
  const [currentSub, setCurrentSub] = useState<SubExperiment | null>(null);
 
14
  const [view, setView] = useState<View>({ kind: "list" });
15
  const [loading, setLoading] = useState(false);
16
  const [error, setError] = useState<string | null>(null);
@@ -36,6 +38,7 @@ export function useExperimentsState() {
36
  setView({ kind: "list" });
37
  setCurrentDetail(null);
38
  setCurrentSub(null);
 
39
  loadExperiments();
40
  }, [loadExperiments]);
41
 
@@ -62,8 +65,17 @@ export function useExperimentsState() {
62
  }
63
  }, [currentDetail]);
64
 
 
 
 
 
 
 
 
 
 
65
  const refreshDetail = useCallback(async () => {
66
- if (view.kind === "detail" || view.kind === "sub") {
67
  const expId = view.expId;
68
  try {
69
  const detail = await experimentsApi.get(expId);
@@ -78,6 +90,7 @@ export function useExperimentsState() {
78
  experiments,
79
  currentDetail,
80
  currentSub,
 
81
  view,
82
  loading,
83
  error,
@@ -85,6 +98,7 @@ export function useExperimentsState() {
85
  navigateToList,
86
  navigateToDetail,
87
  navigateToSub,
 
88
  refreshDetail,
89
  loadExperiments,
90
  };
 
1
  import { useState, useCallback, useEffect } from "react";
2
+ import type { Experiment, ExperimentDetail, SubExperiment, ExperimentNote } from "./types";
3
  import { experimentsApi } from "./api";
4
 
5
  export type View =
6
  | { kind: "list" }
7
  | { kind: "detail"; expId: string }
8
+ | { kind: "sub"; expId: string; subId: string }
9
+ | { kind: "note"; expId: string; noteId: string };
10
 
11
  export function useExperimentsState() {
12
  const [experiments, setExperiments] = useState<Experiment[]>([]);
13
  const [currentDetail, setCurrentDetail] = useState<ExperimentDetail | null>(null);
14
  const [currentSub, setCurrentSub] = useState<SubExperiment | null>(null);
15
+ const [currentNote, setCurrentNote] = useState<ExperimentNote | null>(null);
16
  const [view, setView] = useState<View>({ kind: "list" });
17
  const [loading, setLoading] = useState(false);
18
  const [error, setError] = useState<string | null>(null);
 
38
  setView({ kind: "list" });
39
  setCurrentDetail(null);
40
  setCurrentSub(null);
41
+ setCurrentNote(null);
42
  loadExperiments();
43
  }, [loadExperiments]);
44
 
 
65
  }
66
  }, [currentDetail]);
67
 
68
+ const navigateToNote = useCallback((expId: string, noteId: string) => {
69
+ if (!currentDetail) return;
70
+ const note = (currentDetail.experiment_notes || []).find(n => n.id === noteId);
71
+ if (note) {
72
+ setCurrentNote(note);
73
+ setView({ kind: "note", expId, noteId });
74
+ }
75
+ }, [currentDetail]);
76
+
77
  const refreshDetail = useCallback(async () => {
78
+ if (view.kind === "detail" || view.kind === "sub" || view.kind === "note") {
79
  const expId = view.expId;
80
  try {
81
  const detail = await experimentsApi.get(expId);
 
90
  experiments,
91
  currentDetail,
92
  currentSub,
93
+ currentNote,
94
  view,
95
  loading,
96
  error,
 
98
  navigateToList,
99
  navigateToDetail,
100
  navigateToSub,
101
+ navigateToNote,
102
  refreshDetail,
103
  loadExperiments,
104
  };
frontend/src/experiments/types.ts CHANGED
@@ -30,6 +30,7 @@ export interface Experiment {
30
  updated: string;
31
  run_count?: number;
32
  sub_count?: number;
 
33
  }
34
 
35
  export interface RunRecord {
@@ -57,7 +58,18 @@ export interface SubExperiment {
57
  updated: string;
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
60
  export interface ExperimentDetail extends Experiment {
61
  runs: RunRecord[];
62
  sub_experiments: SubExperiment[];
 
63
  }
 
30
  updated: string;
31
  run_count?: number;
32
  sub_count?: number;
33
+ note_count?: number;
34
  }
35
 
36
  export interface RunRecord {
 
58
  updated: string;
59
  }
60
 
61
+ export interface ExperimentNote {
62
+ id: string;
63
+ experiment_id: string;
64
+ title: string;
65
+ filename: string;
66
+ content_md: string;
67
+ created: string;
68
+ updated: string;
69
+ }
70
+
71
  export interface ExperimentDetail extends Experiment {
72
  runs: RunRecord[];
73
  sub_experiments: SubExperiment[];
74
+ experiment_notes: ExperimentNote[];
75
  }
scripts/import_experiments.py CHANGED
@@ -200,13 +200,40 @@ def load_experiment(exp_dir: Path) -> tuple[dict, list[dict], list[dict]]:
200
  }
201
  sub_experiments.append(sub)
202
 
203
- return experiment, runs, sub_experiments
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
 
206
  def main():
207
  all_experiments = []
208
  all_runs = []
209
  all_subs = []
 
210
 
211
  for exp_dir in sorted(EXPERIMENTS_DIR.iterdir()):
212
  if not exp_dir.is_dir():
@@ -215,13 +242,14 @@ def main():
215
  continue
216
 
217
  print(f"Loading: {exp_dir.name}")
218
- exp, runs, subs = load_experiment(exp_dir)
219
  all_experiments.append(exp)
220
  all_runs.extend(runs)
221
  all_subs.extend(subs)
222
- print(f" -> {len(runs)} runs, {len(subs)} sub-experiments, {len(exp.get('hf_repos', []))} HF repos")
 
223
 
224
- print(f"\nTotal: {len(all_experiments)} experiments, {len(all_runs)} runs, {len(all_subs)} sub-experiments")
225
 
226
  # Upload to HF
227
  api = HfApi()
@@ -230,7 +258,7 @@ def main():
230
  except Exception:
231
  pass
232
 
233
- for name, data in [("experiments", all_experiments), ("runs", all_runs), ("sub_experiments", all_subs)]:
234
  with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
235
  json.dump(data, f, indent=2, default=str)
236
  tmp = f.name
 
200
  }
201
  sub_experiments.append(sub)
202
 
203
+ # Experiment notes from note_sources
204
+ experiment_notes = []
205
+ for source_dir in config.get("note_sources", []):
206
+ source_path = Path(source_dir).expanduser()
207
+ if not source_path.exists():
208
+ continue
209
+ for md_file in sorted(source_path.glob("*.md")):
210
+ # Skip claude.md instruction files
211
+ if md_file.name.lower() == "claude.md":
212
+ continue
213
+ note_title = md_file.stem.replace("_", " ").replace("-", " ").title()
214
+ with open(md_file) as f:
215
+ note_content = f.read()
216
+
217
+ note_id = f"{name}__note_{md_file.stem}"
218
+ note = {
219
+ "id": note_id,
220
+ "experiment_id": name,
221
+ "title": note_title,
222
+ "filename": md_file.name,
223
+ "content_md": note_content,
224
+ "created": config.get("created", ""),
225
+ "updated": config.get("updated", ""),
226
+ }
227
+ experiment_notes.append(note)
228
+
229
+ return experiment, runs, sub_experiments, experiment_notes
230
 
231
 
232
  def main():
233
  all_experiments = []
234
  all_runs = []
235
  all_subs = []
236
+ all_notes = []
237
 
238
  for exp_dir in sorted(EXPERIMENTS_DIR.iterdir()):
239
  if not exp_dir.is_dir():
 
242
  continue
243
 
244
  print(f"Loading: {exp_dir.name}")
245
+ exp, runs, subs, notes = load_experiment(exp_dir)
246
  all_experiments.append(exp)
247
  all_runs.extend(runs)
248
  all_subs.extend(subs)
249
+ all_notes.extend(notes)
250
+ print(f" -> {len(runs)} runs, {len(subs)} sub-experiments, {len(notes)} notes, {len(exp.get('hf_repos', []))} HF repos")
251
 
252
+ print(f"\nTotal: {len(all_experiments)} experiments, {len(all_runs)} runs, {len(all_subs)} sub-experiments, {len(all_notes)} notes")
253
 
254
  # Upload to HF
255
  api = HfApi()
 
258
  except Exception:
259
  pass
260
 
261
+ for name, data in [("experiments", all_experiments), ("runs", all_runs), ("sub_experiments", all_subs), ("experiment_notes", all_notes)]:
262
  with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
263
  json.dump(data, f, indent=2, default=str)
264
  tmp = f.name