@oh-my-pi/omp-stats 18.6.3 → 18.8.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 (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/client/index.css +1 -1
  3. package/dist/client/index.js +96 -96
  4. package/dist/types/aggregator.d.ts +3 -0
  5. package/dist/types/client/api.d.ts +5 -1
  6. package/dist/types/client/data/live.d.ts +16 -11
  7. package/dist/types/client/data/query-store.d.ts +39 -0
  8. package/dist/types/client/data/query.d.ts +10 -5
  9. package/dist/types/client/traces/Minimap.d.ts +2 -0
  10. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  11. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  12. package/dist/types/db.d.ts +14 -2
  13. package/dist/types/embedded-client.d.ts +5 -0
  14. package/dist/types/live.d.ts +5 -3
  15. package/dist/types/rollup.d.ts +9 -1
  16. package/dist/types/trace.d.ts +18 -6
  17. package/dist/types/types.d.ts +8 -0
  18. package/package.json +4 -4
  19. package/src/aggregator.ts +26 -9
  20. package/src/client/api.ts +40 -5
  21. package/src/client/app/LiveChip.tsx +2 -2
  22. package/src/client/app/Shell.tsx +2 -2
  23. package/src/client/data/live.tsx +85 -45
  24. package/src/client/data/query-store.ts +108 -0
  25. package/src/client/data/query.ts +47 -56
  26. package/src/client/routes/CostsRoute.tsx +4 -2
  27. package/src/client/routes/ErrorsRoute.tsx +3 -1
  28. package/src/client/routes/FrustrationRoute.tsx +1 -1
  29. package/src/client/routes/GainRoute.tsx +3 -1
  30. package/src/client/routes/ModelsRoute.tsx +9 -2
  31. package/src/client/routes/OverviewRoute.tsx +2 -2
  32. package/src/client/routes/ProjectsRoute.tsx +1 -1
  33. package/src/client/routes/ProvidersRoute.tsx +5 -3
  34. package/src/client/routes/RequestsRoute.tsx +1 -1
  35. package/src/client/routes/ToolsRoute.tsx +1 -1
  36. package/src/client/routes/TracesRoute.tsx +5 -4
  37. package/src/client/styles.css +4 -2
  38. package/src/client/traces/Minimap.tsx +63 -26
  39. package/src/client/traces/TimelineCanvas.tsx +224 -105
  40. package/src/client/traces/TraceView.tsx +29 -10
  41. package/src/client/traces/TranscriptList.tsx +17 -9
  42. package/src/client/traces/traces.css +26 -0
  43. package/src/client/ui/RequestDrawer.tsx +3 -1
  44. package/src/db.ts +123 -69
  45. package/src/embedded-client.ts +9 -0
  46. package/src/live.ts +36 -11
  47. package/src/parser.ts +18 -17
  48. package/src/rollup.ts +41 -20
  49. package/src/server.ts +38 -24
  50. package/src/trace.ts +54 -40
  51. package/src/types.ts +8 -0
@@ -13,11 +13,14 @@ export declare function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T
13
13
  * `current` is the number of files completed (skipped + parsed),
14
14
  * `total` is the size of the work set. `processed` is the running total
15
15
  * of inserted rows. Parsed files are reported only after their batch commits.
16
+ * `changes` is the running total of stored rows the sync inserted, updated or
17
+ * deleted (0 while every transcript is unchanged).
16
18
  */
17
19
  export interface SyncProgress {
18
20
  current: number;
19
21
  total: number;
20
22
  processed: number;
23
+ changes: number;
21
24
  sessionFile: string;
22
25
  }
23
26
  export interface SyncOptions {
@@ -25,7 +25,11 @@ export declare function getProviderDashboardStats(range?: TimeRange, signal?: Ab
25
25
  /** Subscription-window insights (all providers) plus utilization series for `provider` only. */
26
26
  export declare function getProviderWindowStats(range: TimeRange, provider: string | null, signal?: AbortSignal): Promise<ProviderWindowStats>;
27
27
  export declare function getSessions(limit?: number, q?: string, signal?: AbortSignal): Promise<SessionSummary[]>;
28
- export declare function getSessionTrace(file: string, signal?: AbortSignal): Promise<SessionTrace>;
28
+ /**
29
+ * Pass the last trace for `file` as `previous` to revalidate it: an unchanged
30
+ * trace resolves to that same object without downloading or parsing the body.
31
+ */
32
+ export declare function getSessionTrace(file: string, signal?: AbortSignal, previous?: SessionTrace): Promise<SessionTrace>;
29
33
  /** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
30
34
  export declare function getSessionEntryDetail(file: string, id: string, signal?: AbortSignal): Promise<{
31
35
  entry: unknown;
@@ -3,18 +3,14 @@
3
3
  * version every query revalidates against, background ingest progress, and
4
4
  * the rollup indexing backlog.
5
5
  *
6
- * Any component can `useLive()`; queries (see `useQuery`) refetch whenever
7
- * `version` advances, so rows ingested in the background repaint the active
8
- * page without a manual refresh.
6
+ * The state lives in a module store read through narrow selectors, so a
7
+ * progress tick only re-renders what shows progress: `useLiveVersion()` for
8
+ * queries (see `useQuery`), `useLiveStatus()` for the chrome.
9
9
  */
10
10
  import { type ReactNode } from "react";
11
11
  import type { LiveSyncStatus } from "../types.js";
12
- export interface LiveState {
13
- /**
14
- * Client-side data version; bumps whenever the server's version changes
15
- * (including after reconnecting to a restarted server).
16
- */
17
- version: number;
12
+ /** Ingest status shown by the chrome. */
13
+ export interface LiveStatusView {
18
14
  sync: LiveSyncStatus;
19
15
  /** Hours whose rollups are still being built; wide ranges may be incomplete. */
20
16
  indexingHours: number;
@@ -23,7 +19,16 @@ export interface LiveState {
23
19
  /** Ask the server to ingest new session data now. */
24
20
  requestSync: () => void;
25
21
  }
22
+ /** Current data version, read outside render (e.g. when a fetch starts). */
23
+ export declare function currentLiveVersion(): number;
24
+ /** Owns the `/api/events` stream; mount once at the app root. */
26
25
  export declare function LiveProvider({ children }: {
27
26
  children: ReactNode;
28
- }): import("react").JSX.Element;
29
- export declare function useLive(): LiveState;
27
+ }): ReactNode;
28
+ /** Ingest status for the chrome; data-version bumps alone don't re-render. */
29
+ export declare function useLiveStatus(): LiveStatusView;
30
+ /**
31
+ * Data version for queries. Disabled (hidden) consumers read a constant -1 so
32
+ * live traffic never re-renders them.
33
+ */
34
+ export declare function useLiveVersion(enabled?: boolean): number;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Framework-free query cache behind `useQuery`: per-key cached results in
3
+ * size-bounded buckets, and one shared in-flight request per key that is
4
+ * aborted once its last waiter leaves.
5
+ */
6
+ export interface CacheEntry {
7
+ data: unknown;
8
+ updatedAt: number;
9
+ version: number;
10
+ }
11
+ export interface Inflight {
12
+ promise: Promise<unknown>;
13
+ controller: AbortController;
14
+ /** Queries (and prefetches) waiting on this request. */
15
+ waiters: number;
16
+ }
17
+ /** Handed to every fetcher. */
18
+ export interface QueryFetchContext {
19
+ /** Aborted once no query waits for this request any more. */
20
+ signal: AbortSignal;
21
+ }
22
+ /**
23
+ * Loads one key's data. `previous` is the cached data for this key, for
24
+ * conditional revalidation; `undefined` on first load. It is a separate
25
+ * parameter so fetchers that ignore it still let `T` be inferred from their
26
+ * return type.
27
+ */
28
+ export type QueryFetcher<T> = (context: QueryFetchContext, previous: T | undefined) => Promise<T>;
29
+ export declare function cachedEntry(key: string): CacheEntry | undefined;
30
+ /** The shared request currently running for `key`, if any. */
31
+ export declare function inflightRequest(key: string): Inflight | undefined;
32
+ /**
33
+ * Fetch into the cache (tagged with live data `version`), sharing one request
34
+ * among concurrent callers of the same key. The caller holds one waiter on
35
+ * the returned request; pair with {@link releaseQuery}.
36
+ */
37
+ export declare function loadQuery<T>(key: string, version: number, fetcher: QueryFetcher<T>): Inflight;
38
+ /** Drop one waiter; the last one out aborts the request. */
39
+ export declare function releaseQuery(key: string, request: Inflight): void;
@@ -2,14 +2,19 @@
2
2
  * `useQuery`: cached, live-revalidating data fetching for dashboard pages.
3
3
  *
4
4
  * - Results are cached per key for the page session, so revisiting a page or
5
- * a range renders instantly from memory.
5
+ * a range renders instantly from memory. Multi-MB detail payloads (traces,
6
+ * request details) live in their own small LRUs so they don't pile up.
6
7
  * - A key change keeps the previous key's data on screen (`stale: true`) until
7
8
  * the new data lands, so charts morph instead of flashing skeletons.
8
9
  * - Every cached entry remembers the live data version it was fetched at
9
- * (see `useLive`); when the version advances, enabled queries refetch in the
10
- * background, throttled so a burst of ingest batches costs one request.
11
- * - Identical keys share one in-flight request.
10
+ * (see `useLiveVersion`); when the version advances, enabled queries refetch
11
+ * in the background, throttled so a burst of ingest batches costs one request.
12
+ * - Identical keys share one in-flight request; it is aborted once no query
13
+ * waits for it any more.
14
+ * - Fetchers receive the cached data for their key, so conditional requests
15
+ * can resolve to that same object; an unchanged reference does not re-render.
12
16
  */
17
+ import { type QueryFetcher } from "./query-store.js";
13
18
  export interface QueryOptions {
14
19
  /** Only enabled queries fetch; hidden (kept-alive) pages pass `false`. Default true. */
15
20
  enabled?: boolean;
@@ -32,4 +37,4 @@ export interface QueryResult<T> {
32
37
  }
33
38
  /** Warm the cache for a key the user is likely to open next. No-op if cached for this version. */
34
39
  export declare function prefetchQuery<T>(key: readonly unknown[], fetcher: () => Promise<T>, version: number): void;
35
- export declare function useQuery<T>(key: readonly unknown[], fetcher: () => Promise<T>, options?: QueryOptions): QueryResult<T>;
40
+ export declare function useQuery<T>(key: readonly unknown[], fetcher: QueryFetcher<T>, options?: QueryOptions): QueryResult<T>;
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Timeline overview strip: per-category density over the full virtual domain
3
3
  * plus a draggable/resizable viewport brush, mirroring DevTools' overview.
4
+ * The density layer is painted once per trace/scale/size/theme into an
5
+ * offscreen canvas; pan and zoom only blit it and redraw the brush.
4
6
  */
5
7
  import type { TraceTrack } from "../types.js";
6
8
  import type { TimelineViewport } from "./TimelineCanvas.js";
@@ -1,10 +1,11 @@
1
1
  /**
2
- * The trace flamegraph: one DPR-scaled canvas with a DOM gutter (track/lane
3
- * labels, collapse chevrons) and a DOM tooltip. Implements the Chrome
4
- * DevTools interaction contract: cursor-anchored wheel zoom, drag pan, WASD,
5
- * fit/focus keys, hover tooltips, click-to-select, double-click-to-zoom.
2
+ * The trace flamegraph: a DPR-scaled scene canvas with DOM hover/selection
3
+ * boxes on top, a DOM gutter (track/lane labels, collapse chevrons) and a
4
+ * DOM tooltip. Implements the Chrome DevTools interaction contract:
5
+ * cursor-anchored wheel zoom, drag pan, WASD, fit/focus keys, hover tooltips,
6
+ * click-to-select, double-click-to-zoom.
6
7
  */
7
- import type { TraceTrack } from "../types.js";
8
+ import type { TraceSpan, TraceTrack } from "../types.js";
8
9
  import { type TraceScale } from "./time-scale.js";
9
10
  export interface TimelineViewport {
10
11
  u0: number;
@@ -15,11 +16,31 @@ export interface TimelineCanvasProps {
15
16
  scale: TraceScale;
16
17
  viewport: TimelineViewport;
17
18
  onViewportChange: (viewport: TimelineViewport) => void;
18
- selection: string | null;
19
+ /** The selected span with its track (resolved by the trace view). */
20
+ selected: {
21
+ span: TraceSpan;
22
+ track: TraceTrack;
23
+ } | null;
19
24
  onSelect: (spanId: string | null) => void;
20
- search: string;
25
+ /** Spans matching the search; `null` when not searching (everything at full opacity). */
26
+ matchIds: ReadonlySet<string> | null;
21
27
  collapsed: ReadonlySet<string>;
22
28
  onToggleCollapse: (trackId: string) => void;
23
29
  traceStart: number;
24
30
  }
25
- export declare function TimelineCanvas({ tracks, scale, viewport, onViewportChange, selection, onSelect, search, collapsed, onToggleCollapse, traceStart, }: TimelineCanvasProps): import("react").JSX.Element;
31
+ /** Per-lane span positions on the virtual axis, parallel to `LaneRow.spans`. */
32
+ interface LaneAxis {
33
+ startU: Float64Array;
34
+ endU: Float64Array;
35
+ /** Running max of `endU`; monotonic, so the first visible span is a binary search away. */
36
+ maxEndU: Float64Array;
37
+ }
38
+ export declare function buildLaneAxes(layout: {
39
+ lanes: ReadonlyArray<{
40
+ spans: ReadonlyArray<Pick<TraceSpan, "start" | "end">>;
41
+ }>;
42
+ }, scale: Pick<TraceScale, "toU">): LaneAxis[];
43
+ /** First index whose running-max end reaches `u0`: every earlier span ends before the viewport. */
44
+ export declare function firstVisible(maxEndU: Float64Array, u0: number): number;
45
+ export declare function TimelineCanvas({ tracks, scale, viewport, onViewportChange, selected, onSelect, matchIds, collapsed, onToggleCollapse, traceStart, }: TimelineCanvasProps): import("react").JSX.Element;
46
+ export {};
@@ -17,8 +17,10 @@ export interface TranscriptListProps {
17
17
  selection: string | null;
18
18
  onSelect: (spanId: string) => void;
19
19
  search: string;
20
+ /** Spans matching `search` (from the trace view); `null` when not searching. */
21
+ matchIds: ReadonlySet<string> | null;
20
22
  traceStart: number;
21
23
  }
22
24
  /** Flatten all spans and markers across tracks into chronological rows. */
23
25
  export declare function buildTranscriptRows(tracks: TraceTrack[]): TranscriptRow[];
24
- export declare function TranscriptList({ tracks, selection, onSelect, search, traceStart }: TranscriptListProps): import("react").JSX.Element;
26
+ export declare const TranscriptList: import("react").MemoExoticComponent<({ tracks, selection, onSelect, search, matchIds, traceStart, }: TranscriptListProps) => import("react").JSX.Element>;
@@ -49,12 +49,23 @@ export interface ParsedSession {
49
49
  /** Recover missing rows from a full-history scan without rewriting unchanged records. */
50
50
  replay: boolean;
51
51
  }
52
- /** Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch. */
52
+ /**
53
+ * Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch.
54
+ * `changes` counts stored rows inserted, updated or deleted (cursor bookkeeping excluded), so callers can
55
+ * tell a batch that changed nothing.
56
+ */
53
57
  export declare function applySessionParseResults(sessions: ParsedSession[]): {
54
58
  processed: number;
55
59
  files: number;
56
60
  reconcile: boolean;
61
+ changes: number;
57
62
  };
63
+ /**
64
+ * SQLite `PRAGMA data_version` of the open connection, or null before
65
+ * {@link initDb}. It moves only when another connection (another omp process)
66
+ * commits, so a caller can detect foreign writes without scanning.
67
+ */
68
+ export declare function getDataVersion(): number | null;
58
69
  export declare function prepareSessionSync(): boolean;
59
70
  export declare function completeSessionSync(reconcile: boolean): void;
60
71
  /**
@@ -90,7 +101,8 @@ export declare function getMessageById(id: number): MessageStats | null;
90
101
  * Self-initializing (opens the stats DB on first use) so the coding-agent TUI
91
102
  * can query without the dashboard server's init flow. Days use the machine's
92
103
  * timezone — this is a localhost tool, same rationale as
93
- * {@link getProviderHourlyBurn}.
104
+ * {@link getProviderHourlyBurn}. Reads the hourly rollup, falling back to the
105
+ * raw rows while its rebuild backlog is too large for exact reads.
94
106
  */
95
107
  export declare function getDailyActivity(days?: number): Promise<DailyActivityPoint[]>;
96
108
  /**
@@ -7,6 +7,11 @@
7
7
  * prepacked npm bundle, and reset to an empty file afterwards so the dev tree
8
8
  * keeps building the dashboard from source.
9
9
  */
10
+ /**
11
+ * Cheap check whether the generated text carries a gzip archive: sniffs the
12
+ * base64 of the gzip magic (`1f 8b 08`) without decoding megabytes.
13
+ */
14
+ export declare function hasEmbeddedClientArchive(txt: string): boolean;
10
15
  /**
11
16
  * Decode the generated archive text.
12
17
  *
@@ -5,11 +5,13 @@
5
5
  * The dashboard never waits for ingest. {@link StatsLive.start} kicks a full
6
6
  * sync in the background and watches the sessions directory; changed
7
7
  * transcripts are re-synced (only those files) shortly after they are written.
8
- * Every committed batch bumps {@link LiveStatus.version} (throttled), so open
9
- * pages refetch and fill in while parsing is still running.
8
+ * Every batch that changes stored rows bumps {@link LiveStatus.version}
9
+ * (throttled), so open pages refetch and fill in while parsing is still
10
+ * running; syncs that change nothing leave the version alone.
10
11
  */
11
12
  import type { LiveStatus } from "./shared-types.js";
12
- type Listener = (status: LiveStatus) => void;
13
+ /** Receives each distinct status and its JSON serialization (shared by every listener). */
14
+ type Listener = (status: LiveStatus, frame: string) => void;
13
15
  /** One per process; owned by the dashboard server (see `startServer`). */
14
16
  export declare class StatsLive {
15
17
  #private;
@@ -25,7 +25,7 @@
25
25
  * (an hour of data is small and indexed by timestamp).
26
26
  */
27
27
  import type { Database } from "bun:sqlite";
28
- import type { AgentTypeStats, AggregatedStats, CostTimeSeriesPoint, FolderStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, ProviderAggregate, ProviderHourlyPoint, ProviderTimeSeriesPoint, TimeSeriesPoint, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats } from "./types.js";
28
+ import type { AgentTypeStats, AggregatedStats, CostTimeSeriesPoint, DailyActivityPoint, FolderStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, ProviderAggregate, ProviderHourlyPoint, ProviderTimeSeriesPoint, TimeSeriesPoint, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats } from "./types.js";
29
29
  /**
30
30
  * Create (or rebuild after a version bump) the rollup tables and the triggers
31
31
  * that mark hours dirty. Called from `initDb` after the raw tables exist.
@@ -87,6 +87,14 @@ export declare function getStatsByProvider(cutoff?: number | null): ProviderAggr
87
87
  * up to 30 minutes.
88
88
  */
89
89
  export declare function getProviderHourlyBurn(cutoff?: number | null): ProviderHourlyPoint[];
90
+ /**
91
+ * Per-local-day request/token/cost totals since `cutoff`, oldest first, read
92
+ * from the hourly rollup. Returns null when the local UTC offset is not a whole
93
+ * hour (now or at `cutoff`), since hourly buckets then straddle local midnight,
94
+ * or while the dirty backlog is too large for exact reads; callers then
95
+ * aggregate the raw rows instead.
96
+ */
97
+ export declare function getDailyActivityFromRollup(cutoff: number): DailyActivityPoint[] | null;
90
98
  /** Tokens/cost per bucket per provider. */
91
99
  export declare function getProviderTimeSeries({ cutoff, bucketMs }: RangeWindow): ProviderTimeSeriesPoint[];
92
100
  /** Per-transcript-file rollup for the Traces session list. */
@@ -18,18 +18,30 @@ export declare class TracePathError extends Error {
18
18
  * against an unchanged transcript mtime.
19
19
  */
20
20
  export declare const TRACE_ETAG_VERSION = 3;
21
+ /** Freshness of a root trace: root mtime plus the child-transcript set fingerprint. */
22
+ export interface TraceFingerprint {
23
+ rootMtimeMs: number;
24
+ childFingerprint: string;
25
+ }
21
26
  /**
22
- * Freshness fingerprint for the trace ETag pre-check: root mtime plus the
23
- * child-transcript set fingerprint (readdir+stat walk, no parse). Returns
24
- * undefined when the path is rejected or missing (the caller falls through
25
- * to the full build, which maps those to 400/404).
27
+ * Freshness fingerprint for the trace ETag pre-check; `SessionTrace.etag` is
28
+ * `${rootMtimeMs}:${childFingerprint}` for an unraced build. Returns undefined
29
+ * when the path is rejected or missing (the caller falls through to the full
30
+ * build, which maps those to 400/404).
26
31
  */
27
- export declare function traceFingerprintForEtag(fileParam: string): Promise<string | undefined>;
32
+ export declare function traceFingerprintForEtag(fileParam: string): Promise<TraceFingerprint | undefined>;
28
33
  export declare function traceMemoForTests(): {
29
34
  file: string;
30
35
  mtimeMs: number;
31
36
  } | undefined;
32
- export declare function buildSessionTrace(fileParam: string): Promise<SessionTrace>;
37
+ /**
38
+ * Assemble the full span tree for one root session transcript.
39
+ * Throws {@link TracePathError} for paths outside the sessions root; ENOENT
40
+ * passes through for the caller's 404 mapping. `fingerprint` may carry the
41
+ * {@link traceFingerprintForEtag} value the caller just computed for the same
42
+ * file (its ETag pre-check), sparing a second walk.
43
+ */
44
+ export declare function buildSessionTrace(fileParam: string, fingerprint?: TraceFingerprint): Promise<SessionTrace>;
33
45
  /** Fetch one full journal entry by id from a transcript, for the span drawer. */
34
46
  export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
35
47
  /**
@@ -33,6 +33,14 @@ export interface MessageStats {
33
33
  usage: Usage;
34
34
  /** Which agent produced this message (main agent, task subagent, advisor) */
35
35
  agentType: AgentType;
36
+ /**
37
+ * Service tier the provider reported serving this turn, or `null` when the
38
+ * provider reported none. Lets consumers separate a fast serving path's
39
+ * throughput and premium traffic from standard-tier traffic. Always written by
40
+ * `rowToMessageStats`; optional only because session fixtures that stand in
41
+ * for ingest input are typed as `MessageStats` too.
42
+ */
43
+ serviceTier?: ServiceTier | null;
36
44
  /**
37
45
  * Ingest refused to price this request: a scheduled (time-based) card with no
38
46
  * recoverable request timestamp, so `usage.cost.total` of 0 is unknown spend
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "18.6.3",
4
+ "version": "18.8.0",
5
5
  "description": "Local observability dashboard for omp AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -42,9 +42,9 @@
42
42
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
43
43
  },
44
44
  "dependencies": {
45
- "@oh-my-pi/pi-ai": "18.6.3",
46
- "@oh-my-pi/pi-catalog": "18.6.3",
47
- "@oh-my-pi/pi-utils": "18.6.3",
45
+ "@oh-my-pi/pi-ai": "18.8.0",
46
+ "@oh-my-pi/pi-catalog": "18.8.0",
47
+ "@oh-my-pi/pi-utils": "18.8.0",
48
48
  "lucide-react": "^1.24.0",
49
49
  "react": "19.2.7",
50
50
  "react-dom": "19.2.7"
package/src/aggregator.ts CHANGED
@@ -85,11 +85,14 @@ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>)
85
85
  * `current` is the number of files completed (skipped + parsed),
86
86
  * `total` is the size of the work set. `processed` is the running total
87
87
  * of inserted rows. Parsed files are reported only after their batch commits.
88
+ * `changes` is the running total of stored rows the sync inserted, updated or
89
+ * deleted (0 while every transcript is unchanged).
88
90
  */
89
91
  export interface SyncProgress {
90
92
  current: number;
91
93
  total: number;
92
94
  processed: number;
95
+ changes: number;
93
96
  sessionFile: string;
94
97
  }
95
98
 
@@ -297,6 +300,7 @@ async function syncAllSessionsLocked(
297
300
 
298
301
  const files = opts?.files ?? (await orderForIngest(await listAllSessionFiles()));
299
302
  let totalProcessed = 0;
303
+ let totalChanges = 0;
300
304
  let filesProcessed = 0;
301
305
  let completed = 0;
302
306
  let cursor = 0;
@@ -314,6 +318,7 @@ async function syncAllSessionsLocked(
314
318
  current: completed,
315
319
  total: files.length,
316
320
  processed: totalProcessed,
321
+ changes: totalChanges,
317
322
  sessionFile,
318
323
  });
319
324
  };
@@ -325,6 +330,7 @@ async function syncAllSessionsLocked(
325
330
  pending = [];
326
331
  pendingRows = 0;
327
332
  totalProcessed += applied.processed;
333
+ totalChanges += applied.changes;
328
334
  filesProcessed += applied.files;
329
335
  reconcile ||= applied.reconcile;
330
336
  // Report only durable progress: callbacks may interrupt the sync.
@@ -358,9 +364,9 @@ async function syncAllSessionsLocked(
358
364
  metadataEnd = index + batch.length;
359
365
  metadata = Promise.all(
360
366
  batch.map(async sessionFile => {
367
+ const stored = offsets.get(sessionFile);
361
368
  try {
362
- const fileStats = await fs.promises.stat(sessionFile);
363
- return { fileStats, stored: offsets.get(sessionFile) };
369
+ return { fileStats: await fs.promises.stat(sessionFile), stored };
364
370
  } catch {
365
371
  return {};
366
372
  }
@@ -415,7 +421,13 @@ async function syncAllSessionsLocked(
415
421
  if (pending.length >= SYNC_BATCH_FILES || pendingRows >= SYNC_BATCH_ROWS) flush();
416
422
  };
417
423
 
418
- const requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
424
+ let requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
425
+ // A few already-tracked transcripts (the watcher's usual work) need only
426
+ // kilobyte tail reads: parsing them inline beats starting a worker.
427
+ if (requestedWorkers > 1 && opts?.workers === undefined && files.length <= SYNC_INLINE_READS) {
428
+ const offsets = getFileOffsets([...files]);
429
+ if (files.every(file => offsets.get(file)?.parserState)) requestedWorkers = 1;
430
+ }
419
431
  if (requestedWorkers === 1) {
420
432
  for (let start = 0; start < files.length; start += SYNC_INLINE_READS) {
421
433
  const results = await Promise.allSettled(
@@ -437,14 +449,20 @@ async function syncAllSessionsLocked(
437
449
 
438
450
  const handles: WorkerHandle[] = [];
439
451
 
440
- async function drain(handle: WorkerHandle): Promise<void> {
452
+ // Each lane spawns its worker on its first real parse, so unchanged transcripts cost no worker.
453
+ async function drain(): Promise<void> {
454
+ let handle: WorkerHandle | null = null;
441
455
  try {
442
456
  while (!failed) {
443
457
  const idx = cursor++;
444
458
  if (idx >= files.length) return;
445
- const parsed = await prepareFile(idx, (file, fromOffset, parserState, replay) =>
446
- dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
447
- );
459
+ const parsed = await prepareFile(idx, (file, fromOffset, parserState, replay) => {
460
+ if (!handle) {
461
+ handle = spawnWorker();
462
+ handles.push(handle);
463
+ }
464
+ return dispatch(handle, { sessionFile: file, fromOffset, parserState, replay });
465
+ });
448
466
  if (!failed) acceptFile(files[idx], parsed);
449
467
  }
450
468
  } catch (error) {
@@ -454,9 +472,8 @@ async function syncAllSessionsLocked(
454
472
  }
455
473
 
456
474
  try {
457
- for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
458
475
  // Drain in-flight work before releasing the sync lock, even after a failed batch.
459
- const results = await Promise.allSettled(handles.map(drain));
476
+ const results = await Promise.allSettled(Array.from({ length: poolSize }, drain));
460
477
  for (const result of results) {
461
478
  if (result.status === "rejected") throw result.reason;
462
479
  }
package/src/client/api.ts CHANGED
@@ -45,12 +45,34 @@ async function readErrorMessage(res: Response, endpoint: string): Promise<string
45
45
  return `HTTP error ${res.status} on ${endpoint}`;
46
46
  }
47
47
 
48
- async function fetchJson<T>(endpoint: string, options?: RequestInit): Promise<T> {
49
- const res = await fetch(endpoint, options);
48
+ /**
49
+ * Response validators keyed by the parsed body they describe. Revalidating a
50
+ * body sends its ETag; the entry dies with the body when callers drop it.
51
+ */
52
+ const validators = new WeakMap<object, string>();
53
+
54
+ /**
55
+ * Fetch `endpoint` as JSON, revalidating `previous` when it came from an
56
+ * ETag-bearing response: a 304 resolves to `previous` itself (same reference,
57
+ * no re-parse).
58
+ */
59
+ async function fetchJson<T>(endpoint: string, options?: RequestInit, previous?: T): Promise<T> {
60
+ const etag = typeof previous === "object" && previous !== null ? validators.get(previous) : undefined;
61
+ let init = options;
62
+ if (etag) {
63
+ const headers = new Headers(options?.headers);
64
+ headers.set("If-None-Match", etag);
65
+ init = { ...options, headers };
66
+ }
67
+ const res = await fetch(endpoint, init);
68
+ if (res.status === 304 && etag) return previous as T;
50
69
  if (!res.ok) {
51
70
  throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
52
71
  }
53
- return res.json() as Promise<T>;
72
+ const data = (await res.json()) as T;
73
+ const nextEtag = res.headers.get("ETag");
74
+ if (nextEtag && typeof data === "object" && data !== null) validators.set(data, nextEtag);
75
+ return data;
54
76
  }
55
77
 
56
78
  /**
@@ -182,8 +204,21 @@ export async function getSessions(limit = 100, q?: string, signal?: AbortSignal)
182
204
  return fetchJson<SessionSummary[]>(`${API_BASE}/sessions?${params}`, { signal });
183
205
  }
184
206
 
185
- export async function getSessionTrace(file: string, signal?: AbortSignal): Promise<SessionTrace> {
186
- return fetchJson<SessionTrace>(`${API_BASE}/session/trace?file=${encodeURIComponent(file)}`, { signal });
207
+ /**
208
+ * Pass the last trace for `file` as `previous` to revalidate it: an unchanged
209
+ * trace resolves to that same object without downloading or parsing the body.
210
+ */
211
+ export async function getSessionTrace(
212
+ file: string,
213
+ signal?: AbortSignal,
214
+ previous?: SessionTrace,
215
+ ): Promise<SessionTrace> {
216
+ // `no-store`: validation is ours (above); the browser cache would only keep a second multi-MB copy.
217
+ return fetchJson<SessionTrace>(
218
+ `${API_BASE}/session/trace?file=${encodeURIComponent(file)}`,
219
+ { signal, cache: "no-store" },
220
+ previous,
221
+ );
187
222
  }
188
223
 
189
224
  /** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
@@ -1,12 +1,12 @@
1
1
  import { RefreshCw } from "lucide-react";
2
2
  import { type ReactNode, useEffect, useState } from "react";
3
3
  import { formatInteger } from "../data/formatters";
4
- import { useLive } from "../data/live";
4
+ import { useLiveStatus } from "../data/live";
5
5
  import { Dot } from "../ui";
6
6
 
7
7
  /** Topbar ingest status; click to sync now. */
8
8
  export function LiveChip() {
9
- const { sync, indexingHours, connected, requestSync } = useLive();
9
+ const { sync, indexingHours, connected, requestSync } = useLiveStatus();
10
10
  const now = useNow(sync.phase === "idle" ? 15_000 : null);
11
11
 
12
12
  let body: ReactNode;
@@ -1,7 +1,7 @@
1
1
  import { Menu } from "lucide-react";
2
2
  import { type ReactNode, useEffect, useState } from "react";
3
3
  import { rangeMeta, TIME_RANGES } from "../data/range";
4
- import { useLive } from "../data/live";
4
+ import { useLiveStatus } from "../data/live";
5
5
  import type { TimeRange } from "../types";
6
6
  import { Segmented } from "../ui";
7
7
  import { LiveChip } from "./LiveChip";
@@ -30,7 +30,7 @@ const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
30
30
  export function Shell({ section, onSectionChange, range, onRangeChange, children }: ShellProps) {
31
31
  const [menuOpen, setMenuOpen] = useState(false);
32
32
  const scrolled = useScrolled();
33
- const { sync } = useLive();
33
+ const { sync } = useLiveStatus();
34
34
  useShortcuts(onSectionChange, onRangeChange);
35
35
 
36
36
  const go = (next: DashboardSection) => {