@ascenda-one/history-import 0.1.14 → 0.1.16

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/README.md CHANGED
@@ -30,8 +30,15 @@ command (same pattern as hooks pairing) and this CLI does the reading.
30
30
  4. **Metrics only by default.** Prompt/response text, thinking blocks and
31
31
  file contents never leave the machine. Content-level ingestion, if it ever
32
32
  ships, is a separate explicit opt-in — not this package's default path.
33
- 5. **Aggregate before shipping.** Per-session / per-day events, not one event
34
- per bubble — a single machine's stores hold tens of thousands of them.
33
+ 5. **Aggregate before shipping, unless a reader counts the rows.** Per-session
34
+ / per-day events, not one event per bubble — a single machine's stores hold
35
+ tens of thousands of them. The one deliberate exception is
36
+ `ai_tool_call_started`: the backend's work-demand rail derives
37
+ `toolCallCount` by counting rows of that type and reads no `toolCallCount`
38
+ key off metadata, so a session-level aggregate would ship, store, and be
39
+ counted by nothing. Per-call events also place the work in the right hour,
40
+ which a session spanning six of them cannot. Expect an order of magnitude
41
+ more events than a session-only import, and an `events.jsonl` to match.
35
42
  6. **Provenance is data.** Every event carries `historical_direct`,
36
43
  `historical_derived` or `historical_unparsed` — never the live
37
44
  `ai_work_telemetry` provenance — so no chart can pass history off as
@@ -46,6 +53,8 @@ command (same pattern as hooks pairing) and this CLI does the reading.
46
53
  | Staging/snapshot (copy-then-parse, WAL-aware, **torn down by the run that makes it**) | implemented |
47
54
  | `archive` (durable content-addressed copy, dedup, verify, restore, prune) | **implemented, verified on a real 4.1 GB store** |
48
55
  | **Claude Code extractor** (human-prompt/tool-result split, session folds incl. recursive subagent transcripts, after-hours, compaction, tool failures, context-window peak, human-corrected edits, correction cadence, gap-split active minutes, epoch marker) | **implemented, verified live** |
56
+ | **Active-time split** (hands-on vs agent-supervising, per session, per local day and per project digest; autonomy bands off the transcript's own `permissionMode`) | **implemented, verified against a real 400-session store** |
57
+ | **Tool-call counting, all three stores** (`tool_use` items / `toolFormerData` / `toolInvocationSerialized`, one `ai_tool_call_started` per call) | **implemented; exercised end to end against all three stores on a developer machine** |
49
58
  | **Batch shipper** (`POST /v1/tool-events/batch`, salted hashes, stable importKey) | **implemented, verified live** |
50
59
  | **Cursor extractor** (composerHeaders + bubble aggregation via SQL-side `json_extract`, prompt text never parsed into the process, subagent-composer folding, epoch marker) | **implemented, verified live** |
51
60
  | **VS Code extractor** (Timeline-history Chat-Edit day×workspace aggregation, Copilot chatSessions folding, workspace identity via `workspace.json` longest-prefix match, epoch marker) | **implemented, verified live** |
@@ -58,12 +67,84 @@ user-role transcript lines are tool-result round-trips, not typed prompts.
58
67
  Conflating the two inflates every prompt metric by roughly an order of
59
68
  magnitude, so its fixtures are the ones to keep green.
60
69
 
61
- ## Gaps that block a real user running this twice
70
+ ## Active time is two figures, never one
62
71
 
63
- Both blockers are enforced server-side, and both are **still landing**. Until
64
- they are live in the deployed backend, this package stays unpublished — a
65
- client anyone could install must not be able to backfill history against a
66
- backend that has not yet gated it.
72
+ `activeMinutes` answers "how much of this session was not idle": every
73
+ known-line timestamp, main thread and subagents merged, gap-split at five
74
+ minutes. It has always been the honest alternative to wall clock, and it is
75
+ unchanged.
76
+
77
+ It is not, on its own, an answer to "how long did this take me". One prompt can
78
+ drive a forty-minute agent run, and forty minutes of an agent working is not
79
+ forty minutes of a person at a keyboard. So the same material is also reported
80
+ split:
81
+
82
+ | Figure | What it is |
83
+ |---|---|
84
+ | `handsOnMinutes` | The interval immediately **preceding** a human prompt. The prompt at its end is the evidence: someone read the previous output and typed. |
85
+ | `agentSupervisingMinutes` | Every other active interval. The agent produced the lines that bound it. |
86
+
87
+ The two partition `activeMinutes` exactly and there is **no third key holding
88
+ their sum**, at session, day or project scale. Adding them reconstructs
89
+ `activeMinutes`, which already exists; a differently-named total would be the
90
+ same number wearing a claim it cannot support. On a real 400-session store the
91
+ split came out 1,074 hands-on minutes against 36,948 supervising — quoting the
92
+ combined 38,035 as time spent is off by a factor of thirty-five for the half a
93
+ person would recognise as their own.
94
+
95
+ **`agentSupervisingMinutes` does not claim anyone was watching**, and nothing
96
+ in a transcript could show that they were. It is time the agent was working
97
+ which the person did not spend typing. Rendering it as attention is a
98
+ fabrication the name invites and the data does not support; the honest gloss is
99
+ "the agent was working".
100
+
101
+ ### Autonomy bands
102
+
103
+ `permissionMode` is on the transcript's human-prompt lines and nowhere else —
104
+ across 120 real stores it appears on 6.7% of `user` lines and on no
105
+ `assistant`, `system` or `attachment` line. So posture is known at prompt
106
+ boundaries and carried forward between them, and supervising minutes are banded
107
+ by it through `autonomyBand`. Time before the first declaration lands in
108
+ `unknown` and is never folded into a neighbouring band.
109
+
110
+ The band map rides in the local handoff only (`autonomySplit`), never on the
111
+ wire: banding is a reader's vocabulary derived from the stored token at query
112
+ time, and storing the band would freeze a decision deliberately left open.
113
+
114
+ ### The counters
115
+
116
+ Three diagnostics ship with the split, read by neither the backend nor the
117
+ handoff on purpose:
118
+
119
+ - `activeSplitInstants` — distinct timestamps the split ran over. Two minutes
120
+ off four instants and off four hundred are not the same measurement.
121
+ - `activeSplitUndatedLines` — known lines whose `timestamp` would not parse.
122
+ They still move the session's wall clock by string comparison, so only this
123
+ says both active figures are short.
124
+ - `activeSplitUnposturedInstants` — instants reached before any
125
+ `permissionMode` was declared. The posture blind spot as a count, not
126
+ inferred from the `unknown` band being present.
127
+
128
+ ### The defect this replaced
129
+
130
+ The per-day slices used to gap-split the **prompt timestamps** while the
131
+ session figure gap-split the **whole timeline**. The threshold was shared and
132
+ commented as keeping one definition of "active"; the material was not. Across
133
+ 200 real sessions the prompts-only reading came to 2,730 minutes against
134
+ 18,938 — an 85.6% under-report, concentrated exactly on the sessions where an
135
+ agent did the most work. Both now cross the call, and
136
+ `tests/activeSplit.test.mjs` pins it against a real transcript.
137
+
138
+ ## What the backend enforces on a second run
139
+
140
+ Both of the blockers this section used to list — the consent scope and
141
+ idempotency — are enforced by the deployed backend, and have been since
142
+ 20 Aug 2026 (verified against a real backfill on 25 Aug 2026). They stopped
143
+ gating publication then, and the package ships from the release tag like
144
+ every other CLI here. Two things stand apart from them and are decided
145
+ elsewhere: which tier the batch ship belongs to, and the consent surface that
146
+ grants the lease. Without that grant every event of a backfill is rejected
147
+ `consent_missing_or_expired` — the gate working, not a broken token.
67
148
 
68
149
  - **Consent scope.** `historical_import` is a distinct `ToolConsentScope`,
69
150
  separate from the lease granted for live IDE telemetry, and a backfill
@@ -119,7 +200,7 @@ ascenda-history-import archive # the durable copy; --verify / --list / -
119
200
  ### Staging is scaffolding; `archive` is the copy
120
201
 
121
202
  `import` snapshots each store, extracts, and **deletes the snapshot in a
122
- `finally`** — success or failure — keeping only the ~10 MB `events.jsonl`.
203
+ `finally`** — success or failure — keeping only `events.jsonl`.
123
204
  It also sweeps snapshots left by earlier runs. This is not tidiness: nineteen
124
205
  runs once left 254 GB on a 926 GB disk and took free space to 279 MB, and the
125
206
  first thing to notice was unrelated tooling failing with `ENOSPC`.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Active time, split by who was doing the work.
3
+ *
4
+ * `activeMinutes` already answers "how much of this session was not idle" by
5
+ * gap-splitting every known-line timestamp. It cannot answer the question the
6
+ * Reveal actually wants to ask, which is what that time *was*: one prompt can
7
+ * drive a forty-minute agent run, and forty minutes of watching an agent is
8
+ * not forty minutes of a person at a keyboard. A single figure reports them as
9
+ * the same thing.
10
+ *
11
+ * This module splits the same gap-split material in two:
12
+ *
13
+ * - **hands-on** — the interval immediately *preceding* a human prompt. The
14
+ * prompt at its end is the evidence: someone read the previous output and
15
+ * typed. It is the only interval in a transcript where a person is
16
+ * demonstrably present, because it is the only one a person signed.
17
+ * - **agent-supervising** — every other active interval. The agent produced
18
+ * the lines that bound it.
19
+ *
20
+ * **What "supervising" does not claim.** It does not claim the person was
21
+ * watching, and nothing in a transcript could show that they were. It is time
22
+ * the agent was working which the person did not spend typing — no more. A
23
+ * reader that renders it as attention is reading a fabrication into it; the
24
+ * name is the plan's, the bound is this paragraph. The honest gloss is
25
+ * "the agent was working", not "you were supervising".
26
+ *
27
+ * **Two figures, never one total.** Nothing here returns their sum. They
28
+ * partition the same active milliseconds exactly (pinned by test), so a caller
29
+ * *can* add them — but adding them reconstructs `activeMinutes`, which already
30
+ * exists and already has readers. A third number spelled like a new
31
+ * measurement would be the same number wearing a claim it cannot support, and
32
+ * the whole point of the split is that the two halves differ.
33
+ *
34
+ * ## Posture
35
+ *
36
+ * `permissionMode` is on the transcript's human prompt lines and nowhere else
37
+ * — checked across 120 real stores: 6.7% of `user` lines carry it, no
38
+ * `assistant`, `system` or `attachment` line ever does. So posture is known at
39
+ * prompt boundaries and interpolated between them: the mode declared by the
40
+ * most recent human prompt at or before an interval governs that interval,
41
+ * because that is when the person last told the runtime what it could do
42
+ * unasked.
43
+ *
44
+ * Before the first declaration there is nothing to carry forward, and those
45
+ * milliseconds land in `unknown` — never folded into a neighbouring band, for
46
+ * the reason `autonomyBand` states at length: a guess there would look exactly
47
+ * like a measurement. `unknown` being non-zero is normal and is not a defect.
48
+ *
49
+ * Only supervising time is banded. Posture describes how much latitude the
50
+ * agent had while working; applied to the interval where the person was typing
51
+ * it would describe nothing.
52
+ */
53
+ import { autonomyBand } from "@ascenda-one/tool-kit";
54
+ /**
55
+ * Upstream's `permissionMode` spellings, snake-cased — the same one
56
+ * transformation `AutonomyMode` documents, applied here because the transcript
57
+ * writes camelCase where the hook payloads write snake_case. Mirroring rather
58
+ * than translating: a mode this table has never seen is passed through
59
+ * verbatim, so it reaches `autonomyBand` as an unrecognised token and lands in
60
+ * `unknown` instead of being quietly mapped onto whichever rung looks close.
61
+ */
62
+ export function snakeCasePermissionMode(mode) {
63
+ return mode.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
64
+ }
65
+ /**
66
+ * The active spans of a timeline, in order.
67
+ *
68
+ * **Ties are collapsed, not ordered.** A parallel tool batch writes several
69
+ * lines on one millisecond, and a human prompt can share an instant with the
70
+ * attachment lines that accompany it. Sorting such lines against each other
71
+ * would make the split depend on a within-millisecond order the store does not
72
+ * promise and the reader cannot see. So every line at one epoch millisecond
73
+ * becomes one instant, `human` if *any* of them was a human prompt and
74
+ * carrying whichever posture was declared there. Intervals between tied lines
75
+ * are zero-length and are not spans, so nothing is lost by it.
76
+ */
77
+ export function activeSpans(points, options) {
78
+ const collapsed = new Map();
79
+ let undatedPoints = 0;
80
+ for (const point of points) {
81
+ if (!Number.isFinite(point.at)) {
82
+ undatedPoints += 1;
83
+ continue;
84
+ }
85
+ const existing = collapsed.get(point.at);
86
+ if (existing) {
87
+ existing.human = existing.human || point.human;
88
+ if (point.autonomyMode)
89
+ existing.autonomyMode = point.autonomyMode;
90
+ }
91
+ else {
92
+ collapsed.set(point.at, { human: point.human, autonomyMode: point.autonomyMode });
93
+ }
94
+ }
95
+ const instants = [...collapsed.keys()].sort((a, b) => a - b);
96
+ const report = {
97
+ spans: [],
98
+ undatedPoints,
99
+ instants: instants.length,
100
+ unposturedInstants: 0
101
+ };
102
+ if (instants.length === 0)
103
+ return report;
104
+ // The posture in force, carried forward from the last human prompt that
105
+ // declared one. Seeded from the first instant before the loop so a session
106
+ // whose opening prompt declares a mode is not credited with an unknown
107
+ // stretch it never had.
108
+ let posture = collapsed.get(instants[0])?.autonomyMode ?? null;
109
+ if (posture === null)
110
+ report.unposturedInstants += 1;
111
+ for (let i = 1; i < instants.length; i += 1) {
112
+ const from = instants[i - 1];
113
+ const to = instants[i];
114
+ const here = collapsed.get(to);
115
+ const gap = to - from;
116
+ if (gap > 0 && gap <= options.activeGapMs) {
117
+ // `autonomyBand` maps a null or unrecognised token to `unknown` on its
118
+ // own; passing the raw carried value keeps that decision in the one
119
+ // place that documents it.
120
+ report.spans.push({ from, to, handsOn: here.human, band: autonomyBand(posture) });
121
+ }
122
+ if (here.autonomyMode)
123
+ posture = here.autonomyMode;
124
+ else if (posture === null)
125
+ report.unposturedInstants += 1;
126
+ }
127
+ return report;
128
+ }
129
+ /**
130
+ * Splits a session's timeline into hands-on and agent-supervising time, by
131
+ * summing {@link activeSpans}.
132
+ */
133
+ export function splitActiveTime(points, options) {
134
+ const report = activeSpans(points, options);
135
+ const split = {
136
+ handsOnMs: 0,
137
+ agentSupervisingMs: 0,
138
+ supervisingMsByBand: {},
139
+ undatedPoints: report.undatedPoints,
140
+ instants: report.instants,
141
+ unposturedInstants: report.unposturedInstants
142
+ };
143
+ for (const span of report.spans) {
144
+ const ms = span.to - span.from;
145
+ if (span.handsOn) {
146
+ split.handsOnMs += ms;
147
+ }
148
+ else {
149
+ split.agentSupervisingMs += ms;
150
+ split.supervisingMsByBand[span.band] = (split.supervisingMsByBand[span.band] ?? 0) + ms;
151
+ }
152
+ }
153
+ return split;
154
+ }
155
+ /** Whole minutes, rounded once at the edge. */
156
+ export function minutesOf(ms) {
157
+ return Math.round(ms / 60_000);
158
+ }
159
+ //# sourceMappingURL=activeSplit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activeSplit.js","sourceRoot":"","sources":["../src/activeSplit.ts"],"names":[],"mappings":"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"}