@oh-my-pi/omp-stats 18.2.4 → 18.2.5

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.5] - 2026-09-17
6
+
7
+ ### Fixed
8
+
9
+ - Improved session and fork-detection polling performance, reducing unnecessary filesystem and database work when monitoring sessions.
10
+
5
11
  ## [18.2.1] - 2026-09-15
6
12
 
7
13
  ### Fixed
@@ -549,6 +549,12 @@ export interface SessionTrace {
549
549
  endedAt: number;
550
550
  /** Root transcript mtime; doubles as the ETag. */
551
551
  mtimeMs: number;
552
+ /**
553
+ * Conditional-request fingerprint `"<rootMs>:<childHighMs>"` (see
554
+ * traceFingerprintForEtag): the server ETag is built from this, so a
555
+ * subagent-only append changes the ETag even when the root is untouched.
556
+ */
557
+ etag: string;
552
558
  /** DFS order, main first. */
553
559
  tracks: TraceTrack[];
554
560
  summary: TraceSummary;
@@ -19,10 +19,16 @@ export declare class TracePathError extends Error {
19
19
  */
20
20
  export declare const TRACE_ETAG_VERSION = 3;
21
21
  /**
22
- * Assemble the full span tree for one root session transcript.
23
- * Throws {@link TracePathError} for paths outside the sessions root; ENOENT
24
- * passes through for the caller's 404 mapping.
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).
25
26
  */
27
+ export declare function traceFingerprintForEtag(fileParam: string): Promise<string | undefined>;
28
+ export declare function traceMemoForTests(): {
29
+ file: string;
30
+ mtimeMs: number;
31
+ } | undefined;
26
32
  export declare function buildSessionTrace(fileParam: string): Promise<SessionTrace>;
27
33
  /** Fetch one full journal entry by id from a transcript, for the span drawer. */
28
34
  export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
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.2.4",
4
+ "version": "18.2.5",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -39,9 +39,9 @@
39
39
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "18.2.4",
43
- "@oh-my-pi/pi-catalog": "18.2.4",
44
- "@oh-my-pi/pi-utils": "18.2.4",
42
+ "@oh-my-pi/pi-ai": "18.2.5",
43
+ "@oh-my-pi/pi-catalog": "18.2.5",
44
+ "@oh-my-pi/pi-utils": "18.2.5",
45
45
  "@tailwindcss/node": "^4.3.2",
46
46
  "chart.js": "^4.5.1",
47
47
  "lucide-react": "^1.24.0",
package/src/db.ts CHANGED
@@ -215,6 +215,7 @@ export async function initDb(): Promise<Database> {
215
215
  );
216
216
 
217
217
  CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
218
+ CREATE INDEX IF NOT EXISTS idx_messages_entry_timestamp ON messages(entry_id, timestamp);
218
219
  CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
219
220
  CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
220
221
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
@@ -249,6 +250,7 @@ export async function initDb(): Promise<Database> {
249
250
  );
250
251
 
251
252
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp ON user_messages(timestamp);
253
+ CREATE INDEX IF NOT EXISTS idx_user_messages_entry_timestamp ON user_messages(entry_id, timestamp);
252
254
  CREATE INDEX IF NOT EXISTS idx_user_messages_timestamp_model ON user_messages(timestamp, model, provider);
253
255
 
254
256
  CREATE TABLE IF NOT EXISTS tool_calls (
@@ -270,6 +272,7 @@ export async function initDb(): Promise<Database> {
270
272
  );
271
273
 
272
274
  CREATE INDEX IF NOT EXISTS idx_tool_calls_timestamp ON tool_calls(timestamp);
275
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_entry_timestamp ON tool_calls(entry_id, timestamp);
273
276
  CREATE INDEX IF NOT EXISTS idx_tool_calls_tool_timestamp ON tool_calls(tool_name, timestamp);
274
277
 
275
278
  CREATE TABLE IF NOT EXISTS meta (
package/src/server.ts CHANGED
@@ -30,7 +30,14 @@ import {
30
30
  STATS_DASHBOARD_HOSTNAME_HEADER,
31
31
  STATS_DASHBOARD_SECURITY_VERSION,
32
32
  } from "./port-conflict";
33
- import { buildSessionTrace, getTraceEntry, listSessionSummaries, TRACE_ETAG_VERSION, TracePathError } from "./trace";
33
+ import {
34
+ buildSessionTrace,
35
+ getTraceEntry,
36
+ listSessionSummaries,
37
+ TRACE_ETAG_VERSION,
38
+ traceFingerprintForEtag,
39
+ TracePathError,
40
+ } from "./trace";
34
41
 
35
42
  const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
36
43
 
@@ -294,9 +301,24 @@ export async function handleApi(req: Request): Promise<Response> {
294
301
  const file = url.searchParams.get("file");
295
302
  if (!file) return Response.json({ error: "file required" }, { status: 400 });
296
303
  try {
304
+ // ETag-first: compare the client's etag against the
305
+ // root+child fingerprint WITHOUT building the trace. A matching
306
+ // (unchanged) poll returns 304 after stats only; only a changed
307
+ // tree pays the full re-read/re-parse/rebuild in
308
+ // buildSessionTrace (itself memoized for non-conditional polls).
309
+ // The fingerprint covers child transcripts, so a subagent-only
310
+ // append changes the ETag and never 304s stale.
311
+ const clientEtag = req.headers.get("if-none-match");
312
+ if (clientEtag) {
313
+ const fingerprint = await traceFingerprintForEtag(file);
314
+ if (fingerprint !== undefined) {
315
+ const etag = `"${TRACE_ETAG_VERSION}:${fingerprint}"`;
316
+ if (clientEtag === etag) return new Response(null, { status: 304 });
317
+ }
318
+ }
297
319
  const trace = await buildSessionTrace(file);
298
- const etag = `"${TRACE_ETAG_VERSION}:${trace.mtimeMs}"`;
299
- if (req.headers.get("if-none-match") === etag) return new Response(null, { status: 304 });
320
+ const etag = `"${TRACE_ETAG_VERSION}:${trace.etag}"`;
321
+ if (clientEtag === etag) return new Response(null, { status: 304 });
300
322
  return Response.json(trace, { headers: { ETag: etag } });
301
323
  } catch (err) {
302
324
  if (err instanceof TracePathError) return Response.json({ error: err.message }, { status: 400 });
@@ -586,6 +586,12 @@ export interface SessionTrace {
586
586
  endedAt: number;
587
587
  /** Root transcript mtime; doubles as the ETag. */
588
588
  mtimeMs: number;
589
+ /**
590
+ * Conditional-request fingerprint `"<rootMs>:<childHighMs>"` (see
591
+ * traceFingerprintForEtag): the server ETag is built from this, so a
592
+ * subagent-only append changes the ETag even when the root is untouched.
593
+ */
594
+ etag: string;
589
595
  /** DFS order, main first. */
590
596
  tracks: TraceTrack[];
591
597
  summary: TraceSummary;
package/src/trace.ts CHANGED
@@ -741,9 +741,92 @@ async function buildTrackTree(
741
741
  * Throws {@link TracePathError} for paths outside the sessions root; ENOENT
742
742
  * passes through for the caller's 404 mapping.
743
743
  */
744
+ // Single-entry memo: the dashboard polls one open trace every 15s. Keyed on
745
+ // the root file's mtime PLUS a fingerprint of the child-transcript SET under
746
+ // the root's artifacts tree (one cheap readdir+stat walk, no parse). A
747
+ // high-water mark alone is blind to deletions and to older-mtime
748
+ // replacements, so the fingerprint encodes every child's (name, mtimeMs)
749
+ // pair: any append, delete, rename, or content swap changes the key and the
750
+ // memo misses. Membership diffs are bounded (depth + file count caps) so a
751
+ // pathological artifacts tree cannot blow the key.
752
+ interface TraceMemoEntry {
753
+ mtimeMs: number;
754
+ childFingerprint: string;
755
+ trace: SessionTrace;
756
+ }
757
+
758
+ let traceMemo: { file: string; entry: TraceMemoEntry } | undefined;
759
+
760
+ /**
761
+ * Fingerprint of the child-transcript set: per-directory file COUNT plus
762
+ * sorted `name@mtimeMs` pairs, mirroring buildTrackTree's discovery (stats
763
+ * only, no parse). The count is load-bearing: without it, children past a
764
+ * truncation cap are invisible and appends to them never invalidate the key.
765
+ * Best-effort: a raced gc deletion is recorded as `name@gone` (which itself
766
+ * changes the fingerprint and misses the memo — the safe direction).
767
+ */
768
+ async function childTranscriptsFingerprint(rootFile: string, depth = 0): Promise<string> {
769
+ if (depth > MAX_TRACK_DEPTH) return "";
770
+ let names: string[] = [];
771
+ try {
772
+ names = await fs.readdir(transcriptStem(rootFile));
773
+ } catch {
774
+ return "";
775
+ }
776
+ names.sort();
777
+ const transcriptNames = names.filter(name => name.endsWith(".jsonl") || name.endsWith(".jsonl.gz"));
778
+ const parts: string[] = [`n=${transcriptNames.length}`];
779
+ for (const name of transcriptNames) {
780
+ const childFile = path.join(transcriptStem(rootFile), name);
781
+ try {
782
+ const stat = await fs.stat(childFile);
783
+ parts.push(`${name}@${stat.mtimeMs}`);
784
+ // Recurse like the builder (nested subagents), without parsing.
785
+ const nested = await childTranscriptsFingerprint(childFile, depth + 1);
786
+ if (nested) parts.push(`${name}/{${nested}}`);
787
+ } catch {
788
+ parts.push(`${name}@gone`);
789
+ }
790
+ }
791
+ return parts.join(",");
792
+ }
793
+
794
+ /**
795
+ * Freshness fingerprint for the trace ETag pre-check: root mtime plus the
796
+ * child-transcript set fingerprint (readdir+stat walk, no parse). Returns
797
+ * undefined when the path is rejected or missing (the caller falls through
798
+ * to the full build, which maps those to 400/404).
799
+ */
800
+ export async function traceFingerprintForEtag(fileParam: string): Promise<string | undefined> {
801
+ try {
802
+ const resolved = resolveSessionPath(fileParam);
803
+ const rootMs = (await fs.stat(resolved)).mtimeMs;
804
+ const childFp = await childTranscriptsFingerprint(resolved);
805
+ return `${rootMs}:${childFp}`;
806
+ } catch {
807
+ return undefined;
808
+ }
809
+ }
810
+
811
+ export function traceMemoForTests(): { file: string; mtimeMs: number } | undefined {
812
+ if (!traceMemo) return undefined;
813
+ return { file: traceMemo.file, mtimeMs: traceMemo.entry.mtimeMs };
814
+ }
815
+
744
816
  export async function buildSessionTrace(fileParam: string): Promise<SessionTrace> {
745
817
  const resolved = resolveSessionPath(fileParam);
746
818
  const rootStat = await fs.stat(resolved);
819
+ const memo = traceMemo?.file === resolved ? traceMemo.entry : undefined;
820
+ // Pre-build child fingerprint on EVERY path (hit check and rebuild
821
+ // alike): the post-build race guard compares against this, so a child
822
+ // append landing mid-parse is detected even on first builds and
823
+ // memo-less rebuilds, where no prior fingerprint exists to compare.
824
+ const preChildFingerprint = await childTranscriptsFingerprint(resolved);
825
+ if (memo && memo.mtimeMs === rootStat.mtimeMs) {
826
+ // Equality, not <=: a LOWER mark (delete/older-mtime replace) is
827
+ // also stale — only an identical child set may reuse the trace.
828
+ if (preChildFingerprint === memo.childFingerprint) return memo.trace;
829
+ }
747
830
 
748
831
  const tracks: TraceTrack[] = [];
749
832
  const scans: TrackScan[] = [];
@@ -823,16 +906,39 @@ export async function buildSessionTrace(fileParam: string): Promise<SessionTrace
823
906
  toolStats: [...toolStats.values()].sort((a, b) => b.totalMs - a.totalMs),
824
907
  };
825
908
 
826
- return {
909
+ // Re-collect the fingerprint AFTER the parse: a child append landing
910
+ // between the pre-build check and now would otherwise be baked into
911
+ // neither the trace nor the key.
912
+ const childFingerprint = await childTranscriptsFingerprint(resolved);
913
+ // Root rewritten mid-build: same no-cache rule as the child race below.
914
+ const rootChanged = (await fs.stat(resolved).catch(() => null))?.mtimeMs !== rootStat.mtimeMs;
915
+ const raced = childFingerprint !== preChildFingerprint;
916
+ // Raced builds keep the PRE-build fingerprint as their ETag: the parsed
917
+ // bytes predate the racy write, so claiming the post-write state would
918
+ // 304 stale forever when that write was the child's last. The pre-build
919
+ // key guarantees the next conditional poll misses (filesystem moved on)
920
+ // and rebuilds — at most one stale response per race, never an
921
+ // indefinitely cached one. Unraced builds use the post-build key, which
922
+ // equals the pre-build key by construction.
923
+ const etagFingerprint = raced || rootChanged ? preChildFingerprint : childFingerprint;
924
+ const trace: SessionTrace = {
827
925
  file: resolved,
828
926
  title: rootScan.title,
829
927
  cwd: rootScan.cwd,
830
928
  startedAt: start,
831
929
  endedAt: end,
832
930
  mtimeMs: rootStat.mtimeMs,
931
+ etag: `${rootStat.mtimeMs}:${etagFingerprint}`,
833
932
  tracks,
834
933
  summary,
835
934
  };
935
+ if (!raced && !rootChanged) {
936
+ traceMemo = {
937
+ file: resolved,
938
+ entry: { mtimeMs: rootStat.mtimeMs, childFingerprint, trace },
939
+ };
940
+ }
941
+ return trace;
836
942
  }
837
943
 
838
944
  /** Fetch one full journal entry by id from a transcript, for the span drawer. */
@@ -945,7 +1051,21 @@ function basenameTimestamp(base: string): number | undefined {
945
1051
  * Traces list include sessions the sync pass hasn't indexed yet (most
946
1052
  * importantly the currently running one).
947
1053
  */
1054
+ // Short-TTL memo for the disk sweep behind /api/sessions (30s poll): the
1055
+ // session list changes only on create/edit, so readdir+stat of every file per
1056
+ // poll is pure syscall churn. TTL is deliberately short so a just-created
1057
+ // session appears within seconds.
1058
+ let diskRootsMemo:
1059
+ | { atMs: number; limit: number; roots: Array<{ file: string; mtimeMs: number; startedAt: number }> }
1060
+ | undefined;
1061
+ const DISK_ROOTS_TTL_MS = 5_000;
1062
+
948
1063
  async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtimeMs: number; startedAt: number }>> {
1064
+ const now = Date.now();
1065
+ const memo = diskRootsMemo;
1066
+ if (memo && memo.limit >= limit && now - memo.atMs < DISK_ROOTS_TTL_MS) {
1067
+ return memo.roots.slice(0, limit);
1068
+ }
949
1069
  const sessionsDir = getSessionsDir();
950
1070
  let projects: string[] = [];
951
1071
  try {
@@ -978,6 +1098,7 @@ async function scanDiskRoots(limit: number): Promise<Array<{ file: string; mtime
978
1098
  }),
979
1099
  );
980
1100
  roots.sort((a, b) => b.mtimeMs - a.mtimeMs);
1101
+ if (roots.length <= 1000) diskRootsMemo = { atMs: Date.now(), limit, roots };
981
1102
  return roots.slice(0, limit);
982
1103
  }
983
1104