From ab4af20ab24cfa668c8f48384f654e9e41d9e407 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 2 Aug 2026 20:47:11 -0600 Subject: [PATCH] fix(pi): locate custom session directories exactly --- README.md | 1 + package.json | 8 +-- pnpm-lock.yaml | 107 ++++++++++++++++++----------------------- src/adapters/pi.ts | 91 +++++++++++++++++++++++++---------- src/index.ts | 1 + tests/adapters.test.ts | 93 +++++++++++++++++++++++++++++++++++ tests/sdk.test.ts | 2 +- 7 files changed, 211 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 5235adc..c97071e 100644 --- a/README.md +++ b/README.md @@ -645,6 +645,7 @@ The CLI is a thin consumer of these exports. | `redactSpans` | `(spans, rules?) → { spans, report }` | PII/secret redaction (`TRACES_REDACTION_RULES`) | | `planUpload` / `executeUpload` | `(…, { backend? }) → …` | redact + dedup + send to any sink | | `selectAdapters` / `listAdapters` / `resolveAdapter` | adapter selection + the harness registry | +| `PiAdapter` | `new PiAdapter({ sessionsRoot? })` | parse Pi's default session tree or an exact `PI_CODING_AGENT_SESSION_DIR` | | `HarnessTraceAdapter` | interface (`locate` + `parse`) | implement to add a harness | | `ExternalAnalyzer` / `Redactor` | `haloAnalyzer` / `hodoscopeAnalyzer` / `commandAnalyzer` / `commandRedactor` | drive engines/models you install | diff --git a/package.json b/package.json index d36263f..e8aa1bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/traces", - "version": "0.11.3", + "version": "0.11.4", "description": "Point it at your coding-agent session traces (Claude Code, Codex, OpenCode, Gemini, Pi, …) and get failure-mode + efficiency findings. CLI + SDK over the @tangle-network/agent-eval analyst suite — observe live sessions, run your own analysts, redact, and upload to the Tangle Intelligence Platform.", "type": "module", "license": "MIT", @@ -59,10 +59,10 @@ "prepublishOnly": "pnpm check:source && pnpm build && pnpm check:package" }, "dependencies": { - "@tangle-network/agent-eval": "0.140.1", - "@tangle-network/agent-runtime": "0.121.0", + "@tangle-network/agent-eval": "0.142.2", + "@tangle-network/agent-runtime": "0.123.0", "@tangle-network/agent-trace-contract": "^1.0.2", - "@tangle-network/sandbox": "0.16.0" + "@tangle-network/sandbox": "0.17.2" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59ec0a2..ed85356 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,17 +14,17 @@ importers: .: dependencies: '@tangle-network/agent-eval': - specifier: 0.140.1 - version: 0.140.1 + specifier: 0.142.2 + version: 0.142.2 '@tangle-network/agent-runtime': - specifier: 0.121.0 - version: 0.121.0(@tangle-network/agent-eval@0.140.1)(@tangle-network/agent-interface@0.40.0)(@tangle-network/sandbox@0.16.0) + specifier: 0.123.0 + version: 0.123.0(@tangle-network/agent-eval@0.142.2)(@tangle-network/agent-interface@0.43.0)(@tangle-network/sandbox@0.17.2) '@tangle-network/agent-trace-contract': specifier: ^1.0.2 version: 1.0.2 '@tangle-network/sandbox': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.17.2 + version: 0.17.2 devDependencies: '@types/node': specifier: ^22.0.0 @@ -495,41 +495,35 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tangle-network/agent-core@0.4.27': - resolution: {integrity: sha512-gD3z0p+h8/XB2/vWQF6/RtKTLcoJOSrB85+ndYyX/3/l0pkUuVBTjm/RISiQBJyNVozgZz5iqhIV2O136keclA==} + '@tangle-network/agent-core@0.4.33': + resolution: {integrity: sha512-if3DeIo4e2c9wELJjrWOu4SsKv3WnWdDrp+kezP6JrpiQ1DXValRRlN/aTppicFHepw67OD5JQI9i351Z/hvDQ==} - '@tangle-network/agent-core@0.4.29': - resolution: {integrity: sha512-8RaZSTYU/HXZt48Mr0KfwLla0j3K6EH8H8xc0lI5qFggAMsNBic8Tk951CnFaNX680VSXGOma499zS1NgowqfA==} - - '@tangle-network/agent-eval@0.140.1': - resolution: {integrity: sha512-fuC+2haGKI93z96mcTcSumsFa4gIsuOOqA3BCeeLPQkasoSLgej3aJ6yDMim0vvbyDhtaW6jYSQ+fdGSPYnqjg==} + '@tangle-network/agent-eval@0.142.2': + resolution: {integrity: sha512-uejOBoCarTEOh520Zz5JzHkRN3YXqDZnsjEwGLF22WUnpbxzSSLUC4AuuAIOUPsvsI5kjs/tyR0lp6ad/H7P+g==} engines: {node: '>=20'} hasBin: true - '@tangle-network/agent-interface@0.38.0': - resolution: {integrity: sha512-4awwTtCeUyCAOlEqr4SKyqbj7ykvoRob6GXe9ZRk6KglTgnAU6VuEoMu1KudVvEYjmTHjBCCIHa3yL1FKgKJaA==} - - '@tangle-network/agent-interface@0.40.0': - resolution: {integrity: sha512-4A6+bb5Ub22WJsVQRNuNIr6BSsfQG4ZZT436GZ2qUcZocjJ7186MEbN7kOmYq9TQ9qeMSUh/HbyYw2nsok5b+w==} + '@tangle-network/agent-interface@0.43.0': + resolution: {integrity: sha512-t35nGOJ3sWouHoUM/5A8eUmsR+8JcuRF6VFQbT9cPzxIMUV3NsFMJgYXPGmyIj65vtvs73O6Mvijp47FAAGbuQ==} - '@tangle-network/agent-knowledge@7.0.4': - resolution: {integrity: sha512-/7FBk6OI14I/L7kc4sDmFwR7hDyndKajOjeQGdP6iSiPE/q8DldQ5UNSjJlUTeMfE3YBmLZe1/IuJEuiaqtzvg==} + '@tangle-network/agent-knowledge@7.0.7': + resolution: {integrity: sha512-h0ukEgQsRd5zUhM8ibVLaHAssBKjcFQTZny9UUP+frL+ID8JtukH9MQSGjHlkKxbeqfA2MOVU6QCU2bcyt5OBw==} engines: {node: '>=20.19.0'} hasBin: true - '@tangle-network/agent-profile-materialize@0.9.4': - resolution: {integrity: sha512-LCKfohFcjzeDz+WrPx6lXyZvn0CYyw0Y3/sBCaJ6fqj2Sgg7KwG178eg/LhlqNPz6xJzBh0g4iHxPge1kNn4nw==} + '@tangle-network/agent-profile-materialize@0.10.2': + resolution: {integrity: sha512-u3MtUy8BD5odTWu/nK0b2XuieBSZMMx1kR9izwqIoR+c8pQaIRQiENNms9qeyQFLqqm7ewC1/Qq5oTtuihs/5g==} peerDependencies: - '@tangle-network/agent-interface': '>=0.38.0 <0.41.0' + '@tangle-network/agent-interface': '>=0.38.0 <0.44.0' - '@tangle-network/agent-runtime@0.121.0': - resolution: {integrity: sha512-gx0y/L3izLFbuqyUDPscQJyqRQmC3owXShTKRywEiCStio7gf/A+6FSjO3epzCsv+L1RWwoHjPEUZmOiuYCM+A==} + '@tangle-network/agent-runtime@0.123.0': + resolution: {integrity: sha512-7O1IkrtT8PYOsg5Y7yeTTATTfBv+C2+PEx4Mo5ksj4GptFCiJKGX0U4GZZ9UTKn+g8mgB7dq+XOz4ccF7mcGJA==} engines: {node: '>=22.13.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.140.1 <0.141.0' - '@tangle-network/agent-interface': '>=0.40.0 <0.41.0' - '@tangle-network/sandbox': '>=0.16.0 <0.17.0' + '@tangle-network/agent-eval': '>=0.142.2 <0.143.0' + '@tangle-network/agent-interface': '>=0.43.0 <0.44.0' + '@tangle-network/sandbox': '>=0.17.2 <0.18.0' playwright: ^1.40.0 peerDependenciesMeta: '@tangle-network/sandbox': @@ -540,8 +534,8 @@ packages: '@tangle-network/agent-trace-contract@1.0.2': resolution: {integrity: sha512-v7uMh56jkEp4vckevEU9xKsIatbs5dqzGPp69dFLSSXUVit0RP6VD6EANMXVlTCUk+6wVKBLHJx23XspVCEiIA==} - '@tangle-network/sandbox@0.16.0': - resolution: {integrity: sha512-4L2Xvmy/9i9udI/ISV0YuB6/f6i5OICIvkiw/cKx1lxDNbnEIkk8l2X4OReGjUkPvFSI8TnOD7Gq7LADlYdmeQ==} + '@tangle-network/sandbox@0.17.2': + resolution: {integrity: sha512-e+p/Uet2nVej3pLLCOoxeRN29t38QfWEWpcnnJsC2b0BNsQEPUg6xNlEgdeT9Eo0U6++QyR+GUrDovJ/houd2g==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.29.0 @@ -1447,59 +1441,50 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tangle-network/agent-core@0.4.27': - dependencies: - '@tangle-network/agent-interface': 0.38.0 - zod: 4.4.3 - - '@tangle-network/agent-core@0.4.29': + '@tangle-network/agent-core@0.4.33': dependencies: - '@tangle-network/agent-interface': 0.40.0 + '@tangle-network/agent-interface': 0.43.0 zod: 4.4.3 - '@tangle-network/agent-eval@0.140.1': + '@tangle-network/agent-eval@0.142.2': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) '@hono/node-server': 2.0.12(hono@4.12.32) - '@tangle-network/agent-core': 0.4.29 - '@tangle-network/agent-interface': 0.40.0 + '@tangle-network/agent-core': 0.4.33 + '@tangle-network/agent-interface': 0.43.0 + '@tangle-network/agent-trace-contract': 1.0.2 hono: 4.12.32 linear-sum-assignment: 1.0.9 re2js: 2.8.6 zod: 4.4.3 - '@tangle-network/agent-interface@0.38.0': - dependencies: - '@noble/hashes': 1.8.0 - spdx-expression-parse: 5.0.0 - zod: 4.4.3 - - '@tangle-network/agent-interface@0.40.0': + '@tangle-network/agent-interface@0.43.0': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@7.0.4': + '@tangle-network/agent-knowledge@7.0.7': dependencies: - '@tangle-network/agent-eval': 0.140.1 - '@tangle-network/agent-interface': 0.40.0 + '@tangle-network/agent-eval': 0.142.2 + '@tangle-network/agent-interface': 0.43.0 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.9.4(@tangle-network/agent-interface@0.40.0)': + '@tangle-network/agent-profile-materialize@0.10.2(@tangle-network/agent-interface@0.43.0)': dependencies: - '@tangle-network/agent-interface': 0.40.0 + '@tangle-network/agent-interface': 0.43.0 - '@tangle-network/agent-runtime@0.121.0(@tangle-network/agent-eval@0.140.1)(@tangle-network/agent-interface@0.40.0)(@tangle-network/sandbox@0.16.0)': + '@tangle-network/agent-runtime@0.123.0(@tangle-network/agent-eval@0.142.2)(@tangle-network/agent-interface@0.43.0)(@tangle-network/sandbox@0.17.2)': dependencies: - '@tangle-network/agent-eval': 0.140.1 - '@tangle-network/agent-interface': 0.40.0 - '@tangle-network/agent-knowledge': 7.0.4 - '@tangle-network/agent-profile-materialize': 0.9.4(@tangle-network/agent-interface@0.40.0) + '@tangle-network/agent-eval': 0.142.2 + '@tangle-network/agent-interface': 0.43.0 + '@tangle-network/agent-knowledge': 7.0.7 + '@tangle-network/agent-profile-materialize': 0.10.2(@tangle-network/agent-interface@0.43.0) + '@tangle-network/agent-trace-contract': 1.0.2 tar-stream: 3.2.0 optionalDependencies: - '@tangle-network/sandbox': 0.16.0 + '@tangle-network/sandbox': 0.17.2 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -1507,10 +1492,10 @@ snapshots: '@tangle-network/agent-trace-contract@1.0.2': {} - '@tangle-network/sandbox@0.16.0': + '@tangle-network/sandbox@0.17.2': dependencies: - '@tangle-network/agent-core': 0.4.27 - '@tangle-network/agent-interface': 0.38.0 + '@tangle-network/agent-core': 0.4.33 + '@tangle-network/agent-interface': 0.43.0 zod: 4.4.3 '@tybys/wasm-util@0.10.3': diff --git a/src/adapters/pi.ts b/src/adapters/pi.ts index 53ee298..309f867 100644 --- a/src/adapters/pi.ts +++ b/src/adapters/pi.ts @@ -1,5 +1,6 @@ /** - * Pi adapter — `~/.pi/agent/sessions//_.jsonl`. + * Pi adapter — the default `~/.pi/agent/sessions//_.jsonl` + * layout or a caller-supplied session directory containing JSONL files directly. * * Line types: `session` (id + cwd), `model_change`, `thinking_level_change`, * and `message`. A `message` line wraps `message.{role, model, provider, @@ -9,12 +10,13 @@ * separate `role: "toolResult"` messages keyed by `message.toolCallId`. */ +import type { Dirent } from 'node:fs' import { readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, resolve } from 'node:path' import { sessionJsonlOptions } from '../integrity.js' import { isMissingPathError } from '../json.js' -import { readJsonl } from '../jsonl.js' +import { readJsonl, takeJsonl } from '../jsonl.js' import type { OtlpSpan } from '../otlp.js' import { span } from '../otlp.js' import type { HarnessTraceAdapter, LocateOptions, ParseOptions, SessionRef } from '../types.js' @@ -144,49 +146,86 @@ function completeToolSpan(toolSpan: OtlpSpan, result: PiToolResult): void { recordToolOutput(toolSpan, result.output) } +export interface PiAdapterOptions { + /** Pi's effective session directory, including PI_CODING_AGENT_SESSION_DIR overrides. */ + sessionsRoot?: string +} + export class PiAdapter implements HarnessTraceAdapter { readonly harness = 'pi' + private readonly sessionsRoot: string + + constructor(options: PiAdapterOptions = {}) { + this.sessionsRoot = resolve(options.sessionsRoot ?? join(homedir(), '.pi', 'agent', 'sessions')) + } private root(): string { - return join(homedir(), '.pi', 'agent', 'sessions') + return this.sessionsRoot } async locate(opts: LocateOptions = {}): Promise { const root = this.root() - let dirs: string[] + let entries: Dirent[] try { - dirs = await readdir(root) + entries = await readdir(root, { withFileTypes: true }) } catch (error) { if (isMissingPathError(error)) return [] throw error } - const refs: SessionRef[] = [] - for (const dir of dirs) { - const dp = join(root, dir) + const paths: string[] = [] + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + paths.push(join(root, entry.name)) + continue + } + if (!entry.isDirectory()) continue + const dir = join(root, entry.name) let files: string[] try { - files = await readdir(dp) + files = await readdir(dir) } catch (error) { if (isMissingPathError(error)) continue throw error } - // Encoded cwd: leading/trailing `--`, separators as `-`. - const cwd = `/${dir.replace(/^-+/, '').replace(/-+$/, '').replace(/-/g, '/')}` - if (opts.cwd && !cwd.startsWith(opts.cwd)) continue - for (const f of files) { - if (!f.endsWith('.jsonl')) continue - const path = join(dp, f) - let st: Awaited> - try { - st = await stat(path) - } catch (error) { - if (isMissingPathError(error)) continue - throw error - } - if (opts.sinceMs && st.mtimeMs < opts.sinceMs) continue - const id = basename(f, '.jsonl').replace(/^[\dTZ.-]+_/, '') - refs.push({ harness: this.harness, sessionId: id, path, cwd, mtimeMs: st.mtimeMs }) + for (const file of files) { + if (file.endsWith('.jsonl')) paths.push(join(dir, file)) + } + } + const refs: SessionRef[] = [] + for (const path of paths) { + const f = basename(path) + let st: Awaited> + try { + st = await stat(path) + } catch (error) { + if (isMissingPathError(error)) continue + throw error + } + if (opts.sinceMs && st.mtimeMs < opts.sinceMs) continue + let id = basename(f, '.jsonl').replace(/^[\dTZ.-]+_/, '') + let cwd: string | null = null + const ref: SessionRef = { + harness: this.harness, + sessionId: id, + path, + cwd, + mtimeMs: st.mtimeMs, + } + const [session] = await takeJsonl(path, 1, sessionJsonlOptions(ref)) + if (session?.type === 'session') { + if (session.id) id = session.id + if (session.cwd) cwd = session.cwd + } + ref.sessionId = id + ref.cwd = cwd + if (ref.integrity) { + ref.integrity.corruptions = ref.integrity.corruptions.map((receipt) => ({ + ...receipt, + sessionId: id, + })) } + if (opts.cwd && (!cwd || !cwd.startsWith(opts.cwd))) continue + refs.push(ref) } return refs.sort((a, b) => b.mtimeMs - a.mtimeMs) } diff --git a/src/index.ts b/src/index.ts index 171296f..625ad67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ export { GeminiAdapter } from './adapters/gemini.js' export { QwenAdapter } from './adapters/qwen.js' export { FactoryAdapter } from './adapters/factory.js' export { PiAdapter } from './adapters/pi.js' +export type { PiAdapterOptions } from './adapters/pi.js' export { AmpAdapter } from './adapters/amp.js' export { CopilotAdapter } from './adapters/copilot.js' export { ForgeAdapter } from './adapters/forge.js' diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts index 8329810..70e6949 100644 --- a/tests/adapters.test.ts +++ b/tests/adapters.test.ts @@ -3067,6 +3067,99 @@ describe('codex cwd recovery — continuation sessions', () => { }) }) +describe('pi cwd recovery', () => { + it('reads a native custom session directory without requiring encoded-cwd folders', async () => { + const sessions = mkdtempSync(join(tmpdir(), 'tt-pi-custom-sessions-')) + const path = join(sessions, '2026-08-02T00-00-00_custom.jsonl') + writeFileSync( + path, + `${JSON.stringify({ + type: 'session', + id: 'pi-custom-session-dir', + timestamp: '2026-08-02T00:00:00Z', + cwd: '/tmp/custom-pi-workspace', + })}\n`, + ) + + const refs = await new PiAdapter({ sessionsRoot: sessions }).locate({ + cwd: '/tmp/custom-pi-workspace', + }) + + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ + sessionId: 'pi-custom-session-dir', + path, + cwd: '/tmp/custom-pi-workspace', + }) + }) + + it('uses the recorded cwd instead of Pi\'s lossy hyphenated directory name', async () => { + const home = mkdtempSync(join(tmpdir(), 'tt-pi-home-')) + const sessions = join(home, '.pi', 'agent', 'sessions', '--home-u-code-tangle-router--') + mkdirSync(sessions, { recursive: true }) + const path = join(sessions, '2026-08-02T00-00-00_session.jsonl') + writeFileSync( + path, + `${JSON.stringify({ + type: 'session', + id: 'pi-hyphenated-cwd', + timestamp: '2026-08-02T00:00:00Z', + cwd: '/home/u/code/tangle-router', + })}\n`, + ) + + const refs = await withEnv('HOME', home, () => + new PiAdapter().locate({ cwd: '/home/u/code/tangle-router' }), + ) + + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ + sessionId: 'pi-hyphenated-cwd', + path, + cwd: '/home/u/code/tangle-router', + }) + }) + + it('does not match the ambiguous directory-name decoding', async () => { + const home = mkdtempSync(join(tmpdir(), 'tt-pi-home-')) + const sessions = join(home, '.pi', 'agent', 'sessions', '--home-u-code-tangle-router--') + mkdirSync(sessions, { recursive: true }) + writeFileSync( + join(sessions, '2026-08-02T00-00-00_session.jsonl'), + `${JSON.stringify({ + type: 'session', + id: 'pi-no-false-cwd', + timestamp: '2026-08-02T00:00:00Z', + cwd: '/home/u/code/tangle-router', + })}\n`, + ) + + const refs = await withEnv('HOME', home, () => + new PiAdapter().locate({ cwd: '/home/u/code/tangle/router' }), + ) + + expect(refs).toHaveLength(0) + }) + + it('does not admit a session with no recorded cwd when a cwd filter is requested', async () => { + const sessions = mkdtempSync(join(tmpdir(), 'tt-pi-custom-sessions-')) + writeFileSync( + join(sessions, '2026-08-02T00-00-00_session.jsonl'), + `${JSON.stringify({ + type: 'session', + id: 'pi-missing-cwd', + timestamp: '2026-08-02T00:00:00Z', + })}\n`, + ) + + const refs = await new PiAdapter({ sessionsRoot: sessions }).locate({ + cwd: '/tmp/requested-pi-workspace', + }) + + expect(refs).toHaveLength(0) + }) +}) + // Gemini/Qwen-family sessions live at tmp//chats/; for a *registered* // project the hash IS the project name, reversible via /projects.json → // recover cwd. (Unregistered/digest dirs stay null — unavoidable.) diff --git a/tests/sdk.test.ts b/tests/sdk.test.ts index 09461a0..b7f9ee5 100644 --- a/tests/sdk.test.ts +++ b/tests/sdk.test.ts @@ -304,7 +304,7 @@ describe('analyzeSpans (bring-your-own analysts)', () => { it('retains token findings when every paired transition supports the stated trend', async () => { const { result } = await analyzeSpans( - tokenTrajectorySpans([100, 200, 300, 400], [400, 300, 200, 100]), + tokenTrajectorySpans([100, 200, 300, 400, 500], [500, 400, 300, 200, 100]), ) const subjects = result.findings.map((finding) => finding.subject) expect(subjects).toContain('monotonic-input-growth')