diff --git a/README.md b/README.md index 7a5a3604..282656e4 100644 --- a/README.md +++ b/README.md @@ -389,13 +389,15 @@ surface. ### More adapters Beyond the hosts above, Better Harness also supports Qwen Code, Pi, Kimi Code, -WorkBuddy, and Grok. Their exact install, invocation, and evidence boundaries -live in the docs so this README stays focused: +WorkBuddy, and Grok. Their exact install, invocation, and +evidence boundaries live in the docs so this README stays focused: - **Qwen Code** — [installation guide](docs/docs/installation.mdx#qwen-code) (`qwen extensions install QoderAI/better-harness`). - **Pi** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#pi) - (`pi install ` or `pi -e `). + (`pi install ` or `pi -e `). The same adapter reads + [Oh My Pi (OMP)](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) sessions when + `PI_CODING_AGENT_DIR` points at an OMP agent directory. - **Kimi Code** — [Host Adapter Matrix](docs/adapters/README.md) (`.kimi-plugin/plugin.json` plugin install). - **WorkBuddy** — [Host Adapter Matrix](docs/docs/hosts/adapter-matrix.md#workbuddy). diff --git a/README.zh-CN.md b/README.zh-CN.md index 0dc63871..67e6f636 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -369,7 +369,9 @@ Grok。它们确切的安装、调用与证据边界都放在文档里,以保 - **Qwen Code** —— [安装指南](docs/docs/installation.mdx#qwen-code) (`qwen extensions install QoderAI/better-harness`)。 - **Pi** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#pi) - (`pi install ` 或 `pi -e `)。 + (`pi install ` 或 `pi -e `)。当 `PI_CODING_AGENT_DIR` 指向 OMP + 的 agent 目录时,同一适配器也能读取 + [Oh My Pi (OMP)](docs/docs/hosts/adapter-matrix.md#oh-my-pi-omp) 会话。 - **Kimi Code** —— [宿主适配器矩阵](docs/adapters/README.md) (`.kimi-plugin/plugin.json` 插件安装)。 - **WorkBuddy** —— [宿主适配器矩阵](docs/docs/hosts/adapter-matrix.md#workbuddy)。 diff --git a/docs/adapters/README.md b/docs/adapters/README.md index dce2604c..43903bde 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -166,6 +166,19 @@ edit host settings, or register an `apply` path. discovers the canonical root `skills/` directory and the `prompts/` templates through the `pi` manifest in `package.json`; that manifest is install/discovery metadata and does not own Pi evidence collection. +- Oh My Pi (OMP) is a session layout of the `pi` platform, not a host of its own: + it has no host id, capability profile, install shell, or lifecycle target here. + Pointing `PI_CODING_AGENT_DIR` at `~/.omp/agent` makes both the Pi + configured-asset provider and `scripts/session-analysis/platforms/pi.mjs` read + that tree. OMP names session directories relative to the home directory + (`-src-dotai` for `~/src/dotai`) alongside pi's absolute `----` form, and + prefixes its JSONL transcripts with a `title` record before the session header; + the adapter skips that preamble while still rejecting any other pre-header + record fail-closed. An OMP `/fork` transcript carries a copy of its parent's + entries and a `parentSession` id: those entries are attributed to the parent + only when the parent session is discovered in the same result, so they are + counted exactly once and never dropped silently. Boundaries and acceptance + scenarios live in `docs/specs/2026-09-04-omp-session-format-compat.md`. - Kimi Code configured assets are inventoried through `scripts/agent-customize/providers/kimi.mjs`: user-level `~/.kimi-code/skills/**/SKILL.md` and `~/.kimi-code/mcp.json`, plus diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 7c38dd57..98015929 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -37,7 +37,7 @@ remains the complete capability-level source of truth. | Cursor | Verified Quickstart | Canvas-capable source-local host | `.cursor-plugin/` | Workspace-matched transcripts, metadata, audit logs, and optional native Context Usage snapshots; partial coverage stays explicit | Cursor Canvas report | | Qwen Code | Verified Quickstart | Analysis-capable source-local host | `qwen-extension.json` | Workspace-matching local Qwen transcripts when present | Self-contained HTML + Markdown | | GitHub Copilot | Verified Quickstart | Analysis-capable source-local host | `.github/plugin/` | Workspace-matched Copilot CLI transcripts; partial coverage stays explicit | Self-contained HTML + Markdown | -| Pi | Adapter support | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching local Pi sessions | Self-contained HTML + Markdown | +| Pi | Adapter support | Analysis-capable source-local host | `pi` manifest in `package.json` | Workspace-matching local Pi sessions, including the Oh My Pi (OMP) session layout | Self-contained HTML + Markdown | | Kimi Code | Adapter support | Analysis-capable source-local host | `.kimi-plugin/plugin.json` | Workspace-matching Kimi wire transcripts | Self-contained HTML + Markdown | | WorkBuddy | Adapter support | Analysis-capable source-local host | None; skills use WorkBuddy-owned paths | Workspace-matching WorkBuddy JSONL transcripts | Self-contained HTML + Markdown | | Grok | Adapter support | Analysis-capable source-local host | None; skills use Grok-owned paths | Workspace-matching Grok session dirs (`updates.jsonl`) | Self-contained HTML + Markdown | @@ -122,6 +122,24 @@ workspace-matched session evidence, and portable HTML routing are implemented. Pi remains outside the verified Quickstart set until a complete interactive report-loop smoke is observed. +### Oh My Pi (OMP) {#oh-my-pi-omp} + +Oh My Pi (OMP) is not a separate host adapter: it is a session layout the `pi` +platform recognizes when `PI_CODING_AGENT_DIR=~/.omp/agent` points at an OMP +agent directory. OMP has no host id, capability profile, install shell, or +lifecycle target of its own. + +OMP keys session directories on the home-relative workspace path (`~/src/dotai` → +`-src-dotai`) instead of pi's absolute `----` form, and its JSONL +transcripts start with a `title` record before the session header. The Pi adapter +reads both naming conventions and skips that preamble while keeping pi's +fail-closed header rule and workspace isolation. An OMP `/fork` transcript copies +its parent's entries; those entries count as the parent's only when the parent +session is discovered in the same result, so nothing is double-counted and nothing +silently disappears. Session evidence and portable HTML routing come from the +shared Pi provider, and configured assets are inventoried by the Pi provider +because it honors the same `PI_CODING_AGENT_DIR` override. + ### Kimi Code {#kimi-code} Kimi Code installs the repository through `/plugins install ` and the diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md index 2ccc0957..7a34c03d 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md @@ -27,7 +27,7 @@ Better Harness 当前声明了十个能力层宿主适配器,其中六个已 | Cursor | 已验证快速开始 | 支持 Canvas 的源码本地宿主 | `.cursor-plugin/` | 工作区匹配的转录、元数据、审计日志和可选原生 Context Usage 快照;部分覆盖保持显式标注 | Cursor Canvas 报告 | | Qwen Code | 已验证快速开始 | 具备分析能力的源码本地宿主 | `qwen-extension.json` | 匹配当前工作区的本地 Qwen 转录(存在时) | 自包含 HTML + Markdown | | GitHub Copilot | 已验证快速开始 | 具备分析能力的源码本地宿主 | `.github/plugin/` | 工作区匹配的 Copilot CLI 转录;部分覆盖保持显式标注 | 自包含 HTML + Markdown | -| Pi | 适配器支持 | 具备分析能力的源码本地宿主 | `package.json` 中的 `pi` manifest | 匹配当前工作区的本地 Pi 会话 | 自包含 HTML + Markdown | +| Pi | 适配器支持 | 具备分析能力的源码本地宿主 | `package.json` 中的 `pi` manifest | 匹配当前工作区的本地 Pi 会话,包含 Oh My Pi (OMP) 会话布局 | 自包含 HTML + Markdown | | Kimi Code | 适配器支持 | 具备分析能力的源码本地宿主 | `.kimi-plugin/plugin.json` | 匹配工作区的 Kimi wire 转录 | 自包含 HTML + Markdown | | WorkBuddy | 适配器支持 | 具备分析能力的源码本地宿主 | 无;Skill 使用 WorkBuddy 自有路径 | 匹配工作区的 WorkBuddy JSONL 转录 | 自包含 HTML + Markdown | | Grok | 适配器支持 | 具备分析能力的源码本地宿主 | 无;Skill 使用 Grok 自有路径 | 匹配工作区的 Grok 会话目录(`updates.jsonl`) | 自包含 HTML + Markdown | @@ -76,6 +76,20 @@ Pi 可以通过 `pi install ` 安装本仓库,或使用 `pi -e --` 形式,并且在会话头之前先写一条 `title` 记录。 +Pi 适配器同时识别两种命名约定并跳过该前置记录,同时保留 pi 的 fail-closed +会话头规则与工作区隔离。OMP 的 `/fork` 转录会复制父会话的全部条目:只有当父 +会话也在同一次发现结果中时,这些条目才计入父会话,因此既不会重复计数,也不会 +静默丢失。会话证据与可移植 HTML 路由均来自共享的 Pi provider;已配置资产也由 Pi +provider 清点,因为它遵循同一个 `PI_CODING_AGENT_DIR` 覆盖项。 + ### Kimi Code {#kimi-code} Kimi Code 通过 `/plugins install ` 和 `.kimi-plugin/plugin.json` diff --git a/docs/specs/2026-09-04-omp-session-format-compat.md b/docs/specs/2026-09-04-omp-session-format-compat.md new file mode 100644 index 00000000..fd526234 --- /dev/null +++ b/docs/specs/2026-09-04-omp-session-format-compat.md @@ -0,0 +1,110 @@ +# Spec: Oh My Pi session format compatibility in the Pi adapter + +## Traceability + +- **Spec ID:** OMP-01 +- **Story:** none; maintainer-approved review follow-up on PR #143 +- **Status:** implemented + +## Intent + +Oh My Pi (OMP) is a fork of Pi that shares the session transcript schema but +diverges in three observable ways. The Pi session adapter should read OMP session +trees when it is pointed at an OMP agent directory, without loosening any of the +workspace-isolation guarantees the Pi adapter already makes. + +OMP is not a new host adapter: it has no host id, no capability profile, no +install shell, and no lifecycle target of its own. It is a recognized session +layout for the existing `pi` platform, reached with +`PI_CODING_AGENT_DIR=~/.omp/agent`. + +## Observed OMP divergences + +| Divergence | Pi | OMP | +| --- | --- | --- | +| Session directory name | absolute slug `--Users-ooxx-src-dotai--` | home-relative slug `-src-dotai` | +| JSONL preamble | first record is the `session` header | a `title` record precedes the header | +| Session forking | not observed | `/fork` copies every parent entry into a new file carrying `parentSession` | + +## Acceptance scenarios + +- **AC-1 — Home-relative discovery.** A workspace under the home directory + resolves an additional `homeExact` variant (`~/src/dotai` → `-src-dotai`), and + a session directory with that name is discovered. +- **AC-2 — Bounded home-relative prefix.** Subdirectory session directories match + through `homePrefix` only across a separator boundary (`-src-dotai-packages-app` + matches, `-src-dotaix` does not). A workspace whose only prefix-similar sibling + has sessions reports its source root as absent. +- **AC-3 — Non-home workspaces unchanged.** A workspace outside the home + directory exposes no home-relative variant, and the absolute `exact`/`prefix` + contract is byte-identical to before. +- **AC-4 — Foreign-platform slug integrity.** A Windows-shaped workspace string + keeps its drive letter on every host (`C:\workspace\project` → + `--C--workspace-project--`) and never resolves against the host cwd. Only a + host-native absolute path is eligible for a home-relative variant. +- **AC-5 — Title preamble.** Leading `title` records are skipped when locating + the session header. Any other non-`session` record before the header still + rejects the transcript fail-closed, and a second `session` header still rejects + the whole file. +- **AC-6 — Fork deduplication.** When a fork's `parentSession` is also discovered + in the same result, entries stamped before the fork point are read as the + parent's and excluded from the fork, so each entry is counted once. +- **AC-7 — Orphan fork retention.** When the parent is not in the discovery + result, the fork keeps its inherited entries: nothing else owns them, so + dropping them would silently lose evidence. +- **AC-8 — Time range agreement.** A session's reported `firstSeen`/`lastSeen` + always covers exactly the entries it yields, whether or not the fork cutoff + applies. + +## Design + +`workspaceToPiSessionDirVariants` keeps the absolute slug derivation untouched +and adds `homeExact`/`homePrefix` when the workspace is a host-native absolute +path under the home directory. Slug normalization and home-relative derivation +read different inputs on purpose: the slug body keeps `path.win32` handling so a +Windows-shaped workspace string survives on a POSIX host, while the +home-relative body needs the host-native path because a foreign-platform path can +never be home-relative. + +`probeTranscript` records `parentSessionId`, `forkTimestamp`, and a second +"owned" time range that excludes inherited entries. `discoverSessions` collects +every probe first, then decides per file whether the fork cutoff applies, because +the parent may be probed after its fork. The cutoff is stored on the source ref +rather than the session, so a session with several transcripts cannot leak one +file's cutoff onto another. `isInheritedForkEntry` is shared by discovery and +`readSession` so the reported time range and the yielded events cannot disagree. + +## Non-goals + +- Adding OMP to `scripts/host-support` as a distinct host id or capability + profile. +- An OMP install shell, plugin manifest, or npm-packaged host artifact. +- Promoting OMP into the verified Quickstart set. +- Detecting an OMP agent directory automatically; the operator points at it with + `PI_CODING_AGENT_DIR`. + +## Risks + +- The `parentSession` field name and the header-timestamp-as-fork-point rule come + from observed OMP transcripts, not a published schema. If OMP renames the field, + fork deduplication silently stops applying and forks report inherited entries + again — the fail-open direction, which over-counts rather than loses evidence. +- Fork deduplication compares timestamps. An inherited entry without a timestamp, + or one sharing the fork millisecond, stays with the fork. + +## Test evidence + +`test/sessions/session-analysis-providers.test.mjs`: + +- `Pi derives OMP home-relative session directory names with a bounded prefix` (AC-1, AC-2, AC-3) +- `Pi discovers OMP home-relative session directories and skips the title preamble` (AC-1, AC-5) +- `Pi keeps a sibling OMP workspace out of the home-relative prefix match` (AC-2) +- `Pi discovers OMP subdirectory session dirs under the home-relative prefix` (AC-2) +- `Pi counts OMP inherited fork entries once when the parent session is discovered` (AC-6, AC-8) +- `Pi retains OMP inherited fork entries when the parent session is absent` (AC-7, AC-8) +- `Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts` (AC-4) +- `Pi provider requires one authoritative first session header` (AC-5) + +Each new assertion was mutation-checked: reverting the prefix boundary, the +`path.win32` slug handling, or the parent-existence condition fails exactly the +test that covers it. diff --git a/scripts/session-analysis/platforms/pi.mjs b/scripts/session-analysis/platforms/pi.mjs index 4918f4a1..6d220e61 100644 --- a/scripts/session-analysis/platforms/pi.mjs +++ b/scripts/session-analysis/platforms/pi.mjs @@ -33,18 +33,51 @@ function isScopedWorkspaceMatch(candidate, scope) { return classifyWorkspaceCwd(candidate, scope._workspaceMatchScope) !== WORKSPACE_CWD_MATCH.UNMATCHED; } -export function workspaceToPiSessionDirVariants(workspace) { +// A Windows-shaped workspace string stays a Windows path on every host, so the +// slug keeps its drive letter instead of being resolved against the POSIX cwd. +function normalizeWorkspaceForSlug(workspace) { const expanded = expandHome(workspace ?? process.cwd()); - const normalized = path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); + return path.win32.isAbsolute(expanded) ? path.win32.normalize(expanded) : normalizeWorkspace(expanded); +} + +// OMP (Oh My Pi) names session directories relative to the home directory: one +// leading "-" plus the home-relative path with separators folded to "-", e.g. +// ~/src/dotai → "-src-dotai" instead of pi's "--Users-ooxx-src-dotai--". +// This reads the host-native path rather than the slug form, because only a +// host-native absolute path can be home-relative at all: a foreign-platform +// workspace string would resolve against the host cwd and invent a false body. +function homeRelativeSlugBody(workspace) { + const expanded = expandHome(workspace ?? process.cwd()); + const home = expandHome("~"); + if (typeof home !== "string" || !path.isAbsolute(home) || !path.isAbsolute(expanded)) return null; + const relative = path.relative(home, path.resolve(expanded)); + // Compare whole segments: a directory legitimately named "..cache" is inside + // the home directory, while ".." and "../" escape it. + const outsideHome = relative === ".." || relative.startsWith(`..${path.sep}`); + if (!relative || outsideHome || path.isAbsolute(relative)) return null; + return relative.replace(/[/\\:]/g, "-"); +} + +export function workspaceToPiSessionDirVariants(workspace) { + const normalized = normalizeWorkspaceForSlug(workspace); // Match pi's session directory naming: strip one leading separator, then // replace every "/", "\", and ":" with "-", wrapped as ----. const body = normalized.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); - return { + const result = { exact: `--${body}--`, // Sessions started in a subdirectory of the workspace live in their own // cwd-keyed directory; its name starts with the workspace slug body. prefix: `--${body}-`, }; + const homeBody = homeRelativeSlugBody(workspace); + if (homeBody) { + result.homeExact = `-${homeBody}`; + // Keep the trailing separator: without it a sibling workspace whose name + // merely starts with this one (~/src/dotai vs ~/src/dotai-next) would + // qualify as a subdirectory session directory. + result.homePrefix = `-${homeBody}-`; + } + return result; } function sessionIdFromFileName(filePath) { @@ -230,6 +263,16 @@ function transcriptEvents(raw, sourceRef, options) { return events; } +// OMP `/fork` copies every parent entry into the new session file. The header +// timestamp is the fork point, so records stamped before it were produced by the +// parent session. Discovery and reading share this predicate so the session time +// range can never disagree with the events the session actually yields. +function isInheritedForkEntry(raw, forkCutoff) { + if (forkCutoff === null || raw?.type === "session") return false; + const millis = timestampMillis(inferTimestamp(raw)); + return millis !== null && millis < forkCutoff; +} + async function probeTranscript(filePath, scope) { const summary = { sessionId: sessionIdFromFileName(filePath), @@ -238,20 +281,36 @@ async function probeTranscript(filePath, scope) { workspaceMatch: false, validHeader: false, cwd: null, + parentSessionId: null, + forkTimestamp: null, + // Time range excluding entries inherited from the parent session. Used only + // when the parent is discovered too, so inherited entries stay counted once. + ownedFirstSeen: null, + ownedLastSeen: null, }; - let firstRecord = true; + const ownedRange = { firstSeen: null, lastSeen: null }; + let headerSeen = false; + let forkCutoff = null; await forEachJsonLine(filePath, (raw) => { - if (firstRecord) { - firstRecord = false; - // Pi requires the first parsed record to be the session header. Do not - // let a later injected header qualify an otherwise foreign transcript. - if (raw?.type !== "session" || typeof raw.id !== "string" || !isScopedWorkspaceMatch(raw.cwd, scope)) { + if (!headerSeen) { + // OMP emits a "title" record before the session header; skip it. + // Any other non-session record before the header indicates a foreign + // or corrupted transcript — reject fail-closed. + if (raw?.type === "title") return undefined; + if (raw?.type !== "session") return false; + headerSeen = true; + if (typeof raw.id !== "string" || !isScopedWorkspaceMatch(raw.cwd, scope)) { return false; } summary.validHeader = true; summary.workspaceMatch = true; summary.sessionId = raw.id; summary.cwd = raw.cwd; + if (typeof raw.parentSession === "string" && raw.parentSession.length > 0) { + summary.parentSessionId = raw.parentSession; + summary.forkTimestamp = normalizeTimestamp(raw.timestamp) ?? null; + forkCutoff = timestampMillis(summary.forkTimestamp); + } } else if (raw?.type === "session") { // Multiple headers are not a valid Pi session and can splice content // from different workspaces, so reject the whole file fail-closed. @@ -259,8 +318,12 @@ async function probeTranscript(filePath, scope) { summary.workspaceMatch = false; return false; } - mergeTimeRange(summary, inferTimestamp(raw)); + const timestamp = inferTimestamp(raw); + mergeTimeRange(summary, timestamp); + if (!isInheritedForkEntry(raw, forkCutoff)) mergeTimeRange(ownedRange, timestamp); }); + summary.ownedFirstSeen = ownedRange.firstSeen; + summary.ownedLastSeen = ownedRange.lastSeen; return summary; } @@ -274,7 +337,13 @@ function addRef(sessions, sessionId, workspace, ref) { sourceKinds: new Set(), sourceRefs: [], workspaceCwds: new Set(), + parentSessionId: null, + forkTimestamp: null, }; + // Provenance is per file; the effective fork cutoff is applied per source ref + // so a second transcript for the same id never inherits a foreign cutoff. + session.parentSessionId ??= ref.parentSessionId ?? null; + session.forkTimestamp ??= ref.forkTimestamp ?? null; if (typeof ref.cwd === "string" && ref.cwd.length > 0) session.workspaceCwds.add(ref.cwd); session.sourceKinds.add(ref.kind); session.sourceRefs.push(ref); @@ -291,6 +360,12 @@ function finalizeSession(session) { ); } +function matchesSessionDirVariants(name, variants) { + if (name === variants.exact || name.startsWith(variants.prefix)) return true; + if (variants.homeExact == null) return false; + return name === variants.homeExact || name.startsWith(variants.homePrefix); +} + async function listSessionDirectories(sessionsRoot, variants) { let entries; try { @@ -301,7 +376,7 @@ async function listSessionDirectories(sessionsRoot, variants) { return entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) - .filter((name) => name === variants.exact || name.startsWith(variants.prefix)) + .filter((name) => matchesSessionDirVariants(name, variants)) .map((name) => path.join(sessionsRoot, name)); } @@ -409,11 +484,11 @@ export class PiSessionAnalyzer extends SessionAnalyzer { } async discoverSessions(scope, roots) { - const sessions = new Map(); const transcriptRoot = roots.find((root) => root.kind === "pi-session-jsonl"); if (!transcriptRoot) return []; const custom = scope.sessionDirMode === "custom"; const seenDirs = new Set(); + const probes = []; for (const sessionsRoot of transcriptRoot.paths ?? []) { if (!await pathExists(sessionsRoot)) continue; // Custom session directories contain JSONL files directly; the default @@ -434,17 +509,30 @@ export class PiSessionAnalyzer extends SessionAnalyzer { // shared custom directory never leaks foreign-workspace sessions. const probe = await probeTranscript(filePath, scope); if (!probe.validHeader || !probe.workspaceMatch || !withinTimeRange(probe.lastSeen ?? probe.firstSeen, scope)) continue; - addRef(sessions, probe.sessionId, scope.workspace, { - kind: transcriptRoot.kind, - role: transcriptRoot.role, - path: filePath, - firstSeen: probe.firstSeen, - lastSeen: probe.lastSeen, - cwd: probe.cwd, - }); + probes.push({ filePath, probe }); } } } + // A fork cutoff only deduplicates evidence the parent session also carries. + // When the parent is absent from this result the inherited entries have no + // other owner, so they stay with the fork rather than vanishing untracked. + const discoveredIds = new Set(probes.map(({ probe }) => probe.sessionId)); + const sessions = new Map(); + for (const { filePath, probe } of probes) { + const dedupeFork = probe.forkTimestamp !== null + && probe.parentSessionId !== null + && discoveredIds.has(probe.parentSessionId); + addRef(sessions, probe.sessionId, scope.workspace, { + kind: transcriptRoot.kind, + role: transcriptRoot.role, + path: filePath, + firstSeen: dedupeFork ? probe.ownedFirstSeen ?? probe.firstSeen : probe.firstSeen, + lastSeen: dedupeFork ? probe.ownedLastSeen ?? probe.lastSeen : probe.lastSeen, + cwd: probe.cwd, + parentSessionId: probe.parentSessionId, + forkTimestamp: dedupeFork ? probe.forkTimestamp : null, + }); + } return [...sessions.values()].map(finalizeSession) .sort((left, right) => (timestampMillis(right.lastSeen) ?? 0) - (timestampMillis(left.lastSeen) ?? 0)); } @@ -472,8 +560,12 @@ export class PiSessionAnalyzer extends SessionAnalyzer { break; } if (!ref.path.endsWith(".jsonl")) continue; + // Per source ref: discovery decided whether this file's inherited entries + // are already owned by a discovered parent session. + const forkCutoff = ref.forkTimestamp ? timestampMillis(ref.forkTimestamp) : null; const readCoverage = await forEachJsonLine(ref.path, (raw, line) => { if (raw?.type === "session" && raw?.cwd && !isScopedWorkspaceMatch(raw.cwd, scope)) return; + if (isInheritedForkEntry(raw, forkCutoff)) return; for (const event of this.normalizeEvents(raw, { ...ref, sessionId: session.sessionId, line }, options)) { if (withinTimeRange(event.timestamp, scope)) events.push(event); } diff --git a/test/sessions/session-analysis-providers.test.mjs b/test/sessions/session-analysis-providers.test.mjs index c7f68549..b550aa73 100644 --- a/test/sessions/session-analysis-providers.test.mjs +++ b/test/sessions/session-analysis-providers.test.mjs @@ -114,7 +114,10 @@ test("Claude, Cursor, and Qwen workspace slugs cover Unix and Windows layouts", assert.ok(workspaceToClaudeSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToCursorSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); assert.ok(workspaceToQwenSlugVariants("C:\\workspace\\project").some((value) => value.includes("C--workspace-project"))); - assert.ok(workspaceToPiSessionDirVariants("C:\\workspace\\project").exact.includes("C--workspace-project")); + // A Windows workspace string keeps its drive letter on every host instead of + // being resolved against the running host's cwd. + assert.equal(workspaceToPiSessionDirVariants("C:\\workspace\\project").exact, "--C--workspace-project--"); + assert.equal(workspaceToPiSessionDirVariants("C:\\workspace\\project").prefix, "--C--workspace-project-"); assert.ok(workspaceToWorkbuddySlugVariants("C:\\workspace\\project").exact.includes("C--workspace-project")); assert.equal( workspaceToGrokSessionDirName("C:\\workspace\\project"), @@ -1487,6 +1490,158 @@ test("Pi provider discovers subdirectory session dirs that share the workspace p assert.equal(result.sources[0].path, path.join(home, "sessions", dirName)); }); +// OMP (Oh My Pi) keys session directories on the home-relative workspace path +// instead of pi's absolute ---- form. os.homedir() reads HOME on POSIX and +// USERPROFILE on Windows, so both are redirected to keep the fixture host-neutral. +async function withFakeHome(prefix, run) { + const fakeHome = await fixtureRoot(prefix); + const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + try { + return await run(fakeHome); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +test("Pi derives OMP home-relative session directory names with a bounded prefix", async () => { + await withFakeHome("session-omp-slug-", (fakeHome) => { + const variants = workspaceToPiSessionDirVariants(path.join(fakeHome, "src", "dotai")); + assert.equal(variants.homeExact, "-src-dotai"); + // The trailing separator keeps a sibling workspace out of the prefix match. + assert.equal(variants.homePrefix, "-src-dotai-"); + // A workspace outside the home directory has no home-relative form. + assert.equal(workspaceToPiSessionDirVariants(path.join(fakeHome, "..", "elsewhere")).homeExact, undefined); + assert.equal(workspaceToPiSessionDirVariants(path.dirname(fakeHome)).homeExact, undefined); + // A directory whose name merely begins with ".." is still inside the home + // directory, so it keeps a home-relative form. + assert.equal(workspaceToPiSessionDirVariants(path.join(fakeHome, "..cache")).homeExact, "-..cache"); + }); +}); + +test("Pi discovers OMP home-relative session directories and skips the title preamble", async () => { + await withFakeHome("session-omp-discovery-", async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const home = path.join(fakeHome, ".omp", "agent"); + const sessionId = "99999999-9999-4999-8999-999999999999"; + await writeJsonl(path.join(home, "sessions", "-src-dotai", `2026-09-01T01-00-00-000Z_${sessionId}.jsonl`), [ + { type: "title", title: "Add the OMP adapter" }, + { type: "session", version: 3, id: sessionId, timestamp: "2026-09-01T01:00:00.000Z", cwd: workspace }, + { + type: "message", + id: "omp1", + parentId: null, + timestamp: "2026-09-01T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "omp prompt" }] }, + }, + ]); + + const analyzer = new PiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + assert.deepEqual(discovery.sessions.map((session) => session.sessionId), [sessionId]); + const scope = await analyzer.resolveScope({ workspace, home }); + const events = await analyzer.readSession(discovery.sessions[0], scope, { includeUserText: true }); + assert.deepEqual(events.filter((event) => event.type === "user").map((event) => event.userText), ["omp prompt"]); + }); +}); + +test("Pi keeps a sibling OMP workspace out of the home-relative prefix match", async () => { + await withFakeHome("session-omp-sibling-", async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const sibling = path.join(fakeHome, "src", "dotaix"); + const home = path.join(fakeHome, ".omp", "agent"); + // Only the sibling workspace has sessions; the target must stay empty. + await writeJsonl(path.join(home, "sessions", "-src-dotaix", "2026-09-01T01-00-00-000Z_sibling.jsonl"), [ + { type: "session", version: 3, id: "sibling", timestamp: "2026-09-01T01:00:00.000Z", cwd: sibling }, + ]); + + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.equal(result.sessions.length, 0); + assert.equal(result.sources[0].exists, false); + }); +}); + +test("Pi discovers OMP subdirectory session dirs under the home-relative prefix", async () => { + await withFakeHome("session-omp-subdir-", async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const subdir = path.join(workspace, "packages", "app"); + const home = path.join(fakeHome, ".omp", "agent"); + await writeJsonl(path.join(home, "sessions", "-src-dotai-packages-app", "2026-09-01T01-00-00-000Z_child.jsonl"), [ + { type: "session", version: 3, id: "omp-child", timestamp: "2026-09-01T01:00:00.000Z", cwd: subdir }, + ]); + + const result = await new PiSessionAnalyzer().analyze({ command: "sources", workspace, home }); + assert.deepEqual(result.sessions.map((session) => session.sessionId), ["omp-child"]); + }); +}); + +const OMP_FORK_PARENT = [ + { type: "session", version: 3, id: "omp-parent", timestamp: "2026-09-01T01:00:00.000Z" }, + { + type: "message", + id: "f1", + parentId: null, + timestamp: "2026-09-01T01:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "inherited prompt" }] }, + }, +]; +const OMP_FORK_CHILD = [ + { type: "session", version: 3, id: "omp-fork", parentSession: "omp-parent", timestamp: "2026-09-01T02:00:00.000Z" }, + OMP_FORK_PARENT[1], + { + type: "message", + id: "f2", + parentId: "f1", + timestamp: "2026-09-01T02:00:10.000Z", + message: { role: "user", content: [{ type: "text", text: "forked prompt" }] }, + }, +]; + +async function readOmpForkSessions(prefix, { includeParent }) { + return withFakeHome(prefix, async (fakeHome) => { + const workspace = path.join(fakeHome, "src", "dotai"); + const home = path.join(fakeHome, ".omp", "agent"); + const sessionsDir = path.join(home, "sessions", "-src-dotai"); + const withCwd = (rows) => rows.map((row) => (row.type === "session" ? { ...row, cwd: workspace } : row)); + if (includeParent) { + await writeJsonl(path.join(sessionsDir, "2026-09-01T01-00-00-000Z_omp-parent.jsonl"), withCwd(OMP_FORK_PARENT)); + } + await writeJsonl(path.join(sessionsDir, "2026-09-01T02-00-00-000Z_omp-fork.jsonl"), withCwd(OMP_FORK_CHILD)); + + const analyzer = new PiSessionAnalyzer(); + const discovery = await analyzer.analyze({ command: "sources", workspace, home }); + const scope = await analyzer.resolveScope({ workspace, home }); + const byId = new Map(discovery.sessions.map((session) => [session.sessionId, session])); + const prompts = async (sessionId) => (await analyzer.readSession(byId.get(sessionId), scope, { includeUserText: true })) + .filter((event) => event.type === "user") + .map((event) => event.userText); + return { byId, prompts }; + }); +} + +test("Pi counts OMP inherited fork entries once when the parent session is discovered", async () => { + const { byId, prompts } = await readOmpForkSessions("session-omp-fork-", { includeParent: true }); + assert.deepEqual([...byId.keys()].sort(), ["omp-fork", "omp-parent"]); + assert.deepEqual(await prompts("omp-parent"), ["inherited prompt"]); + assert.deepEqual(await prompts("omp-fork"), ["forked prompt"]); + // The fork's reported range starts at the fork point, matching its events. + assert.equal(byId.get("omp-fork").firstSeen, "2026-09-01T02:00:00.000Z"); + assert.equal(byId.get("omp-fork").parentSessionId, "omp-parent"); +}); + +test("Pi retains OMP inherited fork entries when the parent session is absent", async () => { + const { byId, prompts } = await readOmpForkSessions("session-omp-orphan-fork-", { includeParent: false }); + assert.deepEqual([...byId.keys()], ["omp-fork"]); + // Nothing else owns the inherited entries, so dropping them would lose evidence. + assert.deepEqual(await prompts("omp-fork"), ["inherited prompt", "forked prompt"]); + assert.equal(byId.get("omp-fork").firstSeen, "2026-09-01T01:00:10.000Z"); + assert.equal(byId.get("omp-fork").parentSessionId, "omp-parent"); +}); + test("Pi treats a configured session directory as the exact flat JSONL directory", async () => { const root = await fixtureRoot("session-pi-custom-dir-"); const home = path.join(root, ".pi", "agent");