@narumitw/pi-inspect 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/dist/app.css +1 -0
- package/dist/app.js +128 -0
- package/dist/index.html +1 -0
- package/docs/REVIEW.md +189 -0
- package/docs/VERIFICATION.md +186 -0
- package/docs/viewer.md +74 -0
- package/package.json +67 -4
- package/src/ancestry.ts +32 -0
- package/src/collector.ts +143 -0
- package/src/context.ts +115 -0
- package/src/correlation.ts +27 -0
- package/src/entry-index.ts +17 -0
- package/src/entry-summary.ts +34 -0
- package/src/extension.ts +259 -0
- package/src/feed.ts +145 -0
- package/src/identity.ts +112 -0
- package/src/index.ts +1 -0
- package/src/model.ts +117 -0
- package/src/native-projection.ts +28 -0
- package/src/privacy.ts +136 -0
- package/src/projection.ts +374 -0
- package/src/server.ts +202 -0
- package/src/system-message.ts +117 -0
- package/src/web/api.ts +137 -0
- package/src/web/app.tsx +570 -0
- package/src/web/captured-executions.tsx +177 -0
- package/src/web/components.css +261 -0
- package/src/web/components.tsx +241 -0
- package/src/web/composition.css +554 -0
- package/src/web/context-composition.tsx +329 -0
- package/src/web/context-inventory.tsx +65 -0
- package/src/web/entry-calls.ts +14 -0
- package/src/web/format.ts +94 -0
- package/src/web/hierarchy.ts +117 -0
- package/src/web/history.ts +56 -0
- package/src/web/inline-entry.tsx +124 -0
- package/src/web/inspector.tsx +386 -0
- package/src/web/json-tree.tsx +74 -0
- package/src/web/overview.tsx +67 -0
- package/src/web/panels.css +267 -0
- package/src/web/panes.tsx +123 -0
- package/src/web/recorded-content.tsx +49 -0
- package/src/web/search.ts +16 -0
- package/src/web/session-filters.tsx +100 -0
- package/src/web/session.css +114 -0
- package/src/web/style.css +332 -0
- package/src/web/styles.d.ts +1 -0
- package/src/web/timeline.tsx +33 -0
- package/src/web/timing.ts +26 -0
- package/src/web/trace.css +411 -0
- package/src/web/trace.tsx +313 -0
- package/src/web/virtual-context.ts +138 -0
package/docs/viewer.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Viewer reference
|
|
2
|
+
|
|
3
|
+
Detailed views, data provenance, bounds and security for `pi-inspect`.
|
|
4
|
+
|
|
5
|
+
## Views and data provenance
|
|
6
|
+
|
|
7
|
+
**Context composition is the primary view.** A warm off-white document layout uses muted, consistent system/developer, user, assistant, tool-call, tool-result and neutral colors. Each ordered message or supported content block is an expandable row; assistant thinking/text/tool calls remain distinct. Desktop details place source metadata beside content, with lightweight structured/metadata/raw accordions. Copy acts on captured display data; the raw message and original role remain available. Missing segment token sizes are explicitly unavailable: persisted assistant totalTokens is usage, not the size of an individual context block.
|
|
8
|
+
|
|
9
|
+
A narrow right-hand minimap summarizes the same filtered segment sequence in up to 160 contiguous buckets, with category proportions and the actual viewport region. Clicking a bucket jumps/focuses its first segment. Variable-height virtualization renders the viewport plus five-row overscan on each side; measured heights and source-ID anchoring preserve older scroll while expanding or appending. Search covers available bounded captured message content, not uncaptured data. Category filters and source-mode navigation retain row expansion; keyboard ArrowUp/ArrowDown/Home/End navigate and Enter/Space or Left/Right control disclosure. Light is the default; dark and responsive inline details remain available.
|
|
10
|
+
|
|
11
|
+
**Source distinction is explicit.** After consent, context_with_system observes Pi messages after conversation-only context handlers and prompt/tool restoration. It returns nothing and does not modify the event. The primary view labels this as the last observed Pi-stage context, including its leaf/time when available; later context_with_system hooks, provider conversion and payload hooks can still differ. The advanced provider-payload copy is an independently timestamped hook-stage observation, not a guaranteed final transport body. Public callbacks have no request identity proving turn ownership: cache-warming and retry bodies may appear, so they are never attributed to the context leaf or observation time. Until a request is observed, the native active-leaf projection is labeled session-derived, not exact model input. Compaction/context edits use native Pi semantics, not the full session log. Session events and execution events are never silently appended to context.
|
|
12
|
+
|
|
13
|
+
**Context and Session have independent selection and reading state.** Context is full-width: it never shows Session filters, event Details, or an execution list. Current runtime prompt, tool schemas/exposure/active/callable state, and currently advertised skills are available under **Current runtime information**, not mixed into historical event evidence. This information may not yet have been sent to a model. Inventory cards mount only while their enclosing disclosures are open; closed schema/Data bodies do not mount recursive JSON or text previews, while bounded Copy remains available. JSON object disclosure state is retained within the existing bounded scope cache.
|
|
14
|
+
|
|
15
|
+
**Session** answers what happened, with one primary navigation area and a content-first **Details** panel. **History** defaults to List and follows the active branch; entries are ordered by recorded ancestry, not timestamps, and grouped by user turn. Entries before the first user turn have their own group. Groups are reading aids, not execution parentage. User/assistant/tool summaries come from bounded redacted content; truncation is labeled. Missing leaves/parents and cycles produce explicit partial-history notices, never repaired links.
|
|
16
|
+
|
|
17
|
+
**Show internal events** includes generic Pi bookkeeping: custom state entries, labels, session metadata, and usage entries. Classification uses the original entry type, not a private extension name or message role; model-visible custom messages, including legacy messages with role `custom`, remain visible. Errors, compaction, branch summaries, system/context changes, model/thinking changes, and unknown types are not silently hidden. Matching hidden internal records are counted, excluding selected records retained visibly by the navigation exception. History searches show direct matches without every ancestor. Search covers the bounded summary/label inventory, not uncaptured full entry content.
|
|
18
|
+
|
|
19
|
+
**Branch view** replaces the duplicate sidebar tree. It retains actual session-entry `parentId` links and access to off-branch entries. Its filters retain actual ancestors, visibly marked as context; child counts are visible counts, not persisted totals. **History at selected entry** explicitly changes the browser's history scope; **Follow active branch** restores automatic active-leaf tracking. Neither changes Pi's leaf. A selection outside the displayed branch is explicitly labeled. Related-entry navigation can reveal an entry outside filters without clearing them; changing filters ends that exception.
|
|
20
|
+
|
|
21
|
+
Clicking a log row selects it without changing disclosure; its chevron toggles disclosure without selecting. Enter selects, Space toggles, ArrowUp/ArrowDown move focus, ArrowRight expands or enters a child in Branch view, and ArrowLeft collapses or moves to the recorded parent. Multiple rows can be expanded; collapsing a parent retains descendant expansion state. Inline Overview/Content/Raw/Metadata sections lazily load bounded recorded text/output, tool arguments, usage, and structured JSON. Complete inspection is available in Details.
|
|
22
|
+
|
|
23
|
+
History and Branch pages contain at most 50 visible rows, with one visible keyboard tab target; ArrowUp/ArrowDown can cross pages. Branch ancestry traversal is iterative and uncapped by visual depth: page-local/scaled indentation preserves absolute ARIA levels and original parents. Initial reveal does not fetch every ancestor's details. Session filters and Details are collapsible, resizable desktop panes; pointer dragging and focused-separator ArrowLeft/ArrowRight/Home/End resize them. Cancellation, Escape, blur and unmount release drag listeners. Narrow layouts use focus-trapped Dialog drawers with Escape dismissal and focus restoration. Entry selection opens Details; headers expose these controls only in Session. View choices, filters, widths, and disclosure are ephemeral, not saved settings.
|
|
24
|
+
|
|
25
|
+
Entry and branch failures have separate visible retries; unavailable historical checkpoints remain distinct from request failures. Live snapshots do not clear a failed selected-branch alert. Inline loads use four concurrent requests and a 64-entry immutable historical-detail cache; collapse, inactive views, disposal, and terminal session failures cancel owned requests. Historical section JSON disclosure is isolated by entry/section scope within a bounded 128-scope cache. Existing capture bounds still apply; UI expansion cannot recover omitted data.
|
|
26
|
+
Malformed entry/parent IDs, non-string type/role/timestamp fields and all duplicated entry identities are excluded from navigation without manufacturing replacement IDs. Duplicate ancestor/detail identities are explicitly unavailable rather than selecting the last indexed occurrence. The overview labels the count and exposes up to 20 indexed raw diagnostic samples, each bounded to 2 KiB-equivalent characters; source entries are unchanged. Invalid leaf identities cannot become a browser selection or captured transcript anchor.
|
|
27
|
+
|
|
28
|
+
A session-owned structural index is rebuilt on structural changes and released on stop/replacement. Inline requests reuse it and project only their validated ancestor path, not every off-branch entry; valid context-edit/compaction replay remains the native Pi result.
|
|
29
|
+
|
|
30
|
+
Before historical replay, ancestry is validated with a visited set and a 10,000-entry inspection budget. Recorded parent cycles, missing parents or a budget overflow keep the raw entry and original parent links visible but mark projection unavailable; the inspector never asks Pi to traverse those malformed paths or mutates the session to repair them.
|
|
31
|
+
|
|
32
|
+
The compact overview distinguishes persisted entries, indexed assistant messages/tokens/log errors and captured calls. Recorded span is the indexed log timestamp range, not execution duration. Tokens are persisted assistant `totalTokens`, excluding compaction/auxiliary usage; inline input/output bars appear only when recorded usage supplies both values, with cache excluded explicitly. Missing metrics are omitted or neutral. No model durations, transport latency, p95 or request totals are invented.
|
|
33
|
+
|
|
34
|
+
Each captured execution has a unique `occurrenceId`; `id` remains the provider/native raw tool-call ID. Repeated raw IDs retain separate history and occurrence-parent links. Transcript correlation requires the recorded owning-assistant anchor, not raw ID alone; overlapping invalid IDs are explicitly marked unavailable rather than assigning an ambiguous result. Evicting a selected occurrence returns selection to its remembered recorded anchor (or current leaf).
|
|
35
|
+
|
|
36
|
+
**Captured executions** is an explicit Session view, not a drawer attached to Context. Running/error counts remain visible in Session navigation without switching the user's view on arrival. Rows use occurrence-parent relationships; chevrons expand child navigation only. Arguments, results, relationships, correlation availability, reported duration and observed times appear once in the selection-owned Details panel. Root calls have no recorded parent; unresolved or invalid parent evidence is labeled unavailable. Correlation requires a recorded anchor and unambiguous IDs, not merely the absence of an error flag. Related execution buttons navigate real parent/child occurrences; **Inspect recorded anchor** returns to Branch view only when that anchor is represented. Eviction returns selection to its represented remembered anchor or current leaf; it switches Captured executions to Branch view but preserves History when already reading it.
|
|
37
|
+
|
|
38
|
+
Timeline is an optional shared-axis view. Log timestamps are points, not duration spans. `observedStartedAt`/`observedEndedAt` are wall-clock samples taken on entry to this collector\'s actual start/end callbacks, after activation; their intervals are labeled observations, not provider transport timing. Public `durationMs` remains the separate monotonic time spent in `execute()`, absent if it did not run. Missing starts are never synthesized from durations; update/end-only, running or reversed-clock records do not become closed spans. Earlier callbacks or framework work can separate observation intervals from reported execution durations.
|
|
39
|
+
|
|
40
|
+
**Details** defaults to Content: literal recorded message blocks, thinking, tool arguments/results, or internal-event payload. Metadata and Raw JSON are secondary; historical Prompt/Tools/Context/Skills/Codemode remain accessible in a horizontally scrollable tab strip. Live executions disable historical transcript tabs; their recorded anchor can be inspected separately. Inapplicable selections fall back to Content. Historical prompt sections, updates, previous-node diffs, declared tools, selected-entry contribution, and skill-read evidence never borrow the current runtime inventory as historical evidence. Full IDs remain in metadata/copy while identity headings use short IDs. Technical JSON can scroll within the pane without page-wide overflow.
|
|
41
|
+
|
|
42
|
+
**View branch context**, in Details' Context tab, opens the same composition reader with a **Session-derived preview** identity and selected leaf. Native Pi compaction/context edits apply, but request-local hooks and provider serialization remain unknown: this is not a captured historical request. Missing checkpoints or invalid ancestry remain explicitly unavailable. The preview has no unrelated provider observation or current runtime inventory. **Back to last observed context** restores the observed reader and its independent search/disclosure/scroll state.
|
|
43
|
+
|
|
44
|
+
| Capability | Location | Provenance / limitation |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| Ordered model input, search, minimap, inline message/metadata/raw/copy | Context | Last observed Pi context; explicit session-derived active-leaf fallback until observed |
|
|
47
|
+
| Current prompt, tools/MCP schemas and exposure, advertised skills | Context → Current runtime information | Current inventory, not historical request evidence; active ≠ provider-visible; MCP connection/auth state unavailable |
|
|
48
|
+
| Provider payload | Context → Advanced independent provider observation | Independent time; request/leaf association unavailable, including warming/retries |
|
|
49
|
+
| Conversation turns, summaries, internal entries, List/Timeline | Session → History | Selected branch ancestry; original entries unchanged; up to 50 visible rows per page |
|
|
50
|
+
| Roots, branches, labels, unknown/malformed ancestry | Session → Branch view and overview diagnostics | Up to 10,000 nodes reserving active ancestry; original parent relationships, not call parentage |
|
|
51
|
+
| Historical prompt, sections, updates, previous-node diff | Details → Prompt | Pi replay from stored checkpoints; missing historical checkpoints unavailable |
|
|
52
|
+
| Historical declared tools and skill invocation/read evidence | Details → Tools / Skills | Branch-local stored evidence; old catalogs and model compliance unknown |
|
|
53
|
+
| Branch projection and selected-entry contribution | Details → Context → View branch context | Explicit native session-derived preview, not final HTTP input |
|
|
54
|
+
| Script source, printed output, persisted nested metadata | Details → Codemode and Raw | Historical child results are not saved by Pi; discovery/model helpers unavailable unless printed |
|
|
55
|
+
| Live nested arguments/results, statuses, occurrence IDs, observed intervals | Session → Captured executions → Details | Session-wide public execution events; no invented Promise graph or request association |
|
|
56
|
+
|
|
57
|
+
Timeline uses log timestamps as points and observed execution callbacks as intervals, never invented model/transport spans. History/List and History/Timeline show the same branch scope. The timing axis includes the indexed session and captured observations for comparison; it does not infer execution causality from adjacent log rows.
|
|
58
|
+
Live results come from public `tool_execution_end` events, after the runtime's tool-result transformation pipeline (verified with a real nested call and a transforming hook). Partial updates are provisional. Unknown tools, schema failures, and permission blocks still have nested execution events. Direct transcript results remain authoritative persisted history. The inspector observes only: its context_with_system and before_provider_request handlers return nothing and never mutate messages or payloads; it registers no result-transforming handler.
|
|
59
|
+
|
|
60
|
+
Codemode discovery helpers (`searchTools`, `describeTool`, `describeNamespace`, catalog helpers), sandbox local variables, and internal Promise scheduling are not normal child-tool events. `models.classify` and `models.generateImages` are also not normal nested tools: their complete input/output is unavailable unless printed. The generic Raw tab can show documented persisted metadata but the inspector never imports another extension or depends on private codemode implementation types.
|
|
61
|
+
|
|
62
|
+
## Bounds and security
|
|
63
|
+
|
|
64
|
+
- The server binds only `127.0.0.1` on a random port, checks Host and Origin, rejects cross-site requests and non-GET methods, and authenticates every data/SSE request with a random per-session token and generation.
|
|
65
|
+
- The URL is at most 68 characters and printed on its own line for manual opening. Its fragment contains the full 256-bit token in Base64url, not a query or HTTP request; the data-free HTML supplies the public generation identifier. Legacy token/generation fragments remain supported. The browser keeps credentials in memory, attempts to save them in origin-scoped sessionStorage, and removes the fragment from history. Storage getter/read/write failures do not block a valid compact or legacy private URL; without saved credentials, fragment-free reloads require reopening from Pi. The launch notification necessarily contains the private credential URL for manual opening; it is not an API key. No request logs are written.
|
|
66
|
+
- A data-free shell and bundled JS/CSS are publicly readable on that loopback port; session data requires authentication. There is no CORS grant, arbitrary file read, attachment path endpoint, or upstream credential/header/environment API.
|
|
67
|
+
- Structured previews use native object/array disclosure. JSON text and plain content are literal with line numbers and bounded coloring (at most 1,000 lines and 512 colored tokens); long lines fall back to plain text. Copy acts only on the bounded redacted display value, including lines omitted by the local preview cap, and reports success or denial. Pending clipboard feedback is ignored after its owning component is disposed; the browser clipboard API itself cannot be aborted.
|
|
68
|
+
- CSP blocks remote scripts, connections, frames, fonts, and images. Tool text and Markdown are shown literally, not as executable HTML. Only small validated PNG/JPEG/GIF/WebP base64 blocks can render as images; SVG, remote images, opaque signatures, and large image payloads are omitted. No filesystem attachment is opened automatically.
|
|
69
|
+
- The collector keeps 128 records, at most 8 KiB-equivalent argument characters and 32 KiB-equivalent result characters per call, with depth/node limits. Tool schemas are bounded, inventory limits are 256 tools/skills, prompt/raw/projection fields are bounded to 64 KiB-equivalent characters, and evictions/truncation are visible. These are character/traversal budgets, not exact serialized-byte caps; JSON overhead adds size.
|
|
70
|
+
- Context observation is consent-gated, in-memory and discarded on stop/replacement/reload/shutdown. It captures at most 4,000 segments, up to 8 KiB-equivalent characters per message and approximately 1 MiB-equivalent serialized message characters overall; provider-payload copies use 64 KiB-equivalent characters. Bounds, omitted content and source-stage limitations are labeled. Expanded Raw/Copy can reveal all captured text beyond local preview limits, but cannot recover content excluded by capture bounds.
|
|
71
|
+
- SSE sends revision invalidations rather than result payloads. Notifications and intermediate partial copies are sampled/coalesced to at most one per 250 ms; final results are always captured. Live-only requests reuse the static snapshot rather than re-summarizing history. An owned 1-second reconciliation checks the public SDK entry count (when exposed), leaf/name and bounded public tool metadata for actions without extension change events, without rebuilding context projections or copying idle history on SDK managers. Readonly adapters lacking that public count method fall back to getEntries. At most eight stream clients and 32 sockets are accepted. A backpressured stream is closed, and reconnect triggers a fresh snapshot. Viewer-capacity responses (429), transient server failures and offline transport retry after one second. Snapshot request failures also retry with an owned one-second timer even when SSE is quiet; authentication/closed-session/generation responses do not retry. Authentication, closed-session, generation and protocol failures stop the connection and require reopening from Pi. Pending fetches, readers, reconnect timers, reconciliation/coalescing timers, browser-launch tasks, and session resources are cancelled on their owning boundary.
|
|
72
|
+
- Tokens do not defend against a malicious process running as your local user. Redaction is not a secrecy guarantee. Do not expose the port remotely or assume this is a multi-user service.
|
|
73
|
+
|
|
74
|
+
Entry IDs, parent IDs, timestamps and tool-result IDs over 512 characters are rejected, not truncated; malformed labels, session names, assistant/tool-call envelopes and context-edit replacements remain bounded raw diagnostics. Missing or empty session names preserve Pi’s title-clear semantics. Ordinary message entries retain Pi’s legacy missing/null-content normalization; checkpoint-owned system messages use strict replay validation. Historical current/previous system replay is preflighted against a cumulative 1 MiB input-character budget before native concatenation; over-budget previews are explicitly unavailable while bounded Raw evidence remains accessible. Tool replay also allows at most 2,048 cumulative additions/removals, with declaration traversal bounded to 16,384 nodes and depth 32 inside the shared character budget. Live own IDs and tool-name inputs must be non-empty strings of at most 512 characters; rejected events are counted as omitted with unavailable correlation, not rewritten. Invalid parent IDs and anchors are omitted with explicit unavailable relationships. When a bounded refresh omits a selected entry, the preview falls back to a represented entry without moving Pi’s leaf. Credential classification includes camelCase/acronym suffixes without masking metric keys. Search queries strip terminal controls before a 512-character bound and normalize once per filter update. Responsive mode depends only on viewport width, with side widths constrained to preserve the central workspace. Explicit filter resets start at the source top even when the matching IDs do not change.
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-inspect",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Read-only browser inspector for Pi session context and live tool calls.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"session",
|
|
13
|
+
"inspector",
|
|
14
|
+
"context",
|
|
15
|
+
"observability"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"dist",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./src/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node scripts/build.mjs",
|
|
31
|
+
"check": "npm run build && biome check . && npm run typecheck",
|
|
32
|
+
"format": "biome check --write .",
|
|
33
|
+
"prepack": "npm run build",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run --config vitest.config.ts",
|
|
36
|
+
"test:browser": "playwright test --config playwright.config.ts",
|
|
37
|
+
"smoke": "node scripts/smoke.mjs",
|
|
38
|
+
"smoke:git-install": "node scripts/git-install-smoke.mjs"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": "*",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@radix-ui/colors": "3.0.0",
|
|
46
|
+
"@radix-ui/react-collapsible": "1.1.20",
|
|
47
|
+
"@radix-ui/react-icons": "1.3.2",
|
|
48
|
+
"@radix-ui/themes": "3.3.0",
|
|
49
|
+
"esbuild": "0.28.2",
|
|
50
|
+
"react": "19.3.0",
|
|
51
|
+
"react-dom": "19.3.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@earendil-works/pi-ai": "1.1.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
56
|
+
"@playwright/test": "1.63.0",
|
|
57
|
+
"@types/node": "26.6.4",
|
|
58
|
+
"@types/react": "19.3.0",
|
|
59
|
+
"@types/react-dom": "19.3.0",
|
|
60
|
+
"typebox": "1.3.36",
|
|
61
|
+
"typescript": "7.0.2",
|
|
62
|
+
"vitest": "5.0.3"
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "https://github.com/narumiruna/pi-extensions",
|
|
67
|
+
"directory": "packages/pi-inspect"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/ancestry.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { EntryIndex } from "./entry-index.js";
|
|
4
|
+
import { structuralIssue } from "./identity.js";
|
|
5
|
+
import { nativeProjection } from "./native-projection.js";
|
|
6
|
+
|
|
7
|
+
// Pi's native projection walks parent links without cycle detection; validate before invoking it.
|
|
8
|
+
export function ancestry(
|
|
9
|
+
manager: ExtensionContext["sessionManager"],
|
|
10
|
+
id: string,
|
|
11
|
+
index = new EntryIndex(manager.getEntries()),
|
|
12
|
+
): { path: SessionEntry[]; issue?: string } {
|
|
13
|
+
const path: SessionEntry[] = [];
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
if (index.duplicates.has(id)) return { path: [], issue: "duplicate entry id" };
|
|
16
|
+
let current = index.get(id);
|
|
17
|
+
while (current) {
|
|
18
|
+
const issue = structuralIssue(current);
|
|
19
|
+
if (issue) return { path: path.reverse(), issue };
|
|
20
|
+
if (seen.has(current.id)) return { path: path.reverse(), issue: "recorded parent cycle" };
|
|
21
|
+
if (path.length >= 10000)
|
|
22
|
+
return { path: path.reverse(), issue: "ancestry exceeds the 10,000-entry inspection budget" };
|
|
23
|
+
seen.add(current.id);
|
|
24
|
+
path.push(current);
|
|
25
|
+
const parent = current.parentId;
|
|
26
|
+
if (parent && index.duplicates.has(parent)) return { path: path.reverse(), issue: "duplicate ancestor id" };
|
|
27
|
+
current = parent ? index.get(parent) : undefined;
|
|
28
|
+
if (parent && !current) return { path: path.reverse(), issue: "missing recorded parent" };
|
|
29
|
+
}
|
|
30
|
+
path.reverse();
|
|
31
|
+
return { path, issue: nativeProjection(path, id).issue };
|
|
32
|
+
}
|
package/src/collector.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToolExecutionEndEvent,
|
|
3
|
+
ToolExecutionStartEvent,
|
|
4
|
+
ToolExecutionUpdateEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Call } from "./model.js";
|
|
7
|
+
import { capture, displayText } from "./privacy.js";
|
|
8
|
+
|
|
9
|
+
function validIdentity(value: unknown): value is string {
|
|
10
|
+
return typeof value === "string" && value.length > 0 && value.length <= 512;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Collector {
|
|
14
|
+
readonly startedAt = Date.now();
|
|
15
|
+
private calls = new Map<string, Call>();
|
|
16
|
+
private partialAt = new Map<string, number>();
|
|
17
|
+
private sequence = 0;
|
|
18
|
+
dropped = 0;
|
|
19
|
+
invalidEvents = 0;
|
|
20
|
+
private admit(event: { toolCallId: string; toolName: string }): boolean {
|
|
21
|
+
if (validIdentity(event.toolCallId) && validIdentity(event.toolName)) return true;
|
|
22
|
+
this.invalidEvents++;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
private fenceParent(call: Call, parent: unknown): boolean {
|
|
26
|
+
if (parent !== undefined && !validIdentity(parent)) {
|
|
27
|
+
const changed =
|
|
28
|
+
call.parentUnavailable !== "Invalid or over-budget parent ID; relationship unavailable" ||
|
|
29
|
+
call.parentOccurrenceId !== undefined;
|
|
30
|
+
call.parentId = undefined;
|
|
31
|
+
call.parentOccurrenceId = undefined;
|
|
32
|
+
call.parentUnavailable = "Invalid or over-budget parent ID; relationship unavailable";
|
|
33
|
+
return changed;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
constructor(private readonly maxCalls = 128) {}
|
|
38
|
+
private create(
|
|
39
|
+
event: { toolCallId: string; parentToolCallId?: string; toolName: string },
|
|
40
|
+
args: unknown,
|
|
41
|
+
anchor: string | null,
|
|
42
|
+
): Call {
|
|
43
|
+
const parentId = validIdentity(event.parentToolCallId) ? event.parentToolCallId : undefined;
|
|
44
|
+
const parent = parentId ? this.active(parentId) : undefined;
|
|
45
|
+
const ambiguousParent =
|
|
46
|
+
event.parentToolCallId !== undefined &&
|
|
47
|
+
this.list().filter((item) => item.id === event.parentToolCallId && item.status === "running").length > 1;
|
|
48
|
+
const call: Call = {
|
|
49
|
+
id: event.toolCallId,
|
|
50
|
+
occurrenceId: `call-${++this.sequence}`,
|
|
51
|
+
parentId,
|
|
52
|
+
parentUnavailable: ambiguousParent ? "Overlapping running parent IDs; relationship unavailable" : undefined,
|
|
53
|
+
parentOccurrenceId: parent?.status === "running" ? parent.occurrenceId : undefined,
|
|
54
|
+
name: displayText(event.toolName),
|
|
55
|
+
status: "running",
|
|
56
|
+
args: capture(args, 8192),
|
|
57
|
+
branchAnchor: parent?.status === "running" ? parent.branchAnchor : validIdentity(anchor) ? anchor : null,
|
|
58
|
+
correlationUnavailable: anchor !== null && !validIdentity(anchor) ? true : undefined,
|
|
59
|
+
};
|
|
60
|
+
this.fenceParent(call, event.parentToolCallId);
|
|
61
|
+
this.calls.set(call.occurrenceId, call);
|
|
62
|
+
while (this.calls.size > this.maxCalls) {
|
|
63
|
+
const first = this.calls.keys().next().value;
|
|
64
|
+
if (first === undefined) break;
|
|
65
|
+
this.calls.delete(first);
|
|
66
|
+
this.partialAt.delete(first);
|
|
67
|
+
this.dropped++;
|
|
68
|
+
}
|
|
69
|
+
return call;
|
|
70
|
+
}
|
|
71
|
+
private active(rawId: string): Call | undefined {
|
|
72
|
+
const running = this.list().filter((call) => call.id === rawId && call.status === "running");
|
|
73
|
+
if (running.length > 1) {
|
|
74
|
+
for (const call of running) call.correlationUnavailable = true;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return running[0];
|
|
78
|
+
}
|
|
79
|
+
start(event: ToolExecutionStartEvent, anchor: string | null): void {
|
|
80
|
+
if (!this.admit(event)) return;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const call = this.create(event, event.args, anchor);
|
|
83
|
+
call.observedStartedAt = now;
|
|
84
|
+
if (
|
|
85
|
+
this.list().filter((parent) => parent.id === call.id && parent.branchAnchor === call.branchAnchor).length === 1
|
|
86
|
+
) {
|
|
87
|
+
for (const child of this.calls.values())
|
|
88
|
+
if (
|
|
89
|
+
child.parentId === call.id &&
|
|
90
|
+
!child.parentOccurrenceId &&
|
|
91
|
+
!child.parentUnavailable &&
|
|
92
|
+
child.status === "running" &&
|
|
93
|
+
child.branchAnchor === call.branchAnchor
|
|
94
|
+
)
|
|
95
|
+
child.parentOccurrenceId = call.occurrenceId;
|
|
96
|
+
}
|
|
97
|
+
const occurrences = this.list().filter((item) => item.id === call.id && item.branchAnchor === call.branchAnchor);
|
|
98
|
+
if (occurrences.length > 1) {
|
|
99
|
+
for (const occurrence of occurrences) occurrence.correlationUnavailable = true;
|
|
100
|
+
for (let i = 0; i < this.calls.size; i++)
|
|
101
|
+
for (const child of this.calls.values())
|
|
102
|
+
if (child.parentOccurrenceId && this.calls.get(child.parentOccurrenceId)?.correlationUnavailable)
|
|
103
|
+
child.correlationUnavailable = true;
|
|
104
|
+
}
|
|
105
|
+
this.active(event.toolCallId); // Explicitly mark overlapping reused IDs as ambiguous, never misroute their results.
|
|
106
|
+
}
|
|
107
|
+
update(event: ToolExecutionUpdateEvent, anchor: string | null): boolean {
|
|
108
|
+
if (!this.admit(event)) return true; // Publish the new unavailable-event counter.
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
let call = this.active(event.toolCallId);
|
|
111
|
+
if (!call && this.list().some((call) => call.id === event.toolCallId && call.status === "running")) return false;
|
|
112
|
+
call ??= this.create(event, event.args, anchor);
|
|
113
|
+
const parentChanged = this.fenceParent(call, event.parentToolCallId);
|
|
114
|
+
if (now - (this.partialAt.get(call.occurrenceId) ?? -Infinity) < 250) return parentChanged;
|
|
115
|
+
this.partialAt.set(call.occurrenceId, now);
|
|
116
|
+
call.result = capture(event.partialResult);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
end(event: ToolExecutionEndEvent, anchor: string | null): void {
|
|
120
|
+
if (!this.admit(event)) return;
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const running = this.list().filter((call) => call.id === event.toolCallId && call.status === "running");
|
|
123
|
+
const call = this.active(event.toolCallId) ?? this.create(event, "[not captured]", anchor);
|
|
124
|
+
if (running.length > 1) {
|
|
125
|
+
call.correlationUnavailable = true;
|
|
126
|
+
call.parentOccurrenceId = undefined;
|
|
127
|
+
}
|
|
128
|
+
this.fenceParent(call, event.parentToolCallId);
|
|
129
|
+
call.status = event.isError ? "error" : "ok";
|
|
130
|
+
call.durationMs =
|
|
131
|
+
typeof event.durationMs === "number" && Number.isFinite(event.durationMs) && event.durationMs >= 0
|
|
132
|
+
? event.durationMs
|
|
133
|
+
: undefined;
|
|
134
|
+
call.observedEndedAt = now;
|
|
135
|
+
call.result = capture(event.result);
|
|
136
|
+
}
|
|
137
|
+
settle(): void {
|
|
138
|
+
for (const call of this.calls.values()) if (call.status === "running") call.status = "unfinished";
|
|
139
|
+
}
|
|
140
|
+
list(): Call[] {
|
|
141
|
+
return [...this.calls.values()];
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { buildSessionProjection, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ancestry } from "./ancestry.js";
|
|
4
|
+
import type { EntryIndex } from "./entry-index.js";
|
|
5
|
+
import { recordedLeaf } from "./identity.js";
|
|
6
|
+
import type { ContextComposition, ContextSegment, Json } from "./model.js";
|
|
7
|
+
import { capture } from "./privacy.js";
|
|
8
|
+
|
|
9
|
+
function object(value: Json | undefined): Record<string, Json> | undefined {
|
|
10
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function captureContext(
|
|
13
|
+
input: readonly unknown[],
|
|
14
|
+
source: ContextComposition["source"],
|
|
15
|
+
leafId: string | null,
|
|
16
|
+
): ContextComposition {
|
|
17
|
+
const result: ContextComposition = {
|
|
18
|
+
source,
|
|
19
|
+
leafId,
|
|
20
|
+
totalMessages: input.length,
|
|
21
|
+
messages: [],
|
|
22
|
+
segments: [],
|
|
23
|
+
incomplete: false,
|
|
24
|
+
};
|
|
25
|
+
const occurrences = new Map<string, number>();
|
|
26
|
+
let budget = 1048576;
|
|
27
|
+
for (const message of input) {
|
|
28
|
+
if (result.segments.length >= 4000 || budget <= 64) {
|
|
29
|
+
result.incomplete = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
const raw = capture(message, Math.min(8192, budget));
|
|
33
|
+
const encoded = JSON.stringify(raw);
|
|
34
|
+
budget -= encoded.length;
|
|
35
|
+
result.incomplete ||= raw.truncated;
|
|
36
|
+
const data = object(raw.value);
|
|
37
|
+
const role = typeof data?.role === "string" ? data.role : "unknown";
|
|
38
|
+
const index = result.messages.length;
|
|
39
|
+
result.messages.push(raw);
|
|
40
|
+
const hash = createHash("sha256").update(encoded).digest("hex").slice(0, 20);
|
|
41
|
+
const occurrence = occurrences.get(hash) ?? 0;
|
|
42
|
+
occurrences.set(hash, occurrence + 1);
|
|
43
|
+
const content = data?.content === "" && data?.sections ? data.sections : data?.content;
|
|
44
|
+
const blocks = Array.isArray(content) && content.length ? content : [content ?? data?.sections ?? raw.value];
|
|
45
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
46
|
+
if (result.segments.length >= 4000) {
|
|
47
|
+
result.incomplete = true;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const item = object(block);
|
|
51
|
+
const kind = typeof item?.type === "string" ? item.type : role;
|
|
52
|
+
const category: ContextSegment["category"] =
|
|
53
|
+
role === "system" || role === "developer"
|
|
54
|
+
? "system"
|
|
55
|
+
: role === "user"
|
|
56
|
+
? "user"
|
|
57
|
+
: role === "toolResult" || role === "tool"
|
|
58
|
+
? "toolResult"
|
|
59
|
+
: role === "assistant" && kind === "toolCall"
|
|
60
|
+
? "toolCall"
|
|
61
|
+
: role === "assistant"
|
|
62
|
+
? "assistant"
|
|
63
|
+
: "other";
|
|
64
|
+
const preview = item?.name ?? item?.text ?? item?.thinking ?? block;
|
|
65
|
+
const timestamp =
|
|
66
|
+
typeof data?.timestamp === "string"
|
|
67
|
+
? data.timestamp
|
|
68
|
+
: typeof data?.timestamp === "number" &&
|
|
69
|
+
Number.isFinite(data.timestamp) &&
|
|
70
|
+
Math.abs(data.timestamp) < 8640000000000000
|
|
71
|
+
? new Date(data.timestamp).toISOString()
|
|
72
|
+
: undefined;
|
|
73
|
+
result.segments.push({
|
|
74
|
+
id: `${source}-${hash}-${occurrence}-${blockIndex}`,
|
|
75
|
+
position: result.segments.length + 1,
|
|
76
|
+
messageIndex: index,
|
|
77
|
+
...(Array.isArray(content) && content.length ? { blockIndex } : {}),
|
|
78
|
+
role,
|
|
79
|
+
kind,
|
|
80
|
+
category,
|
|
81
|
+
preview: (typeof preview === "string" ? preview : JSON.stringify(preview)).slice(0, 240),
|
|
82
|
+
...(timestamp ? { timestamp } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
result.incomplete ||= result.messages.length < input.length;
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
export function sessionContext(manager: ExtensionContext["sessionManager"], index: EntryIndex): ContextComposition {
|
|
90
|
+
const leafId = recordedLeaf(manager, index.duplicates);
|
|
91
|
+
const validation = leafId ? ancestry(manager, leafId, index) : { path: [], issue: "No unambiguous active leaf" };
|
|
92
|
+
if (validation.issue)
|
|
93
|
+
return {
|
|
94
|
+
source: "session-derived",
|
|
95
|
+
leafId,
|
|
96
|
+
totalMessages: 0,
|
|
97
|
+
messages: [],
|
|
98
|
+
segments: [],
|
|
99
|
+
incomplete: true,
|
|
100
|
+
unavailable: validation.issue,
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
return captureContext(buildSessionProjection(validation.path, leafId).messages, "session-derived", leafId);
|
|
104
|
+
} catch {
|
|
105
|
+
return {
|
|
106
|
+
source: "session-derived",
|
|
107
|
+
leafId,
|
|
108
|
+
totalMessages: 0,
|
|
109
|
+
messages: [],
|
|
110
|
+
segments: [],
|
|
111
|
+
incomplete: true,
|
|
112
|
+
unavailable: "Native context projection unavailable",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Call } from "./model.js";
|
|
2
|
+
|
|
3
|
+
// Occurrence IDs form the captured execution graph. Raw IDs are only used with an owning assistant anchor.
|
|
4
|
+
export function correlatedCalls(rawIds: Set<string>, anchor: string | undefined, all: Call[]): Call[] {
|
|
5
|
+
if (!anchor) return [];
|
|
6
|
+
const multiplicity = new Map<string, number>();
|
|
7
|
+
for (const call of all)
|
|
8
|
+
if (call.branchAnchor === anchor) multiplicity.set(call.id, (multiplicity.get(call.id) ?? 0) + 1);
|
|
9
|
+
const ids = new Set(
|
|
10
|
+
all
|
|
11
|
+
.filter(
|
|
12
|
+
(call) =>
|
|
13
|
+
(rawIds.has(call.id) ||
|
|
14
|
+
(call.parentId !== undefined &&
|
|
15
|
+
rawIds.has(call.parentId) &&
|
|
16
|
+
(!call.parentOccurrenceId || !all.some((parent) => parent.occurrenceId === call.parentOccurrenceId)))) &&
|
|
17
|
+
call.branchAnchor === anchor &&
|
|
18
|
+
(!call.parentOccurrenceId || !all.some((parent) => parent.occurrenceId === call.parentOccurrenceId)) &&
|
|
19
|
+
!call.correlationUnavailable &&
|
|
20
|
+
(multiplicity.get(call.id) ?? 0) < 2,
|
|
21
|
+
)
|
|
22
|
+
.map((call) => call.occurrenceId),
|
|
23
|
+
);
|
|
24
|
+
for (let i = 0; i < all.length; i++)
|
|
25
|
+
for (const call of all) if (call.parentOccurrenceId && ids.has(call.parentOccurrenceId)) ids.add(call.occurrenceId);
|
|
26
|
+
return all.filter((call) => ids.has(call.occurrenceId));
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// Rebuilt only at structural boundaries, never once per inline request.
|
|
4
|
+
export class EntryIndex {
|
|
5
|
+
readonly byId = new Map<string, SessionEntry>();
|
|
6
|
+
readonly duplicates = new Set<string>();
|
|
7
|
+
constructor(readonly entries: SessionEntry[]) {
|
|
8
|
+
for (const entry of entries)
|
|
9
|
+
if (typeof entry?.id === "string") {
|
|
10
|
+
if (this.byId.has(entry.id)) this.duplicates.add(entry.id);
|
|
11
|
+
this.byId.set(entry.id, entry);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
get(id: string): SessionEntry | undefined {
|
|
15
|
+
return this.duplicates.has(id) ? undefined : this.byId.get(id);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Json } from "./model.js";
|
|
3
|
+
import { capture } from "./privacy.js";
|
|
4
|
+
|
|
5
|
+
/** Summarize only an already redacted, bounded display copy. */
|
|
6
|
+
export function readableSummary(entry: SessionEntry): { summary: string; summaryTruncated?: true } {
|
|
7
|
+
const source =
|
|
8
|
+
entry.type === "message"
|
|
9
|
+
? "content" in entry.message
|
|
10
|
+
? entry.message.content
|
|
11
|
+
: undefined
|
|
12
|
+
: entry.type === "custom_message"
|
|
13
|
+
? entry.content
|
|
14
|
+
: entry.type === "compaction" || entry.type === "branch_summary"
|
|
15
|
+
? entry.summary
|
|
16
|
+
: undefined;
|
|
17
|
+
if (source === undefined || source === null) return { summary: "" };
|
|
18
|
+
const captured = capture(source, 1024);
|
|
19
|
+
const text = (value: Json): string => {
|
|
20
|
+
if (typeof value === "string") return value;
|
|
21
|
+
if (!value || typeof value !== "object") return "";
|
|
22
|
+
if (Array.isArray(value)) return value.map(text).filter(Boolean).join(" · ");
|
|
23
|
+
if (typeof value.text === "string") return value.text;
|
|
24
|
+
if (value.type === "thinking") return "Thinking";
|
|
25
|
+
if (value.type === "image") return "Image";
|
|
26
|
+
if (value.type === "toolCall") return `${value.name ?? "Tool call"} ${JSON.stringify(value.arguments ?? {})}`;
|
|
27
|
+
return "";
|
|
28
|
+
};
|
|
29
|
+
const summary = text(captured.value).replace(/\s+/g, " ").trim();
|
|
30
|
+
return {
|
|
31
|
+
summary: summary.slice(0, 180),
|
|
32
|
+
summaryTruncated: captured.truncated || summary.length > 180 ? true : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|