File size: 8,593 Bytes
5f43c7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761261e
 
 
5f43c7d
 
 
 
 
 
 
 
 
 
 
 
761261e
5f43c7d
761261e
5f43c7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
import { useEffect, useState } from "react";
import {
  setApiMode, apiMode,
  callOverview, callAdvice, callChat, callProjectChat, callProjectNarrative,
} from "./gradioApi.js";
import { withClient } from "./client.js";

// Loader behind the contract seam. The UI consumes ONLY {session, turns, events,
// findings} (the normalized engine output) plus an OPTIONAL narrated overlay.
// No JSONL is ever touched here; this is the same shape an hf-loader would emit.
//
// Two sources, same shape:
//   sourcePath == null  -> /fixture-analysis.json  (the bundled POC, static)
//   sourcePath == <abs> -> /api/analyze?path=...    (any real ~/.claude session,
//                          analyzed on demand by the LOCAL server/app.py)
//
// 100% local: static assets from /public, or the localhost API. No egress.
export function useAnalysis(sourcePath) {
  const [state, setState] = useState({ status: "loading", data: null, narrated: null, error: null });

  useEffect(() => {
    let alive = true;
    setState((s) => ({ ...s, status: "loading" }));
    (async () => {
      try {
        const url = sourcePath
          ? `/api/analyze?path=${encodeURIComponent(sourcePath)}`
          : "fixture-analysis.json";
        const res = await fetch(url, { cache: "no-store", headers: withClient() });
        if (!res.ok) throw new Error(`engine output HTTP ${res.status}`);
        const data = await res.json();
        if (data.error) throw new Error(data.error);

        // The narrated overlay is best-effort and exists only for the bundled
        // fixture. A missing/malformed file must NOT break the core view.
        let narrated = null;
        if (!sourcePath) {
          try {
            const nr = await fetch("fixture-analysis.narrated.json", { cache: "no-store" });
            if (nr.ok) {
              const txt = await nr.text();
              if (txt.trim().startsWith("{")) narrated = JSON.parse(txt);
            }
          } catch {
            narrated = null;
          }
        }

        if (alive) setState({ status: "ready", data, narrated, error: null });
      } catch (e) {
        if (!alive) return;
        // No bundled fixture (trace content is never shipped) -> first-run welcome,
        // not an error. A real failure on a chosen session is a genuine error.
        if (!sourcePath) setState({ status: "welcome", data: null, narrated: null, error: null });
        else setState({ status: "error", data: null, narrated: null, error: String(e) });
      }
    })();
    return () => {
      alive = false;
    };
  }, [sourcePath]);

  return state;
}

// Plain-English "what happened" for a session (narrator prose). On the Space this is
// a GPU call routed through @gradio/client; locally it's the REST /api/overview route.
// Returns { overview, model }.
export async function fetchOverview(sourcePath) {
  if (apiMode() === "space") {
    try { return await callOverview(sourcePath); } catch { return { overview: "", model: null }; }
  }
  const r = await fetch(`/api/overview?path=${encodeURIComponent(sourcePath || "")}`, { cache: "no-store" });
  return r.json();
}

// Session-scoped "what could have been better" — the engine DETECTS the fixable
// signals; the model WRITES the advice, scoped to this session's objective + the
// cited Anthropic best practice. Returns { recommendations:[{...,scoped}], model }.
// `scoped` is null when the model is offline (caller falls back to the engine's
// transcribed fix text). Deterministic detection is never model-dependent. On the
// Space this is a GPU call via @gradio/client; locally it's REST /api/advice.
export async function fetchAdvice(sourcePath) {
  if (apiMode() === "space") {
    try { return await callAdvice(sourcePath); } catch { return { recommendations: [], model: null }; }
  }
  const r = await fetch(`/api/advice?path=${encodeURIComponent(sourcePath || "")}`, { cache: "no-store" });
  if (!r.ok) throw new Error(`advice HTTP ${r.status}`);
  return r.json();
}

// Is the local engine API up? (Enables the session browser + chat.) Returns
// { hasApi, llama, space } once known; null while probing. `space` is this HF
// Space's "owner/name" (empty locally) — used to build a self-referencing
// uploader-download command in the help modal.
export function useApi() {
  const [api, setApi] = useState(null);
  useEffect(() => {
    let alive = true;
    (async () => {
      try {
        const r = await fetch("/api/health", { cache: "no-store" });
        const j = r.ok ? await r.json() : {};
        // The ZeroGPU Space marks itself with gpu:true so narration calls route
        // through @gradio/client (auth headers forward for GPU quota); the local
        // `./her` server omits it and narration stays on plain REST.
        setApiMode(j.gpu ? "space" : "local");
        if (alive) setApi({ hasApi: !!j.ok, llama: !!j.llama, space: j.space || "" });
      } catch {
        if (alive) setApi({ hasApi: false, llama: false, space: "" });
      }
    })();
    return () => { alive = false; };
  }, []);
  return api;
}

export async function fetchSessions() {
  const r = await fetch("/api/sessions", { cache: "no-store", headers: withClient() });
  if (!r.ok) throw new Error(`sessions HTTP ${r.status}`);
  return r.json();
}

// Ask a question grounded ONLY in the current session's trace. The server does
// deterministic retrieval, the model writes the prose. Returns
// { answer, focusTurn, citedTurns, model }. GPU call via @gradio/client on the
// Space; REST /api/chat locally.
export async function askTrace(question, sourcePath) {
  if (apiMode() === "space") {
    const j = await callChat(question, sourcePath);
    if (j && j.error) throw new Error(j.error);
    return j;
  }
  const r = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ question, path: sourcePath || "" }),
  });
  const j = await r.json();
  if (!r.ok || j.error) throw new Error(j.error || `chat HTTP ${r.status}`);
  return j;
}

// Cross-session project chat ("which session did X happen in?"). GPU call via
// @gradio/client on the Space; REST /api/project_chat locally. Returns
// { answer, sessionHits, model }.
export async function fetchProjectChat(question, cwd) {
  if (apiMode() === "space") {
    const j = await callProjectChat(question, cwd);
    if (j && j.error) throw new Error(j.error);
    return j;
  }
  const r = await fetch("/api/project_chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ question, cwd }),
  });
  const j = await r.json();
  if (!r.ok || j.error) throw new Error(j.error || `HTTP ${r.status}`);
  return j;
}

// Project changelog prose. On the Space /api/project is deterministic-only, so the
// narrative is fetched separately here (GPU via @gradio/client). Locally the
// narrative already arrives inside /api/project, so this is a no-op. Returns
// { narrative, model }.
export async function fetchProjectNarrative(cwd) {
  if (apiMode() === "space") return callProjectNarrative(cwd);
  return { narrative: "", model: null };
}

// Pull narrator prose for a turn out of the overlay, if any. Returns null when
// absent so callers can fall back to the deterministic narrative.
// Real narrated schema: turns[].why_tokens (+ optional .guidance.text).
export function turnProse(narrated, i) {
  if (!narrated) return null;
  const arr = narrated.turns || narrated.turnProse || [];
  const hit = Array.isArray(arr) ? arr.find((x) => x && x.i === i) : null;
  if (!hit) return null;
  return hit.why_tokens || hit.text || hit.prose || hit.body || null;
}

// Narrator-proposed guidance prose for a turn (label generated; suggests, never
// asserts). Falls back to the engine Guide body when no narrated guidance.
export function turnGuideProse(narrated, i) {
  if (!narrated) return null;
  const arr = narrated.turns || [];
  const hit = Array.isArray(arr) ? arr.find((x) => x && x.i === i) : null;
  return hit && hit.guidance ? hit.guidance.text || hit.guidance.body || null : null;
}

// Session-level outcome prose. Real schema: session_outcome (string).
export function sessionProse(narrated) {
  if (!narrated) return null;
  return narrated.session_outcome || narrated.session || narrated.summary || narrated.outcome || null;
}

// Session-level "next time" tips (list of strings) proposed by the narrator.
export function nextTimeTips(narrated) {
  if (!narrated) return null;
  const nt = narrated.next_time || narrated.nextTime;
  return Array.isArray(nt) && nt.length ? nt : null;
}