mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
feat(coding-agents): first-class pi support, sharing one extension adapter and installer with Prime Agent (#3993)
Adds pi (@earendil-works/pi-coding-agent) as a harness and extracts createPiExtension(harness), the extension adapter pi and its fork Prime Agent share. Supersedes #3775. The `pi` key is removed from package.json: both hosts read that same key, so it could only ever name one bundle, and the host it did not name loaded the other's and reported the wrong harness. `hindsight-coding-agents install pi|prime-agent` is now the only route for both. Also makes the companion skill self-update for every host that installs one — the paths now live once in core/skill-dirs.ts, which both the installer and skill-sync read. Co-authored-by: Sebastian Otaegui <feniix@gmail.com>
This commit is contained in:
@@ -254,7 +254,7 @@ npx @vectorize-io/hindsight-coding-agents install all # every detected
|
||||
npx @vectorize-io/hindsight-coding-agents install claude-code # or just one
|
||||
```
|
||||
|
||||
Supports Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, opencode, Kilo CLI, Cline CLI, Antigravity CLI, Devin CLI, Prime Agent, Grok Build and DeepSeek Harness. Ingestion is automatic — there is no setup command. See the [coding agents integration](https://hindsight.vectorize.io/sdks/integrations/coding-agents).
|
||||
Supports Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, opencode, Kilo CLI, Cline CLI, Antigravity CLI, Devin CLI, pi, Prime Agent, Grok Build and DeepSeek Harness. Ingestion is automatic — there is no setup command. See the [coding agents integration](https://hindsight.vectorize.io/sdks/integrations/coding-agents).
|
||||
|
||||
### MCP Server
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#383e45" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20 H52" />
|
||||
<path d="M24 20 V46" />
|
||||
<path d="M40 20 V46" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -13,7 +13,9 @@
|
||||
* The ids below are exactly the ones that integration emits — see its
|
||||
* `src/harness/hook-lifecycle.ts` (one HookSpec per hook-driven agent) and
|
||||
* `src/harness/registry.ts` (the persistent-plugin agents, whose id is the
|
||||
* `createPluginEntry(...)` argument of their entrypoint). Do not add entries for
|
||||
* argument their entrypoint passes to the shared factory it builds on —
|
||||
* `createPluginEntry(...)` for the opencode family, `createPiExtension(...)` for
|
||||
* the pi family). Do not add entries for
|
||||
* agents that cannot appear yet: an id nothing writes is a logo nothing renders.
|
||||
* When that integration gains a harness, add it here in the same change — drop
|
||||
* its icon in `public/img/harness/` (copied from
|
||||
@@ -92,6 +94,10 @@ export const HARNESS_LOGO_REGISTRY: Record<string, HarnessLogoEntry> = {
|
||||
// its own harness id, but it is the same product — so it deliberately shares
|
||||
// v1's brand mark rather than inventing a second one.
|
||||
opencode2: { id: "opencode2", label: "OpenCode 2", src: "/img/harness/opencode.png" },
|
||||
// pi ships no brand mark of its own, so this is the letter the agent is named
|
||||
// for, drawn in the same monochrome style as the Prime Agent mark below (the
|
||||
// fork of pi) rather than a logo invented for it.
|
||||
pi: { id: "pi", label: "pi", src: "/img/harness/pi.svg", invertOnDark: true },
|
||||
"qwen-code": { id: "qwen-code", label: "Qwen Code", src: "/img/harness/qwen-code.svg" },
|
||||
"prime-agent": {
|
||||
id: "prime-agent",
|
||||
|
||||
@@ -36,8 +36,9 @@ describe("documentHarness", () => {
|
||||
describe("resolveHarnessLogo", () => {
|
||||
// The exact set hindsight-coding-agents emits: one per HookSpec in
|
||||
// src/harness/hook-lifecycle.ts, plus the persistent-plugin harnesses whose id
|
||||
// is their entrypoint's createPluginEntry(...) argument (opencode, opencode2,
|
||||
// kilo, cline-cli). The registry must cover these and nothing speculative.
|
||||
// is their entrypoint's argument to the shared factory they use (opencode,
|
||||
// opencode2, kilo, cline-cli, pi, prime-agent). The registry must cover these
|
||||
// and nothing speculative.
|
||||
const EMITTED_HARNESSES = [
|
||||
"antigravity-cli",
|
||||
"claude-code",
|
||||
@@ -52,6 +53,7 @@ describe("resolveHarnessLogo", () => {
|
||||
"kilo",
|
||||
"opencode",
|
||||
"opencode2",
|
||||
"pi",
|
||||
"prime-agent",
|
||||
"qwen-code",
|
||||
];
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "One Hindsight memory plugin for coding agents — per-repo memory
|
||||
|
||||
Long-term project memory for **coding agents**, backed by [Hindsight](https://vectorize.io/hindsight).
|
||||
One package, several agents: a shared reflect-and-inject core with a thin entry point per agent
|
||||
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
|
||||
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **pi**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
|
||||
automatic — there is no setup command: a repo's git history and conversations flow into its memory
|
||||
bank in the background as you work.
|
||||
|
||||
@@ -179,14 +179,35 @@ npx @vectorize-io/hindsight-coding-agents install cline-cli
|
||||
|
||||
A native plugin via `cline plugin install`, plus MCP and the companion skill.
|
||||
|
||||
#### <img src="/img/harness/pi.svg" alt="" width="20" height="20" /> pi
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-coding-agents install pi
|
||||
```
|
||||
|
||||
An extension entry in `~/.pi/agent/settings.json`, plus the companion skill in
|
||||
`~/.pi/agent/skills` — native tools, no MCP needed.
|
||||
|
||||
This command is the only supported route, for pi and for Prime Agent below. Installing us as a pi
|
||||
package (`pi install npm:@vectorize-io/hindsight-coding-agents`) is deliberately not wired: both
|
||||
hosts read the same `pi` key of a package's `package.json`, and that key can only name one entry —
|
||||
whichever host it did not name would load the other's bundle and report itself as the wrong agent,
|
||||
taking that harness's config section and stamping every document it retains with it. So the package
|
||||
carries no `pi` key at all, and each host is pointed at its own bundle by the install command above.
|
||||
|
||||
#### <img src="/img/harness/prime-agent.svg" alt="" width="20" height="20" /> Prime Agent
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-coding-agents install prime-agent
|
||||
```
|
||||
|
||||
An extension entry in `~/.prime/agent/settings.json` — native tools, no MCP needed — plus the
|
||||
companion skill in `~/.prime/agent/skills`.
|
||||
Prime Agent is a fork of pi, so it is wired the same way: an extension entry, here in
|
||||
`~/.prime/agent/settings.json`, plus the companion skill in `~/.prime/agent/skills` — native tools,
|
||||
no MCP needed. Installing both is fine and expected:
|
||||
each host loads its own entry from its own settings file, and like every other pair of agents they
|
||||
**share one bank per repo** (the default `coding-agent::{gitProject}`), so what you tell pi is there
|
||||
when you open Prime Agent. Separate entries are what keeps each side attributable — its own
|
||||
`harnesses.<name>` config section, and its own agent stamped on every document it retains.
|
||||
|
||||
#### <img src="/img/harness/dsh.svg" alt="" width="20" height="20" /> DeepSeek Harness
|
||||
|
||||
@@ -274,7 +295,8 @@ transcripts are needed either way; going through them directly is simply the sho
|
||||
**How sessions are matched.** A conversation is imported only when the session itself records the
|
||||
directory it ran in — never inferred from a file or folder name. Claude Code writes that directory
|
||||
on its entries, Codex in its `session_meta` header and DeepSeek Harness in its session-log header,
|
||||
so all three can be attributed exactly, including sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
|
||||
and pi and Prime Agent in their session header, so all five can be attributed exactly, including
|
||||
sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
|
||||
folders after the project path) but unsafe: `/` and `.` both encode to `-`, so `repo-sub` is either
|
||||
the subdirectory `repo/sub` or an unrelated sibling repo — and a wrong guess files someone else's
|
||||
conversation into your bank. Sessions that record nothing are skipped and the count is reported.
|
||||
@@ -384,7 +406,7 @@ what reads it:
|
||||
| host | reads the file | an edit applies |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
|
||||
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
|
||||
|
||||
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
|
||||
|
||||
@@ -31,6 +31,8 @@ const CODING_AGENT_LOGOS: {id: string; name: string; file: string}[] = [
|
||||
{id: 'devin-cli', name: 'Devin CLI', file: 'devin-cli.svg'},
|
||||
{id: 'cline-cli', name: 'Cline CLI', file: 'cline-cli.svg'},
|
||||
{id: 'dsh', name: 'DeepSeek Harness', file: 'dsh.svg'},
|
||||
{id: 'pi', name: 'pi', file: 'pi.svg'},
|
||||
{id: 'prime-agent', name: 'Prime Agent', file: 'prime-agent.svg'},
|
||||
];
|
||||
|
||||
const INTEGRATIONS_JSON_URL =
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#383e45" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20 H52" />
|
||||
<path d="M24 20 V46" />
|
||||
<path d="M40 20 V46" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -2,7 +2,7 @@
|
||||
|
||||
Long-term project memory for **coding agents**, backed by [Hindsight](https://vectorize.io/hindsight).
|
||||
One package, several agents: a shared reflect-and-inject core with a thin entry point per agent
|
||||
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
|
||||
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **pi**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
|
||||
automatic — there is no setup command: a repo's git history and conversations flow into its memory
|
||||
bank in the background as you work.
|
||||
|
||||
@@ -176,14 +176,35 @@ npx @vectorize-io/hindsight-coding-agents install cline-cli
|
||||
|
||||
A native plugin via `cline plugin install`, plus MCP and the companion skill.
|
||||
|
||||
#### <img src="https://hindsight.vectorize.io/img/harness/pi.svg" alt="" width="20" height="20" /> pi
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-coding-agents install pi
|
||||
```
|
||||
|
||||
An extension entry in `~/.pi/agent/settings.json`, plus the companion skill in
|
||||
`~/.pi/agent/skills` — native tools, no MCP needed.
|
||||
|
||||
This command is the only supported route, for pi and for Prime Agent below. Installing us as a pi
|
||||
package (`pi install npm:@vectorize-io/hindsight-coding-agents`) is deliberately not wired: both
|
||||
hosts read the same `pi` key of a package's `package.json`, and that key can only name one entry —
|
||||
whichever host it did not name would load the other's bundle and report itself as the wrong agent,
|
||||
taking that harness's config section and stamping every document it retains with it. So the package
|
||||
carries no `pi` key at all, and each host is pointed at its own bundle by the install command above.
|
||||
|
||||
#### <img src="https://hindsight.vectorize.io/img/harness/prime-agent.svg" alt="" width="20" height="20" /> Prime Agent
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-coding-agents install prime-agent
|
||||
```
|
||||
|
||||
An extension entry in `~/.prime/agent/settings.json` — native tools, no MCP needed — plus the
|
||||
companion skill in `~/.prime/agent/skills`.
|
||||
Prime Agent is a fork of pi, so it is wired the same way: an extension entry, here in
|
||||
`~/.prime/agent/settings.json`, plus the companion skill in `~/.prime/agent/skills` — native tools,
|
||||
no MCP needed. Installing both is fine and expected:
|
||||
each host loads its own entry from its own settings file, and like every other pair of agents they
|
||||
**share one bank per repo** (the default `coding-agent::{gitProject}`), so what you tell pi is there
|
||||
when you open Prime Agent. Separate entries are what keeps each side attributable — its own
|
||||
`harnesses.<name>` config section, and its own agent stamped on every document it retains.
|
||||
|
||||
#### <img src="https://hindsight.vectorize.io/img/harness/dsh.svg" alt="" width="20" height="20" /> DeepSeek Harness
|
||||
|
||||
@@ -271,7 +292,8 @@ transcripts are needed either way; going through them directly is simply the sho
|
||||
**How sessions are matched.** A conversation is imported only when the session itself records the
|
||||
directory it ran in — never inferred from a file or folder name. Claude Code writes that directory
|
||||
on its entries, Codex in its `session_meta` header and DeepSeek Harness in its session-log header,
|
||||
so all three can be attributed exactly, including sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
|
||||
and pi and Prime Agent in their session header, so all five can be attributed exactly, including
|
||||
sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
|
||||
folders after the project path) but unsafe: `/` and `.` both encode to `-`, so `repo-sub` is either
|
||||
the subdirectory `repo/sub` or an unrelated sibling repo — and a wrong guess files someone else's
|
||||
conversation into your bank. Sessions that record nothing are skipped and the count is reported.
|
||||
@@ -387,7 +409,7 @@ what reads it:
|
||||
| host | reads the file | an edit applies |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
|
||||
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
|
||||
|
||||
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM hindsight-coding-agents-e2e-base
|
||||
ARG VERSION=latest
|
||||
RUN npm install --global "@earendil-works/pi-coding-agent@${VERSION}" && command -v pi
|
||||
@@ -68,11 +68,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./dist/prime-agent.js"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"skill:build": "node scripts/build-skill.mjs",
|
||||
|
||||
@@ -132,7 +132,7 @@ what reads it:
|
||||
| host | reads the file | an edit applies |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
|
||||
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
|
||||
|
||||
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { claudeProjectDir, importLocalHistory } from "./history";
|
||||
import { claudeProjectDir, importLocalHistory, piSessionDir } from "./history";
|
||||
|
||||
let home: string;
|
||||
afterEach(() => {
|
||||
@@ -22,6 +22,43 @@ const claudeLine = (role: string, text: string, cwd?: string) =>
|
||||
message: { role, content: [{ type: "text", text }] },
|
||||
});
|
||||
|
||||
/** One entry of a stored pi/Prime Agent session log. */
|
||||
const piHeader = (id: string, cwd?: string) =>
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id,
|
||||
timestamp: "2026-08-24T12:00:00.000Z",
|
||||
...(cwd ? { cwd } : {}),
|
||||
});
|
||||
const piMessage = (role: string, text: string) =>
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "e1",
|
||||
timestamp: "2026-08-24T12:00:01.000Z",
|
||||
message: { role, content: [{ type: "text", text }] },
|
||||
});
|
||||
const piLog = (id: string, cwd: string | undefined, ...turns: string[]) =>
|
||||
`${[piHeader(id, cwd), ...turns].join("\n")}\n`;
|
||||
|
||||
/** Write a stored session where each host actually keeps it: pi under an encoded per-cwd folder,
|
||||
* Prime Agent flat. */
|
||||
function writePiSession(home: string, cwd: string, id: string, ...turns: string[]): void {
|
||||
const dir = join(home, ".pi", "agent", "sessions", piSessionDir(cwd));
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `2026-08-24T12-00-00-000Z_${id}.jsonl`), piLog(id, cwd, ...turns));
|
||||
}
|
||||
function writePrimeSession(
|
||||
home: string,
|
||||
cwd: string | undefined,
|
||||
id: string,
|
||||
...turns: string[]
|
||||
): void {
|
||||
const dir = join(home, ".prime", "agent", "sessions");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${id}.jsonl`), piLog(id, cwd, ...turns));
|
||||
}
|
||||
|
||||
describe("local history import", () => {
|
||||
it("reads Claude sessions from the project directory for THIS repo only", () => {
|
||||
const h = newHome();
|
||||
@@ -224,6 +261,129 @@ describe("attribution must be proven, never guessed", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* pi and Prime Agent write the same session schema but lay the files out differently, so each host
|
||||
* needs its own enumeration proved — the shared body that reads a header is only reached if the
|
||||
* files were found at all.
|
||||
*/
|
||||
describe("pi-family history import", () => {
|
||||
it("reads pi sessions from the encoded per-directory folder for THIS repo only", () => {
|
||||
const h = newHome();
|
||||
const repo = "/Users/x/dev/myrepo";
|
||||
writePiSession(
|
||||
h,
|
||||
repo,
|
||||
"s1",
|
||||
piMessage("user", "why retry 429?"),
|
||||
piMessage("assistant", "backpressure")
|
||||
);
|
||||
writePiSession(h, "/Users/x/dev/otherrepo", "s2", piMessage("user", "unrelated"));
|
||||
|
||||
const r = importLocalHistory("pi", repo, h);
|
||||
expect(r.supported).toBe(true);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].id).toBe("s1");
|
||||
expect(JSON.stringify(r.sessions[0].turns)).toContain("why retry 429?");
|
||||
expect(JSON.stringify(r.sessions)).not.toContain("unrelated");
|
||||
});
|
||||
|
||||
it("includes a pi session started in a SUBDIRECTORY of the repo", () => {
|
||||
const h = newHome();
|
||||
writePiSession(
|
||||
h,
|
||||
"/Users/x/dev/repo/packages/api",
|
||||
"s1",
|
||||
piMessage("user", "work in a subpackage")
|
||||
);
|
||||
expect(JSON.stringify(importLocalHistory("pi", "/Users/x/dev/repo", h).sessions)).toContain(
|
||||
"work in a subpackage"
|
||||
);
|
||||
});
|
||||
|
||||
// The folder name is lossy in exactly the way the Claude encoding is: "/Users/x/dev/repo-sub"
|
||||
// matches the prefix scan for "/Users/x/dev/repo", so only the header cwd can separate them.
|
||||
it("does not pull a SIBLING repo's pi sessions in via the ambiguous folder name", () => {
|
||||
const h = newHome();
|
||||
writePiSession(h, "/Users/x/dev/repo-sub", "s1", piMessage("user", "sibling repo work"));
|
||||
expect(JSON.stringify(importLocalHistory("pi", "/Users/x/dev/repo", h).sessions)).not.toContain(
|
||||
"sibling repo work"
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: `~/.pi/agent/sessions` itself as a regular file passed the existsSync check and the
|
||||
// root readdirSync threw ENOTDIR out of importLocalHistory — which documents that it never throws,
|
||||
// and whose caller does not catch. One junk file killed the whole --import-conversations run.
|
||||
it("treats a stray file where the pi sessions ROOT was expected as no history", () => {
|
||||
const h = newHome();
|
||||
mkdirSync(join(h, ".pi", "agent"), { recursive: true });
|
||||
writeFileSync(join(h, ".pi", "agent", "sessions"), "not a folder");
|
||||
|
||||
expect(importLocalHistory("pi", "/Users/x/dev/myrepo", h)).toEqual({
|
||||
supported: true,
|
||||
sessions: [],
|
||||
unattributed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Same shape one level down: a regular file named like a session folder must cost that one
|
||||
// entry, not the run.
|
||||
it("skips a stray file where a pi session folder was expected, keeping the real sessions", () => {
|
||||
const h = newHome();
|
||||
const repo = "/Users/x/dev/myrepo";
|
||||
writePiSession(h, repo, "s1", piMessage("user", "real work"));
|
||||
writeFileSync(join(h, ".pi", "agent", "sessions", piSessionDir(`${repo}/sub`)), "not a folder");
|
||||
|
||||
const r = importLocalHistory("pi", repo, h);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(JSON.stringify(r.sessions)).toContain("real work");
|
||||
});
|
||||
|
||||
it("reads Prime Agent sessions out of its FLAT directory, attributing by header cwd", () => {
|
||||
const h = newHome();
|
||||
const repo = "/Users/x/dev/myrepo";
|
||||
writePrimeSession(h, repo, "p1", piMessage("user", "which statuses retry?"));
|
||||
writePrimeSession(h, "/Users/x/dev/otherrepo", "p2", piMessage("user", "unrelated"));
|
||||
|
||||
const r = importLocalHistory("prime-agent", repo, h);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
expect(r.sessions[0].id).toBe("p1");
|
||||
expect(JSON.stringify(r.sessions)).not.toContain("unrelated");
|
||||
});
|
||||
|
||||
it("skips a session whose header records no cwd rather than trusting where the file sits", () => {
|
||||
const h = newHome();
|
||||
writePrimeSession(h, undefined, "p1", piMessage("user", "ambiguous"));
|
||||
const r = importLocalHistory("prime-agent", "/Users/x/dev/myrepo", h);
|
||||
expect(r.sessions).toEqual([]);
|
||||
expect(r.unattributed).toBe(1);
|
||||
});
|
||||
|
||||
it("reports no sessions, not an error, when the host has never run on this machine", () => {
|
||||
const h = newHome();
|
||||
for (const harness of ["pi", "prime-agent"]) {
|
||||
const r = importLocalHistory(harness, "/Users/x/dev/myrepo", h);
|
||||
expect(r.supported).toBe(true);
|
||||
expect(r.sessions).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("piSessionDir", () => {
|
||||
// Verified against pi 0.84.2's SessionManager: one leading separator dropped, `/`, `\` and `:`
|
||||
// mapped to `-`, everything else — dots, spaces, case — left alone.
|
||||
it.each([
|
||||
["/Users/x/dev/myrepo", "--Users-x-dev-myrepo--"],
|
||||
["/private/tmp", "--private-tmp--"],
|
||||
["/Users/x/dev/repo.git", "--Users-x-dev-repo.git--"],
|
||||
["/Users/x/My Projects/App", "--Users-x-My Projects-App--"],
|
||||
// Windows: no leading separator to strip, and the drive colon maps to `-` like the
|
||||
// separators, so the drive letter survives as its own segment.
|
||||
["C:\\Users\\x\\repo", "--C--Users-x-repo--"],
|
||||
])("encodes %s", (repo, encoded) => {
|
||||
expect(piSessionDir(repo)).toBe(encoded);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importLocalHistory — dcode", () => {
|
||||
const REPO = "/Users/x/dev/repo";
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { readClaudeTranscript } from "./transcript";
|
||||
import { readCodexTranscript } from "./transcript-codex";
|
||||
import { readDcodeTranscript } from "./transcript-dcode";
|
||||
import { readDshEvents, type DshSessionEvent } from "./transcript-dsh";
|
||||
import { readPiTranscript } from "./transcript-pi";
|
||||
import { zstdDecompressFrames } from "./zstd-frames";
|
||||
import type { TransportTurn } from "./chat";
|
||||
|
||||
@@ -62,6 +63,18 @@ export function claudeProjectDir(repoDir: string, home = homedir()): string {
|
||||
return join(home, ".claude", "projects", repoDir.replace(/[^a-zA-Z0-9]/g, "-"));
|
||||
}
|
||||
|
||||
/** pi names a session folder after the working directory it was started in: one leading separator
|
||||
* stripped, then `/`, `\` and `:` replaced by `-`, wrapped in `--`…`--`. Verified against pi
|
||||
* 0.84.2, whose SessionManager builds exactly
|
||||
* `` `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--` ``.
|
||||
*
|
||||
* Unlike Claude's encoding this keeps dots, spaces and case, but it is still NOT injective — `/a/b`
|
||||
* and `/a-b` both give `--a-b--` — so it only narrows the search; attribution comes from the `cwd`
|
||||
* recorded in each session's header line. */
|
||||
export function piSessionDir(repoDir: string): string {
|
||||
return `--${repoDir.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
}
|
||||
|
||||
/** Is `dir` the repo itself or somewhere inside it? */
|
||||
export function withinRepo(dir: string | undefined, repoDir: string): boolean {
|
||||
return (
|
||||
@@ -376,6 +389,72 @@ function readDshLog(file: string): string[] {
|
||||
return text.split("\n").filter((line) => line.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* pi and its fork Prime Agent: a `{type:"session", id, cwd}` header line followed by
|
||||
* `{type:"message"}` entries — the same schema from both hosts, so one body reads either.
|
||||
*
|
||||
* Only the file layout differs, which is why the two callers below exist:
|
||||
* pi `sessions/--<encoded cwd>--/<timestamp>_<uuid>.jsonl`
|
||||
* Prime Agent `sessions/<uuid>.jsonl` — flat, no per-directory folder
|
||||
*
|
||||
* Attribution is the header's `cwd` in both cases. pi's folder name is a lossy rendering of that
|
||||
* same path (see piSessionDir), so it is used only to narrow the walk, never to decide ownership —
|
||||
* exactly the rule the Claude reader follows, and for the same reason: a wrong guess files someone
|
||||
* else's conversation into this repo's memory.
|
||||
*/
|
||||
function piFamilySessions(files: string[], repoDir: string): HistoryImport {
|
||||
const sessions: ChatSession[] = [];
|
||||
let unattributed = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
const head = firstLine(file);
|
||||
if (!head) continue;
|
||||
const meta = JSON.parse(head) as { type?: string; cwd?: string; id?: string };
|
||||
// A file whose first line is not the session header is not a session log (pi keeps other
|
||||
// artifacts under the same tree), not a session we failed to attribute.
|
||||
if (meta?.type !== "session") continue;
|
||||
if (!meta.cwd) {
|
||||
unattributed++;
|
||||
continue;
|
||||
}
|
||||
if (!withinRepo(meta.cwd, repoDir)) continue;
|
||||
const s = toSession(meta.id ?? file, readPiTranscript(file));
|
||||
if (s) sessions.push(s);
|
||||
} catch {
|
||||
/* a single unreadable transcript must not abort the import */
|
||||
}
|
||||
}
|
||||
return { supported: true, sessions, unattributed };
|
||||
}
|
||||
|
||||
function piHistory(repoDir: string, home: string): HistoryImport {
|
||||
const root = join(home, ".pi", "agent", "sessions");
|
||||
if (!existsSync(root)) return { supported: true, sessions: [] };
|
||||
// Starting pi from a subdirectory gives that subdirectory its own folder, so an exact match would
|
||||
// silently miss that history. Prefilter on the encoded name — `--<repo>--` itself plus anything
|
||||
// nested under `--<repo>-` — then let the header cwd confirm; a sibling repo whose name merely
|
||||
// extends this one (`repo-other`) matches the prefix and is dropped by that check.
|
||||
const exact = piSessionDir(repoDir);
|
||||
const nested = `${exact.slice(0, -2)}-`;
|
||||
const files: string[] = [];
|
||||
// listDir at both levels: a stray FILE where either `sessions` itself or one of its session
|
||||
// folders was expected must cost that one entry, not the run — importLocalHistory promises never
|
||||
// to throw and its caller (installer.ts importConversations) has no catch of its own.
|
||||
for (const dir of listDir(root).filter((d) => d === exact || d.startsWith(nested))) {
|
||||
files.push(...jsonlFiles(join(root, dir)));
|
||||
}
|
||||
return piFamilySessions(files, repoDir);
|
||||
}
|
||||
|
||||
function primeAgentHistory(repoDir: string, home: string): HistoryImport {
|
||||
// Flat storage, so there is nothing to prefilter on and every header is read. That stays cheap
|
||||
// because the directory holds one file per SESSION, where pi's holds one folder per working
|
||||
// directory — the scale that made pi's prefilter necessary does not arise here.
|
||||
const root = join(home, ".prime", "agent", "sessions");
|
||||
if (!existsSync(root)) return { supported: true, sessions: [] };
|
||||
return piFamilySessions(jsonlFiles(root), repoDir);
|
||||
}
|
||||
|
||||
const SQLITE_HISTORY =
|
||||
"keeps session history in an internal SQLite database, whose schema is unversioned and would " +
|
||||
"break on any upstream change";
|
||||
@@ -403,6 +482,10 @@ export function importLocalHistory(
|
||||
);
|
||||
case "dsh":
|
||||
return dshHistory(repoDir, home);
|
||||
case "pi":
|
||||
return piHistory(repoDir, home);
|
||||
case "prime-agent":
|
||||
return primeAgentHistory(repoDir, home);
|
||||
case "opencode":
|
||||
// opencode v2 keeps sessions in the SAME `opencode.db` v1 does.
|
||||
case "opencode2":
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Where each host keeps the companion skill, as home-relative path parts.
|
||||
*
|
||||
* ONE map, because two independent code paths write and refresh those directories: the installer
|
||||
* copies the packaged skill in and removes it again (src/installer.ts), and every session start
|
||||
* re-copies it on drift so `npm update -g` upgrades the skill too (core/skill-sync.ts).
|
||||
*
|
||||
* They used to hold the paths separately, and the self-update copy listed only four of the ten
|
||||
* hosts the installer writes — so Copilot, Grok Build, Cline, dsh, pi and Prime Agent stayed pinned
|
||||
* to whichever SKILL.md they happened to be installed with, forever. Same shape as #3524: the
|
||||
* sibling nobody wrote a test for is the sibling that gets forgotten, so the list lives once and
|
||||
* `installer.test.ts` asserts it over the whole family.
|
||||
*
|
||||
* A host absent here has no skills mechanism at all (opencode and its Kilo fork).
|
||||
*/
|
||||
export const SKILL_DIRS: Record<string, string[]> = {
|
||||
"claude-code": [".claude", "skills"],
|
||||
// Codex and dsh share the agentskills-standard root; uninstalling either removes the one copy.
|
||||
codex: [".agents", "skills"],
|
||||
dsh: [".agents", "skills"],
|
||||
"antigravity-cli": [".gemini", "config", "skills"],
|
||||
"cursor-cli": [".cursor", "skills"],
|
||||
"copilot-cli": [".copilot", "skills"],
|
||||
"grok-build": [".grok", "skills"],
|
||||
"cline-cli": [".cline", "data", "settings", "skills"],
|
||||
"qwen-code": [".qwen", "skills"], // Qwen's user-level skills root (Storage.getUserSkillsDirs)
|
||||
// The pi family reads the shared ~/.agents/skills too, but writes its OWN root: skill removal is
|
||||
// by fixed directory name, so installing to the shared one would make `uninstall pi` take Codex's
|
||||
// and dsh's copy with it.
|
||||
pi: [".pi", "agent", "skills"],
|
||||
"prime-agent": [".prime", "agent", "skills"],
|
||||
};
|
||||
@@ -2,7 +2,9 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SKILL_DIRS, syncCompanionSkill } from "./skill-sync";
|
||||
import { INSTALLERS } from "../installer";
|
||||
import { SKILL_DIRS } from "./skill-dirs";
|
||||
import { syncCompanionSkill } from "./skill-sync";
|
||||
|
||||
describe("syncCompanionSkill", () => {
|
||||
const dirs: string[] = [];
|
||||
@@ -55,19 +57,23 @@ describe("syncCompanionSkill", () => {
|
||||
* Family-wide guard (#3524 shape): a harness whose installer copies the skill but that SKILL_DIRS
|
||||
* does not map installs it once and then never refreshes it — `npm update -g` upgrades the package
|
||||
* while that host keeps the old SKILL.md until someone re-installs. A per-harness test cannot catch
|
||||
* this, because the harness that forgets is by definition the one whose test nobody wrote. So
|
||||
* enumerate the installer's own call sites instead and assert each is mapped.
|
||||
* this, because the harness that forgets is by definition the one whose test nobody wrote.
|
||||
*
|
||||
* KNOWN_UNMAPPED are pre-existing gaps found while adding qwen-code; they are real defects, listed
|
||||
* so they stay visible and so a NEW harness cannot silently join them.
|
||||
* This once grepped `installSkill(c, "<harness>"` out of installer.ts, with an exemption list for
|
||||
* five hosts it found unmapped. Both halves are gone. The installer now derives every skills
|
||||
* directory from this map, so an unmapped host cannot install at all (skillsBaseFor throws) — and
|
||||
* the pi family, which reaches installSkill through a shared factory with a `harness` variable,
|
||||
* matched no literal and so was invisible to that regex anyway. installer.test.ts owns the forward
|
||||
* direction by behaviour instead: it installs EVERY entry of INSTALLERS, finds where the skill
|
||||
* actually landed, and requires the self-update to refresh that copy.
|
||||
*
|
||||
* What is left here is the reverse direction, which nothing else states: a mapped host must be a
|
||||
* host that exists. A stale entry is a path this module would keep writing to for an agent the
|
||||
* installer no longer supports.
|
||||
*/
|
||||
describe("SKILL_DIRS covers every harness the installer copies the skill into", () => {
|
||||
const KNOWN_UNMAPPED = new Set(["copilot-cli", "grok-build", "cline-cli", "dsh", "prime-agent"]);
|
||||
|
||||
it("maps every installSkill target", () => {
|
||||
const src = readFileSync(new URL("../installer.ts", import.meta.url), "utf8");
|
||||
const targets = [...src.matchAll(/installSkill\(c, "([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(targets.length).toBeGreaterThan(5); // the regex still matches the real call sites
|
||||
expect(targets.filter((h) => !KNOWN_UNMAPPED.has(h) && !(h in SKILL_DIRS))).toEqual([]);
|
||||
describe("SKILL_DIRS maps only real harnesses", () => {
|
||||
it("names no harness the installer does not offer", () => {
|
||||
const installable = new Set(INSTALLERS.map((i) => i.name));
|
||||
expect(Object.keys(SKILL_DIRS).filter((h) => !installable.has(h))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,21 +11,7 @@ import { cpSync, existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Where each host keeps the installed skill, so a session start can refresh it in place.
|
||||
*
|
||||
* MUST list every harness whose installer calls `installSkill` — the structural guard in
|
||||
* skill-sync.test.ts asserts that, with an explicit exemption list for the hosts that predate it
|
||||
* (see there). A harness missing here installs the skill once and then keeps that copy forever.
|
||||
*/
|
||||
export const SKILL_DIRS: Record<string, string[]> = {
|
||||
"claude-code": [".claude", "skills"],
|
||||
codex: [".agents", "skills"], // agentskills-standard shared dir
|
||||
"antigravity-cli": [".gemini", "config", "skills"],
|
||||
"cursor-cli": [".cursor", "skills"],
|
||||
"qwen-code": [".qwen", "skills"], // Qwen's user-level skills root (Storage.getUserSkillsDirs)
|
||||
};
|
||||
import { SKILL_DIRS } from "./skill-dirs";
|
||||
|
||||
/** The packaged skill dir (pkgRoot/skill, resolved relative to the built dist). */
|
||||
function packagedSkillDir(): string {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type PiMessage, readPiMessages, readPiTranscript } from "./transcript-pi";
|
||||
|
||||
describe("readPiMessages", () => {
|
||||
it("keeps user/assistant text + compact action turns; drops other roles/blocks and tool args", () => {
|
||||
const messages: PiMessage[] = [
|
||||
// non-conversational role: dropped
|
||||
{ role: "system", content: [{ type: "text", text: "you are pi" }] },
|
||||
// string content is accepted as a prose turn
|
||||
{ role: "user", content: "add retry backoff to the uploader" },
|
||||
// assistant message: text + a toolCall (args NOT retained) + a dropped reasoning block
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "thinking…" },
|
||||
{ type: "text", text: "I'll add exponential backoff." },
|
||||
{ type: "toolCall", name: "bash", arguments: { command: "npm test" } },
|
||||
],
|
||||
},
|
||||
// assistant message with only a toolCall: just the compact action line
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", name: "read", arguments: { path: "nope.ts" } }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(readPiMessages(messages)).toEqual([
|
||||
{ role: "user", content: "add retry backoff to the uploader" },
|
||||
{ role: "assistant", content: "I'll add exponential backoff." },
|
||||
{ role: "action", content: "bash npm test" },
|
||||
{ role: "action", content: "read nope.ts" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips injected memory that leaks into a kept message", () => {
|
||||
const messages: PiMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "<hindsight_memories>\nleak\n</hindsight_memories>\nWhy retry?" },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(readPiMessages(messages)).toEqual([{ role: "user", content: "Why retry?" }]);
|
||||
});
|
||||
|
||||
it("never throws on malformed entries", () => {
|
||||
const messages = [
|
||||
null,
|
||||
{},
|
||||
{ role: "user" },
|
||||
{ role: "assistant", content: [null, 3, "x"] },
|
||||
] as unknown as PiMessage[];
|
||||
expect(() => readPiMessages(messages)).not.toThrow();
|
||||
expect(readPiMessages(messages)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readPiTranscript", () => {
|
||||
let dir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
});
|
||||
|
||||
function writeLog(...lines: unknown[]): string {
|
||||
dir = mkdtempSync(join(tmpdir(), "hs-pi-log-"));
|
||||
const file = join(dir, "session.jsonl");
|
||||
writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
it("renders a stored session exactly like the live agent_end path, stamped with entry timestamps", () => {
|
||||
const file = writeLog(
|
||||
{ type: "session", version: 3, id: "s1", cwd: "/repo" },
|
||||
// Settings entries sit between messages and carry no conversation.
|
||||
{ type: "model_change", provider: "openai-codex", modelId: "gpt-5.6-sol" },
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-08-24T12:00:01.000Z",
|
||||
message: { role: "user", content: [{ type: "text", text: "add retry backoff" }] },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-08-24T12:00:02.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "internal" },
|
||||
{ type: "text", text: "Adding exponential backoff." },
|
||||
{ type: "toolCall", name: "read", arguments: { path: "uploader.ts" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Tool OUTPUT is mechanical noise, dropped like the Codex reader drops function_call_output.
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-08-24T12:00:03.000Z",
|
||||
message: { role: "toolResult", content: [{ type: "text", text: "file contents" }] },
|
||||
}
|
||||
);
|
||||
|
||||
expect(readPiTranscript(file)).toEqual([
|
||||
{ role: "user", content: "add retry backoff", timestamp: "2026-08-24T12:00:01.000Z" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Adding exponential backoff.",
|
||||
timestamp: "2026-08-24T12:00:02.000Z",
|
||||
},
|
||||
{ role: "action", content: "read uploader.ts", timestamp: "2026-08-24T12:00:02.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
// An imported session must not feed our own injected memory back into the bank — the same
|
||||
// stripInjectedMemory guarantee the live path has, exercised through the file reader.
|
||||
it("strips injected memory from a stored user turn", () => {
|
||||
const file = writeLog(
|
||||
{ type: "session", version: 3, id: "s1", cwd: "/repo" },
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-08-24T12:00:01.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<hindsight_memories>only 429 and 408 retry</hindsight_memories>\nship it",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const turns = readPiTranscript(file);
|
||||
expect(turns).toEqual([
|
||||
{ role: "user", content: "ship it", timestamp: "2026-08-24T12:00:01.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("survives a torn tail line and a missing file instead of throwing", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hs-pi-log-"));
|
||||
const file = join(dir, "session.jsonl");
|
||||
writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify({ type: "session", id: "s1", cwd: "/repo" })}\n` +
|
||||
`${JSON.stringify({ type: "message", message: { role: "user", content: "kept" } })}\n` +
|
||||
`{"type":"message","message":{"role":"assis`
|
||||
);
|
||||
expect(readPiTranscript(file)).toEqual([{ role: "user", content: "kept" }]);
|
||||
expect(readPiTranscript(join(dir, "gone.jsonl"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* pi live-transcript normalizer — shared by pi and its fork Prime Agent.
|
||||
*
|
||||
* These hosts hand an extension the completed exchange as an in-memory message list on the
|
||||
* `agent_end` event (not a JSONL file like Claude/Codex), so this is a pure function over that list,
|
||||
* mirroring transcript-opencode.ts. It produces the same rich `TransportTurn[]` shape (prose turns +
|
||||
* compact `role:"action"` tool turns) and reuses the shared `stripInjectedMemory`/`actionLine`
|
||||
* helpers so a retain never feeds injected memory back into recall and tool noise stays out of the
|
||||
* bank.
|
||||
*/
|
||||
import type { TransportTurn } from "./chat";
|
||||
import { readJsonlTail } from "./jsonl";
|
||||
import { actionLine, stripInjectedMemory } from "./transcript-util";
|
||||
|
||||
/** Structural subset of a pi message content block (TextContent | ToolCall | dropped). */
|
||||
export interface PiBlock {
|
||||
type?: string;
|
||||
text?: string; // text block
|
||||
name?: string; // toolCall block: the tool name
|
||||
arguments?: unknown; // toolCall block: the call input
|
||||
}
|
||||
|
||||
/** Structural subset of a pi message ({ role, content }). */
|
||||
export interface PiMessage {
|
||||
role?: string;
|
||||
content?: unknown; // string | PiBlock[]
|
||||
}
|
||||
|
||||
/** Structural subset of one line of a stored pi session (`~/.pi/agent/sessions/**\/*.jsonl`).
|
||||
* Conversation lives in `type:"message"` entries; the file's first line is the `type:"session"`
|
||||
* header (id + cwd) and the rest are settings changes, which carry no conversation. */
|
||||
interface PiEntry {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
message?: PiMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one pi message into turns. Text (string content or text blocks) joins into one
|
||||
* prose turn (injected-memory stripped); each `toolCall` block becomes its own compact
|
||||
* `role:"action"` turn (tool name + primary target via `actionLine` — no args, no output). Other
|
||||
* block types and non-conversational roles are dropped.
|
||||
*/
|
||||
function renderMessage(m: PiMessage): TransportTurn[] {
|
||||
if (!m || typeof m !== "object") return [];
|
||||
const role = m.role;
|
||||
if (role !== "user" && role !== "assistant") return [];
|
||||
|
||||
const texts: string[] = [];
|
||||
const actions: TransportTurn[] = [];
|
||||
|
||||
if (typeof m.content === "string") {
|
||||
const t = stripInjectedMemory(m.content).trim();
|
||||
if (t) texts.push(t);
|
||||
} else if (Array.isArray(m.content)) {
|
||||
for (const part of m.content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const block = part as PiBlock;
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
const t = stripInjectedMemory(block.text).trim();
|
||||
if (t) texts.push(t);
|
||||
} else if (block.type === "toolCall" && typeof block.name === "string") {
|
||||
actions.push({ role: "action", content: actionLine(block.name, block.arguments) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out: TransportTurn[] = [];
|
||||
const joined = texts.join("\n").trim();
|
||||
if (joined) out.push({ role, content: joined });
|
||||
out.push(...actions);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pi `agent_end` message list into transcript turns (user/assistant prose plus
|
||||
* compact action turns for tool calls). Never throws on malformed entries.
|
||||
*/
|
||||
export function readPiMessages(messages: readonly PiMessage[]): TransportTurn[] {
|
||||
return (messages || []).flatMap((m) => renderMessage(m));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a STORED pi session file (the history-import path) into the same turns the live `agent_end`
|
||||
* path produces.
|
||||
*
|
||||
* pi and its fork Prime Agent both persist a session as JSONL whose conversation entries wrap
|
||||
* exactly the message objects the live event hands over, so this shares `renderMessage` with
|
||||
* readPiMessages rather than re-deriving the normalization — an imported session and a live one
|
||||
* must reach the bank identically. `toolResult` entries are dropped with every other
|
||||
* non-conversational role, matching how the Codex reader drops `function_call_output`.
|
||||
*
|
||||
* Bounded and fail-open like the other stored-transcript readers: a missing file, a torn line or a
|
||||
* transcript past the size cap yields fewer turns, never a throw (see core/jsonl.ts).
|
||||
*/
|
||||
export function readPiTranscript(path: string): TransportTurn[] {
|
||||
const turns: TransportTurn[] = [];
|
||||
for (const rawLine of readJsonlTail(path, { scope: "pi" }).lines) {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) continue;
|
||||
const entry = parsed as PiEntry;
|
||||
if (entry.type !== "message" || !entry.message) continue;
|
||||
// The entry's timestamp, not the inner message's: the outer one is the ISO string every other
|
||||
// reader's turns carry, while the inner is epoch milliseconds.
|
||||
for (const turn of renderMessage(entry.message)) {
|
||||
turns.push(entry.timestamp ? { ...turn, timestamp: entry.timestamp } : turn);
|
||||
}
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type PaMessage, readPrimeAgentMessages } from "./transcript-prime-agent";
|
||||
|
||||
describe("readPrimeAgentMessages", () => {
|
||||
it("keeps user/assistant text + compact action turns; drops other roles/blocks and tool args", () => {
|
||||
const messages: PaMessage[] = [
|
||||
// non-conversational role: dropped
|
||||
{ role: "system", content: [{ type: "text", text: "you are prime-agent" }] },
|
||||
// string content is accepted as a prose turn
|
||||
{ role: "user", content: "add retry backoff to the uploader" },
|
||||
// assistant message: text + a toolCall (args NOT retained) + a dropped reasoning block
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "thinking…" },
|
||||
{ type: "text", text: "I'll add exponential backoff." },
|
||||
{ type: "toolCall", name: "bash", arguments: { command: "npm test" } },
|
||||
],
|
||||
},
|
||||
// assistant message with only a toolCall: just the compact action line
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", name: "read", arguments: { path: "nope.ts" } }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(readPrimeAgentMessages(messages)).toEqual([
|
||||
{ role: "user", content: "add retry backoff to the uploader" },
|
||||
{ role: "assistant", content: "I'll add exponential backoff." },
|
||||
{ role: "action", content: "bash npm test" },
|
||||
{ role: "action", content: "read nope.ts" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips injected memory that leaks into a kept message", () => {
|
||||
const messages: PaMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "<hindsight_memories>\nleak\n</hindsight_memories>\nWhy retry?" },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(readPrimeAgentMessages(messages)).toEqual([{ role: "user", content: "Why retry?" }]);
|
||||
});
|
||||
|
||||
it("never throws on malformed entries", () => {
|
||||
const messages = [
|
||||
null,
|
||||
{},
|
||||
{ role: "user" },
|
||||
{ role: "assistant", content: [null, 3, "x"] },
|
||||
] as unknown as PaMessage[];
|
||||
expect(() => readPrimeAgentMessages(messages)).not.toThrow();
|
||||
expect(readPrimeAgentMessages(messages)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Prime Agent live-transcript normalizer.
|
||||
*
|
||||
* Prime Agent hands an extension the completed exchange as an in-memory message list on the
|
||||
* `agent_end` event (not a JSONL file like Claude/Codex), so this is a pure function over that list,
|
||||
* mirroring transcript-opencode.ts. It produces the same rich `TransportTurn[]` shape (prose turns +
|
||||
* compact `role:"action"` tool turns) and reuses the shared `stripInjectedMemory`/`actionLine`
|
||||
* helpers so a retain never feeds injected memory back into recall and tool noise stays out of the
|
||||
* bank.
|
||||
*/
|
||||
import type { TransportTurn } from "./chat";
|
||||
import { actionLine, stripInjectedMemory } from "./transcript-util";
|
||||
|
||||
/** Structural subset of a Prime Agent message content block (TextBlock | ToolCallBlock | dropped). */
|
||||
export interface PaBlock {
|
||||
type?: string;
|
||||
text?: string; // text block
|
||||
name?: string; // toolCall block: the tool name
|
||||
arguments?: unknown; // toolCall block: the call input
|
||||
}
|
||||
|
||||
/** Structural subset of a Prime Agent message ({ role, content }). */
|
||||
export interface PaMessage {
|
||||
role?: string;
|
||||
content?: unknown; // string | PaBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one Prime Agent message into turns. Text (string content or text blocks) joins into one
|
||||
* prose turn (injected-memory stripped); each `toolCall` block becomes its own compact
|
||||
* `role:"action"` turn (tool name + primary target via `actionLine` — no args, no output). Other
|
||||
* block types and non-conversational roles are dropped.
|
||||
*/
|
||||
function renderMessage(m: PaMessage): TransportTurn[] {
|
||||
if (!m || typeof m !== "object") return [];
|
||||
const role = m.role;
|
||||
if (role !== "user" && role !== "assistant") return [];
|
||||
|
||||
const texts: string[] = [];
|
||||
const actions: TransportTurn[] = [];
|
||||
|
||||
if (typeof m.content === "string") {
|
||||
const t = stripInjectedMemory(m.content).trim();
|
||||
if (t) texts.push(t);
|
||||
} else if (Array.isArray(m.content)) {
|
||||
for (const part of m.content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const block = part as PaBlock;
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
const t = stripInjectedMemory(block.text).trim();
|
||||
if (t) texts.push(t);
|
||||
} else if (block.type === "toolCall" && typeof block.name === "string") {
|
||||
actions.push({ role: "action", content: actionLine(block.name, block.arguments) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out: TransportTurn[] = [];
|
||||
const joined = texts.join("\n").trim();
|
||||
if (joined) out.push({ role, content: joined });
|
||||
out.push(...actions);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Prime Agent's `agent_end` message list into transcript turns (user/assistant prose plus
|
||||
* compact action turns for tool calls). Never throws on malformed entries.
|
||||
*/
|
||||
export function readPrimeAgentMessages(messages: readonly PaMessage[]): TransportTurn[] {
|
||||
return (messages || []).flatMap((m) => renderMessage(m));
|
||||
}
|
||||
@@ -244,6 +244,20 @@ export const clineDockerSetup: HarnessDockerSetup = {
|
||||
command: (prompt) => ["cline", "--auto-approve", "true", "--cwd", "/workspace", prompt],
|
||||
};
|
||||
|
||||
/**
|
||||
* pi — extension host, and the upstream Prime Agent forked from. `-p` runs one prompt
|
||||
* non-interactively and prints the reply, and `~/.pi/agent/auth.json` carries the provider
|
||||
* credentials on its own.
|
||||
*/
|
||||
export const piDockerSetup: HarnessDockerSetup = {
|
||||
name: "pi",
|
||||
hindsightHarness: "pi",
|
||||
credentialPath: () => authPath("PI_E2E_AUTH_PATH", ".pi", "agent", "auth.json"),
|
||||
credentialTarget: "/root/.pi/agent/auth.json",
|
||||
installCommand: "hindsight-coding-agents install pi",
|
||||
command: (prompt) => ["pi", "-p", prompt],
|
||||
};
|
||||
|
||||
/**
|
||||
* Prime Agent — extension host. `-p` runs one prompt non-interactively and prints the reply.
|
||||
* Auth is the whole `~/.prime/agent` directory (auth.json plus the kernel venv it provisions on
|
||||
@@ -300,6 +314,7 @@ export const ALL_HARNESS_SETUPS: HarnessDockerSetup[] = [
|
||||
qwenDockerSetup,
|
||||
devinDockerSetup,
|
||||
clineDockerSetup,
|
||||
piDockerSetup,
|
||||
primeAgentDockerSetup,
|
||||
dshDockerSetup,
|
||||
];
|
||||
|
||||
+12
-12
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ToolSpec } from "./core/knowledge-tools";
|
||||
import { createPrimeAgentHooks, toPrimeAgentTool } from "./prime-agent";
|
||||
import type { ToolSpec } from "../core/knowledge-tools";
|
||||
import { createPiHooks, toPiTool } from "./pi-extension";
|
||||
|
||||
describe("Prime Agent extension adapter", () => {
|
||||
describe("pi extension adapter", () => {
|
||||
it("recalls on each prompt and appends the injection to the system prompt", async () => {
|
||||
const onPrompt = vi.fn(async () => {});
|
||||
const core = {
|
||||
@@ -11,17 +11,17 @@ describe("Prime Agent extension adapter", () => {
|
||||
getInjection: vi.fn(() => "<hindsight_memories>remember this</hindsight_memories>"),
|
||||
onTranscript: vi.fn(async () => {}),
|
||||
};
|
||||
const hooks = createPrimeAgentHooks(core as never);
|
||||
const hooks = createPiHooks(core as never, "pi");
|
||||
|
||||
const result = await hooks.beforeAgentStart(
|
||||
{ prompt: " plan the change ", systemPrompt: "You are Prime Agent." },
|
||||
{ prompt: " plan the change ", systemPrompt: "You are pi." },
|
||||
"session-1"
|
||||
);
|
||||
|
||||
expect(onPrompt).toHaveBeenCalledOnce();
|
||||
expect(onPrompt).toHaveBeenCalledWith("session-1", "plan the change");
|
||||
expect(result?.systemPrompt).toBe(
|
||||
"You are Prime Agent.\n\n<hindsight_memories>remember this</hindsight_memories>"
|
||||
"You are pi.\n\n<hindsight_memories>remember this</hindsight_memories>"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("Prime Agent extension adapter", () => {
|
||||
getInjection: vi.fn(() => undefined),
|
||||
onTranscript: vi.fn(async () => {}),
|
||||
};
|
||||
const hooks = createPrimeAgentHooks(core as never);
|
||||
const hooks = createPiHooks(core as never, "pi");
|
||||
const result = await hooks.beforeAgentStart({ prompt: "hi", systemPrompt: "sys" }, "session-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("Prime Agent extension adapter", () => {
|
||||
getInjection: vi.fn(() => undefined),
|
||||
onTranscript: vi.fn(async () => {}),
|
||||
};
|
||||
const hooks = createPrimeAgentHooks(core as never, sessionStart);
|
||||
const hooks = createPiHooks(core as never, "pi", sessionStart);
|
||||
const pending = hooks.beforeAgentStart(
|
||||
{ prompt: "first prompt", systemPrompt: "sys" },
|
||||
"session-1"
|
||||
@@ -66,7 +66,7 @@ describe("Prime Agent extension adapter", () => {
|
||||
getInjection: vi.fn(() => undefined),
|
||||
onTranscript,
|
||||
};
|
||||
const hooks = createPrimeAgentHooks(core as never);
|
||||
const hooks = createPiHooks(core as never, "pi");
|
||||
|
||||
await hooks.agentEnd(
|
||||
{
|
||||
@@ -98,12 +98,12 @@ describe("Prime Agent extension adapter", () => {
|
||||
getInjection: vi.fn(() => undefined),
|
||||
onTranscript,
|
||||
};
|
||||
const hooks = createPrimeAgentHooks(core as never);
|
||||
const hooks = createPiHooks(core as never, "pi");
|
||||
await hooks.agentEnd({ messages: [] }, "session-1");
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adapts a knowledge ToolSpec into a Prime Agent native tool with a JSON-Schema parameters object", async () => {
|
||||
it("adapts a knowledge ToolSpec into a pi native tool with a JSON-Schema parameters object", async () => {
|
||||
const spec: ToolSpec = {
|
||||
name: "hindsight_search_knowledge_pages",
|
||||
description: "Search the knowledge pages",
|
||||
@@ -117,7 +117,7 @@ describe("Prime Agent extension adapter", () => {
|
||||
handler: async () => ({ content: [{ type: "text", text: "page A\npage B" }] }),
|
||||
};
|
||||
|
||||
const def = toPrimeAgentTool(spec);
|
||||
const def = toPiTool(spec);
|
||||
expect(def.name).toBe("hindsight_search_knowledge_pages");
|
||||
expect(def.label).toBe("hindsight_search_knowledge_pages");
|
||||
expect(def.parameters.type).toBe("object");
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Shared entrypoint factory for the pi-family EXTENSION hosts (pi and its fork Prime Agent).
|
||||
*
|
||||
* pi (`@earendil-works/pi-coding-agent`) loads extensions listed in `~/.pi/agent/settings.json`;
|
||||
* Prime Agent (PrimeIntellect) is a fork of pi and loads the same shape from
|
||||
* `~/.prime/agent/settings.json`. Both call an extension's default export with their `pi` API, and
|
||||
* both expose the identical surface this adapter needs: `before_agent_start` (recall +
|
||||
* system-prompt injection), `agent_end` (transcript write-back), and `registerTool` for the native
|
||||
* `hindsight_*` knowledge tools. So neither host needs an adapter of its own — they differ only in
|
||||
* which harness name they report, which selects the `harnesses.<name>` config section, feeds
|
||||
* `{harness}` bank templating, and keeps their sessions attributable in diagnostics separately.
|
||||
*
|
||||
* The memory behaviour itself stays in RuntimeCore — the same reflect-and-inject core every
|
||||
* Hindsight harness uses; this file only adapts the pi extension API at its boundary.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { resolveHostMemory } from "../core/host-client";
|
||||
import { diag } from "../core/diag";
|
||||
import type { ToolSpec } from "../core/knowledge-tools";
|
||||
import { RuntimeCore } from "../core/runtime";
|
||||
import { type PiMessage, readPiMessages } from "../core/transcript-pi";
|
||||
|
||||
// ── Structural subset of the pi extension API ───────────────────────────────────────────────────
|
||||
// Declared locally so this package takes no dependency on the fast-moving pi / Prime Agent SDKs;
|
||||
// the real runtime passes a compatible object at load time.
|
||||
|
||||
interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
/** The raw user prompt text. */
|
||||
prompt: string;
|
||||
/** The fully assembled system prompt for this turn. */
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
/** The conversation messages for the completed agent loop. */
|
||||
messages: readonly PiMessage[];
|
||||
}
|
||||
|
||||
interface BeforeAgentStartResult {
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
interface SessionManagerLike {
|
||||
getSessionId(): string;
|
||||
}
|
||||
|
||||
/** Only what this adapter reads. Both hosts also expose a UI notifier, but the seed banner it would
|
||||
* carry is raised inside seedIfCold at extension load, before any handler has a `ctx` to notify
|
||||
* through — so it is logged rather than toasted here, and the field is not declared. */
|
||||
interface ExtensionContext {
|
||||
sessionManager: SessionManagerLike;
|
||||
}
|
||||
|
||||
/** A JSON-Schema-shaped parameters object. The host forwards it to the model provider verbatim. */
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
interface ToolDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: JsonSchema;
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ content: { type: "text"; text: string }[]; details: unknown }>;
|
||||
}
|
||||
|
||||
interface ExtensionAPI {
|
||||
on(
|
||||
event: "before_agent_start",
|
||||
handler: (
|
||||
event: BeforeAgentStartEvent,
|
||||
ctx: ExtensionContext
|
||||
) => Promise<BeforeAgentStartResult | void> | BeforeAgentStartResult | void
|
||||
): void;
|
||||
on(
|
||||
event: "agent_end",
|
||||
handler: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void> | void
|
||||
): void;
|
||||
registerTool(definition: ToolDefinition): void;
|
||||
}
|
||||
|
||||
export type ExtensionFactory = (pi: ExtensionAPI) => void;
|
||||
|
||||
/**
|
||||
* Adapt a harness-agnostic ToolSpec (MCP-shaped, shared by every harness) to a pi native tool. The
|
||||
* spec's Zod raw shape is converted to a JSON Schema for `parameters` — pi's documented type is a
|
||||
* TypeBox `TSchema`, which is itself a JSON Schema, and neither host validates tool arguments
|
||||
* against it (the agent loop only runs an optional `prepareArguments`), so the schema is passed
|
||||
* straight to the model provider. A plain JSON Schema is therefore exactly what the tool needs. The
|
||||
* spec's handler returns an MCP `{content:[{text}]}` result and never throws, so we surface the
|
||||
* joined text back to the model.
|
||||
*/
|
||||
export function toPiTool(spec: ToolSpec): ToolDefinition {
|
||||
const parameters = z.toJSONSchema(z.object(spec.inputSchema)) as JsonSchema;
|
||||
return {
|
||||
name: spec.name,
|
||||
label: spec.name,
|
||||
description: spec.description,
|
||||
parameters,
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const r = await spec.handler(params);
|
||||
const text = r.content?.map((c) => c.text).join("\n") || "";
|
||||
return { content: [{ type: "text", text }], details: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the host-specific pi hooks testable without importing either host's SDK. RuntimeCore is the
|
||||
* shared lifecycle implementation; this adapter only converts pi messages at its boundary and never
|
||||
* calls Hindsight directly.
|
||||
*/
|
||||
export function createPiHooks(
|
||||
core: Pick<RuntimeCore, "onPrompt" | "getInjection" | "onTranscript">,
|
||||
harness: string,
|
||||
sessionStart?: Promise<void>
|
||||
) {
|
||||
let sessionStartAwaited = false;
|
||||
return {
|
||||
async beforeAgentStart(
|
||||
event: { prompt: string; systemPrompt: string },
|
||||
sessionId: string
|
||||
): Promise<BeforeAgentStartResult | undefined> {
|
||||
if (!sessionStartAwaited) {
|
||||
sessionStartAwaited = true;
|
||||
// Awaiting the shared SessionStart lifecycle before the first prompt preserves the invariant
|
||||
// that a brand-new bank skips its first auto-reflect instead of spending that synthesis
|
||||
// before it has any knowledge (mirrors the other harnesses).
|
||||
await sessionStart;
|
||||
}
|
||||
const prompt = event.prompt.trim();
|
||||
if (prompt) await core.onPrompt(sessionId, prompt);
|
||||
const injection = core.getInjection(sessionId);
|
||||
if (!injection) {
|
||||
diag(harness, "inject_empty", { session: sessionId });
|
||||
return undefined;
|
||||
}
|
||||
diag(harness, "inject_ok", { session: sessionId, chars: injection.length });
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${injection}` };
|
||||
},
|
||||
async agentEnd(event: { messages: readonly PiMessage[] }, sessionId: string): Promise<void> {
|
||||
const turns = readPiMessages(event.messages);
|
||||
if (turns.length) await core.onTranscript(sessionId, turns);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(harness: string, repoPath: string): RuntimeCore | undefined {
|
||||
const { cfg, bankId, client } = resolveHostMemory(harness, repoPath);
|
||||
if (cfg.disabled) return undefined; // global switch, per-bank opt-out or optInOnly
|
||||
return new RuntimeCore(client, bankId, cfg, harness, repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default export for a pi-family extension host. `harness` is the name the host is known
|
||||
* by ("pi", "prime-agent"), used for config lookup, bank derivation and diagnostics scoping — it is
|
||||
* NOT config-chosen; the entrypoint the host loaded determines it.
|
||||
*
|
||||
* The returned factory is called once per session in the project directory; it resolves config and
|
||||
* bank from `process.cwd()`, registers the knowledge tools, kicks off the shared cold-seed, and
|
||||
* wires the recall/retain hooks.
|
||||
*/
|
||||
export function createPiExtension(harness: string): ExtensionFactory {
|
||||
return (pi) => {
|
||||
const repoPath = process.cwd();
|
||||
const core = createRuntime(harness, repoPath);
|
||||
if (!core) return;
|
||||
|
||||
for (const spec of core.toolSpecs()) pi.registerTool(toPiTool(spec));
|
||||
|
||||
// Fire-and-forget cold seed (bank check + background git seed + knowledge preamble); the first
|
||||
// before_agent_start awaits it via createPiHooks.
|
||||
const sessionStart = core.seedIfCold(repoPath);
|
||||
const hooks = createPiHooks(core, harness, sessionStart);
|
||||
|
||||
pi.on("before_agent_start", (event, ctx) =>
|
||||
hooks.beforeAgentStart(event, ctx.sessionManager.getSessionId())
|
||||
);
|
||||
pi.on("agent_end", (event, ctx) => hooks.agentEnd(event, ctx.sessionManager.getSessionId()));
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getHarness, HARNESS_NAMES } from "./registry";
|
||||
import { getHarness, HARNESS_NAMES, PLUGIN_ENTRYPOINTS } from "./registry";
|
||||
|
||||
describe("HARNESS_NAMES", () => {
|
||||
it("lists all registered harnesses", () => {
|
||||
@@ -9,6 +12,7 @@ describe("HARNESS_NAMES", () => {
|
||||
"opencode2",
|
||||
"kilo",
|
||||
"cline-cli",
|
||||
"pi",
|
||||
"prime-agent",
|
||||
"dsh",
|
||||
"claude-code",
|
||||
@@ -22,7 +26,7 @@ describe("HARNESS_NAMES", () => {
|
||||
"qwen-code",
|
||||
])
|
||||
);
|
||||
expect(HARNESS_NAMES).toHaveLength(15);
|
||||
expect(HARNESS_NAMES).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +73,20 @@ describe("getHarness", () => {
|
||||
expect(() => adapter.createRuntime({} as never)).toThrow(/src\/cline\.ts/);
|
||||
});
|
||||
|
||||
it("resolves pi and its Prime Agent fork as separate extension harnesses", async () => {
|
||||
for (const [name, entry] of [
|
||||
["pi", /src\/pi\.ts/],
|
||||
["prime-agent", /src\/prime-agent\.ts/],
|
||||
] as const) {
|
||||
const adapter = await getHarness(name);
|
||||
expect(adapter.name).toBe(name);
|
||||
// Both drive the same shared adapter (harness/pi-extension.ts) but are wired by their own
|
||||
// entrypoint, so each must resolve to its own dist bundle — swapping them would silently
|
||||
// retain one host's sessions under the other's bank.
|
||||
expect(() => adapter.createRuntime({} as never)).toThrow(entry);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves DeepSeek Harness as a native Cordis-plugin harness", async () => {
|
||||
const adapter = await getHarness("dsh");
|
||||
expect(adapter.name).toBe("dsh");
|
||||
@@ -105,3 +123,32 @@ describe("registry covers every installable harness", () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The harness name an entrypoint reports is a bare string literal that nothing else checks: it
|
||||
* selects the `harnesses.<name>` config section, feeds `{harness}` bank templating, and is stamped
|
||||
* on every document that host retains. A typo there ships green — the registry, the installer and
|
||||
* the control plane's logo map are three SEPARATE hand-maintained lists, so none of them notices
|
||||
* that the running plugin calls itself something else.
|
||||
*
|
||||
* So assert it over the whole family, enumerated from the registry rather than a fourth list: every
|
||||
* entrypoint the registry names must report the harness the registry maps it to.
|
||||
*/
|
||||
describe("every plugin entrypoint reports the harness the registry maps it to", () => {
|
||||
const PKG = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
/** The two idioms an entrypoint uses to name itself: the argument it hands its shared factory
|
||||
* (opencode/Kilo via createPluginEntry, opencode 2 via createOpencode2PluginEntry, pi/Prime Agent
|
||||
* via createPiExtension), or its own module constant (Cline and dsh, which build their runtime
|
||||
* themselves). Matching the factory NAMES rather than a generic call keeps the guard honest: an
|
||||
* entrypoint that names itself some new way fails here until this list is widened, which is
|
||||
* exactly the review that a new host needs. */
|
||||
const DECLARES_HARNESS =
|
||||
/(?:createPluginEntry|createOpencode2PluginEntry|createPiExtension)\("([^"]+)"\)|const HARNESS = "([^"]+)"/;
|
||||
|
||||
it.each(Object.entries(PLUGIN_ENTRYPOINTS))("%s (%s)", (harness, entry) => {
|
||||
const declaration = readFileSync(join(PKG, entry), "utf8").match(DECLARES_HARNESS);
|
||||
expect(declaration, `${entry} names no harness in a form this guard recognises`).not.toBeNull();
|
||||
expect(declaration![1] ?? declaration![2]).toBe(harness);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,9 @@ export const HARNESS_NAMES = [
|
||||
// DeepSeek Harness loads dist/dsh.js as a native Cordis plugin (src/dsh.ts). Its Claude Code /
|
||||
// Codex hook bridges are optional packages, so there is no hook binary to install either.
|
||||
"dsh",
|
||||
// Prime Agent loads dist/prime-agent.js as an extension (src/prime-agent.ts); no hook binary.
|
||||
// pi loads dist/pi.js as an extension (src/pi.ts), and Prime Agent — a fork of pi — loads
|
||||
// dist/prime-agent.js the same way (src/prime-agent.ts). Neither has a hook binary.
|
||||
"pi",
|
||||
"prime-agent",
|
||||
"claude-code",
|
||||
"cursor-cli",
|
||||
@@ -77,12 +79,15 @@ const HOOK_BINS: Record<string, string> = {
|
||||
// more hook harnesses: add a HookSpec entry point (see src/cursor-hook.ts) + a registration here.
|
||||
};
|
||||
|
||||
/** Where each persistent-plugin harness's runtime is actually built (see the branch below). */
|
||||
const PLUGIN_ENTRYPOINTS: Record<string, string> = {
|
||||
/** Where each persistent-plugin harness's runtime is actually built (see the branch below).
|
||||
* Exported so a test can assert, over the whole family, that each of those files reports the
|
||||
* harness mapped to it here — see registry.test.ts. */
|
||||
export const PLUGIN_ENTRYPOINTS: Record<string, string> = {
|
||||
opencode: "src/index.ts",
|
||||
opencode2: "src/opencode2.ts",
|
||||
kilo: "src/kilo.ts",
|
||||
"cline-cli": "src/cline.ts",
|
||||
pi: "src/pi.ts",
|
||||
"prime-agent": "src/prime-agent.ts",
|
||||
dsh: "src/dsh.ts",
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { INSTALLERS, MARKER, parseJsonc, run, type InstallCtx } from "./installer";
|
||||
import { SKILL_DIRS } from "./core/skill-dirs";
|
||||
|
||||
// Every test gets a FRESH temp dir as ctx.home (never the real $HOME) and a stubbed
|
||||
// claudeMcp so the real `claude` CLI is never executed. run() is always called with
|
||||
@@ -751,37 +752,153 @@ describe("opencode installer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("prime-agent installer", () => {
|
||||
const cfgPath = (ctx: InstallCtx) => join(ctx.home, ".prime", "agent", "settings.json");
|
||||
const entry = (ctx: InstallCtx) => join(ctx.pkgRoot, "dist", "prime-agent.js");
|
||||
// pi and Prime Agent share one installer factory but must stay independently wired: each writes
|
||||
// only its own settings.json, and each registers the bundle that reports its own harness.
|
||||
describe.each([
|
||||
{ harness: "pi", dir: [".pi", "agent"] },
|
||||
{ harness: "prime-agent", dir: [".prime", "agent"] },
|
||||
])("$harness installer", ({ harness, dir }) => {
|
||||
const cfgPath = (ctx: InstallCtx) => join(ctx.home, ...dir, "settings.json");
|
||||
const entry = (ctx: InstallCtx) => join(ctx.pkgRoot, "dist", `${harness}.js`);
|
||||
|
||||
it("install adds the built extension to the extensions array exactly once, even across reinstalls", () => {
|
||||
const ctx = makeCtx();
|
||||
expect(run(["install", "prime-agent"], ctx)).toBe(0);
|
||||
run(["install", "prime-agent"], ctx);
|
||||
expect(run(["install", harness], ctx)).toBe(0);
|
||||
run(["install", harness], ctx);
|
||||
expect(readJson(cfgPath(ctx)).extensions).toEqual([entry(ctx)]);
|
||||
});
|
||||
|
||||
it("preserves other extension entries", () => {
|
||||
const ctx = makeCtx();
|
||||
writeJsonAt(cfgPath(ctx), { extensions: ["/some/other/ext.js"] });
|
||||
run(["install", "prime-agent"], ctx);
|
||||
run(["install", harness], ctx);
|
||||
expect(readJson(cfgPath(ctx)).extensions).toEqual(["/some/other/ext.js", entry(ctx)]);
|
||||
});
|
||||
|
||||
it("uninstall removes our entry and deletes the extensions key when empty", () => {
|
||||
const ctx = makeCtx();
|
||||
run(["install", "prime-agent"], ctx);
|
||||
run(["uninstall", "prime-agent"], ctx);
|
||||
run(["install", harness], ctx);
|
||||
run(["uninstall", harness], ctx);
|
||||
expect(readJson(cfgPath(ctx)).extensions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uninstall keeps the extensions key when other entries remain", () => {
|
||||
const ctx = makeCtx();
|
||||
writeJsonAt(cfgPath(ctx), { extensions: ["/some/other/ext.js"] });
|
||||
run(["install", harness], ctx);
|
||||
run(["uninstall", harness], ctx);
|
||||
expect(readJson(cfgPath(ctx)).extensions).toEqual(["/some/other/ext.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pi-family companion skill", () => {
|
||||
// Both hosts discover their own skills root AND the shared `~/.agents/skills`. We write the OWN
|
||||
// directory: the shared root is where Codex and dsh install, and uninstallSkill removes by a
|
||||
// fixed name, so installing there would make `uninstall pi` delete their copy too.
|
||||
const HOSTS: [string, string[]][] = [
|
||||
["pi", [".pi", "agent", "skills"]],
|
||||
["prime-agent", [".prime", "agent", "skills"]],
|
||||
];
|
||||
|
||||
/** makeCtx's pkgRoot is a synthetic /opt path that does not exist, so the packaged skill can't be
|
||||
* staged in it. Build a real temp package root instead, like the cross-host skill test below. */
|
||||
function ctxWithSkill(): InstallCtx {
|
||||
const home = mkdtempSync(join(tmpdir(), "hs-inst-pi-skill-"));
|
||||
homes.push(home);
|
||||
const pkgRoot = mkdtempSync(join(tmpdir(), "hs-pkg-"));
|
||||
homes.push(pkgRoot);
|
||||
mkdirSync(join(pkgRoot, "skill"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(pkgRoot, "skill", "SKILL.md"),
|
||||
"---\nname: hindsight-coding-agent\n---\nbody"
|
||||
);
|
||||
return { home, pkgRoot, dist: join(pkgRoot, "dist"), claudeMcp: vi.fn(() => true) };
|
||||
}
|
||||
|
||||
it.each(HOSTS)(
|
||||
"%s installs the packaged skill into its own skills directory and removes it on uninstall",
|
||||
(harness, dir) => {
|
||||
const ctx = ctxWithSkill();
|
||||
const base = join(ctx.home, ...dir);
|
||||
run(["install", harness], ctx);
|
||||
expect(existsSync(join(base, "hindsight-coding-agent", "SKILL.md"))).toBe(true);
|
||||
run(["uninstall", harness], ctx);
|
||||
expect(existsSync(join(base, "hindsight-coding-agent"))).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(HOSTS)(
|
||||
"uninstalling %s cannot strip Codex's copy from the shared ~/.agents/skills root",
|
||||
(harness) => {
|
||||
const ctx = ctxWithSkill();
|
||||
run(["install", "codex"], ctx);
|
||||
const shared = join(ctx.home, ".agents", "skills", "hindsight-coding-agent");
|
||||
expect(existsSync(shared)).toBe(true);
|
||||
|
||||
run(["install", harness], ctx);
|
||||
run(["uninstall", harness], ctx);
|
||||
expect(existsSync(shared)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
// The two hosts write DIFFERENT roots, so one's uninstall must not touch the other's skill —
|
||||
// the same independence the settings files already have.
|
||||
it("installing both gives each its own copy, and uninstalling one leaves the other", () => {
|
||||
const ctx = ctxWithSkill();
|
||||
run(["install", "pi"], ctx);
|
||||
run(["install", "prime-agent"], ctx);
|
||||
const primeSkill = join(ctx.home, ".prime", "agent", "skills", "hindsight-coding-agent");
|
||||
expect(existsSync(primeSkill)).toBe(true);
|
||||
|
||||
run(["uninstall", "pi"], ctx);
|
||||
expect(existsSync(primeSkill)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* pi and Prime Agent both read `pkg.pi.extensions` when this package is installed as a distributed
|
||||
* pi package, so that key can only ever name one bundle — and the host it did not name loads the
|
||||
* other's, reports the wrong harness, and stamps it on every document it retains. It named Prime
|
||||
* Agent's until pi got an entry of its own, which is exactly how pi mis-attributed.
|
||||
*
|
||||
* `hindsight-coding-agents install pi|prime-agent` is the supported route for both, so the key is
|
||||
* gone. Re-adding it would be silent: nothing in this package reads it, the wrong attribution only
|
||||
* shows up later on retained documents, and it looks like the obvious way to support `pi install`.
|
||||
*/
|
||||
describe("the published manifest", () => {
|
||||
it("carries no `pi` key, which could only ever be right for one of the two hosts", () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
||||
) as Record<string, unknown>;
|
||||
expect(manifest.pi).toBeUndefined();
|
||||
// The single-host manifest keys are fine and stay: exactly one harness reads each.
|
||||
expect(manifest.dsh).toBeDefined();
|
||||
expect(manifest.cline).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pi and prime-agent do not disturb each other", () => {
|
||||
const piCfg = (ctx: InstallCtx) => join(ctx.home, ".pi", "agent", "settings.json");
|
||||
const primeCfg = (ctx: InstallCtx) => join(ctx.home, ".prime", "agent", "settings.json");
|
||||
|
||||
it("wires each host to its own bundle and leaves the other config untouched", () => {
|
||||
const ctx = makeCtx();
|
||||
run(["install", "pi"], ctx);
|
||||
expect(existsSync(primeCfg(ctx))).toBe(false);
|
||||
|
||||
run(["install", "prime-agent"], ctx);
|
||||
expect(readJson(piCfg(ctx)).extensions).toEqual([join(ctx.pkgRoot, "dist", "pi.js")]);
|
||||
expect(readJson(primeCfg(ctx)).extensions).toEqual([
|
||||
join(ctx.pkgRoot, "dist", "prime-agent.js"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uninstalling one leaves the other wired", () => {
|
||||
const ctx = makeCtx();
|
||||
run(["install", "pi"], ctx);
|
||||
run(["install", "prime-agent"], ctx);
|
||||
run(["uninstall", "prime-agent"], ctx);
|
||||
expect(readJson(cfgPath(ctx)).extensions).toEqual(["/some/other/ext.js"]);
|
||||
expect(readJson(piCfg(ctx)).extensions).toEqual([join(ctx.pkgRoot, "dist", "pi.js")]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1047,6 +1164,7 @@ describe("run() CLI behavior", () => {
|
||||
"opencode",
|
||||
"opencode2",
|
||||
"kilo",
|
||||
"pi",
|
||||
"prime-agent",
|
||||
"claude-code",
|
||||
"codex",
|
||||
@@ -1082,10 +1200,18 @@ describe("MCP registrations name the calling harness", () => {
|
||||
}
|
||||
|
||||
// These hosts have no MCP registration at all: they load our plugin/extension in-process
|
||||
// (src/kilo.ts, src/dsh.ts, src/prime-agent.ts, dist/index.js for opencode, index.js ->
|
||||
// dist/opencode2.js for opencode2), and that entry hands its own harness name straight to
|
||||
// RuntimeCore.
|
||||
const IN_PROCESS = new Set(["opencode", "opencode2", "kilo", "prime-agent", "dsh", "dcode"]);
|
||||
// (src/kilo.ts, src/dsh.ts, src/pi.ts, src/prime-agent.ts, dist/index.js for opencode,
|
||||
// index.js -> dist/opencode2.js for opencode2), and that entry hands its own harness name
|
||||
// straight to RuntimeCore.
|
||||
const IN_PROCESS = new Set([
|
||||
"opencode",
|
||||
"opencode2",
|
||||
"kilo",
|
||||
"pi",
|
||||
"prime-agent",
|
||||
"dsh",
|
||||
"dcode",
|
||||
]);
|
||||
const MCP_HOSTS = INSTALLERS.map((i) => i.name).filter((n) => !IN_PROCESS.has(n));
|
||||
|
||||
it.each(MCP_HOSTS)("%s", (harness) => {
|
||||
@@ -1657,3 +1783,73 @@ describe("server setup", () => {
|
||||
expect(logs.join("\n")).toContain("rustup");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Companion-skill parity across every host that installs one (#3524 shape).
|
||||
*
|
||||
* The installer writes the skill and core/skill-sync.ts re-copies it on drift at every session
|
||||
* start, so `npm update -g` upgrades the skill too. Those were two hand-maintained path lists, and
|
||||
* the self-update one covered four of the ten hosts — the six it missed kept whichever SKILL.md
|
||||
* they were installed with, forever, with nothing failing. A per-host test cannot catch that: the
|
||||
* host that is forgotten is by definition the one nobody wrote a test for.
|
||||
*
|
||||
* So drive the real thing over the WHOLE family: install each harness into a temp home, find where
|
||||
* the skill actually landed, and require that the self-update refreshes that same copy.
|
||||
*/
|
||||
describe("every installed companion skill is kept current by the session-start self-update", () => {
|
||||
const SKILL_NAME = "hindsight-coding-agent";
|
||||
|
||||
/** makeCtx's pkgRoot is a synthetic /opt path, so the packaged skill can't be staged in it.
|
||||
* Build a real temp package root holding a SKILL.md the installer can copy. */
|
||||
function ctxWithPackagedSkill(body: string): InstallCtx {
|
||||
const ctx = makeCtx();
|
||||
const pkgRoot = mkdtempSync(join(tmpdir(), "hs-pkg-skillsync-"));
|
||||
homes.push(pkgRoot);
|
||||
mkdirSync(join(pkgRoot, "skill"), { recursive: true });
|
||||
writeFileSync(join(pkgRoot, "skill", "SKILL.md"), body);
|
||||
return { ...ctx, pkgRoot, dist: join(pkgRoot, "dist") };
|
||||
}
|
||||
|
||||
/** Every directory named `hindsight-coding-agent` under `root`, home-relative. */
|
||||
function findSkillCopies(root: string, prefix: string[] = []): string[][] {
|
||||
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isDirectory()) return [];
|
||||
const rel = [...prefix, entry.name];
|
||||
return entry.name === SKILL_NAME ? [rel] : findSkillCopies(join(root, entry.name), rel);
|
||||
});
|
||||
}
|
||||
|
||||
/** Hosts that install no skill at all, and why — so a host that silently STOPS installing one
|
||||
* fails here instead of passing as "nothing to check". opencode (both major versions) and its
|
||||
* Kilo fork have no skills mechanism; Devin and Dcode read no user-level skills directory
|
||||
* either. */
|
||||
const NO_SKILL_MECHANISM = ["dcode", "devin-cli", "kilo", "opencode", "opencode2"];
|
||||
|
||||
it("lands in the mapped directory and is refreshed there, for every host that has one", async () => {
|
||||
const { syncCompanionSkill } = await import("./core/skill-sync");
|
||||
const withoutSkill: string[] = [];
|
||||
for (const harness of INSTALLERS.map((i) => i.name)) {
|
||||
const ctx = ctxWithPackagedSkill("packaged v1");
|
||||
run(["install", harness], ctx);
|
||||
const copies = findSkillCopies(ctx.home);
|
||||
if (!copies.length) {
|
||||
withoutSkill.push(harness);
|
||||
continue;
|
||||
}
|
||||
// Where it landed must be the directory the shared map names, so the self-update looks there.
|
||||
expect(
|
||||
copies.map((parts) => parts.slice(0, -1)),
|
||||
harness
|
||||
).toEqual([SKILL_DIRS[harness]]);
|
||||
|
||||
// And the self-update must actually refresh THIS host's copy when the package moves on.
|
||||
const installed = join(ctx.home, ...copies[0], "SKILL.md");
|
||||
const srcDir = mkdtempSync(join(tmpdir(), "hs-pkg-newer-"));
|
||||
homes.push(srcDir);
|
||||
writeFileSync(join(srcDir, "SKILL.md"), "packaged v2");
|
||||
syncCompanionSkill(harness, { home: ctx.home, srcDir });
|
||||
expect(readFileSync(installed, "utf8"), harness).toBe("packaged v2");
|
||||
}
|
||||
expect(withoutSkill.sort()).toEqual(NO_SKILL_MECHANISM);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import { HOOK_HARNESSES, type HookHarnessName } from "./harness/hook-lifecycle";
|
||||
import { importLocalHistory } from "./core/history";
|
||||
import { detectLlm, hasRustToolchain, hasUvx, type LlmChoice } from "./core/daemon";
|
||||
import { readLegacyEndpoint } from "./core/legacy";
|
||||
import { SKILL_DIRS } from "./core/skill-dirs";
|
||||
import { createInstallerUi, type SelectOption } from "./install-ui";
|
||||
|
||||
/**
|
||||
@@ -238,21 +239,33 @@ function stripHarnessHooks(hooks: Record<string, any>, harness: HookHarnessName)
|
||||
}
|
||||
}
|
||||
|
||||
/** This host's skills directory, from the map core/skill-sync.ts also reads — see SKILL_DIRS for
|
||||
* why the two sides must not keep separate copies of these paths. */
|
||||
function skillsBaseFor(c: InstallCtx, harness: string): string {
|
||||
const parts = SKILL_DIRS[harness];
|
||||
if (!parts) throw new Error(`${harness} installs a skill but names no directory in SKILL_DIRS`);
|
||||
return join(c.home, ...parts);
|
||||
}
|
||||
|
||||
/** Copy the packaged companion SKILL into a host's skills directory (idempotent overwrite).
|
||||
* The log line carries the harness prefix like every adapter message: several adapters install
|
||||
* the skill before their first own log, and an unprefixed line would render under the PREVIOUS
|
||||
* harness's group in the CLI output. */
|
||||
function installSkill(c: InstallCtx, harness: string, skillsBase: string): void {
|
||||
function installSkill(c: InstallCtx, harness: string): void {
|
||||
const src = join(c.pkgRoot, "skill");
|
||||
if (!existsSync(join(src, "SKILL.md"))) return;
|
||||
const skillsBase = skillsBaseFor(c, harness);
|
||||
const dst = join(skillsBase, "hindsight-coding-agent");
|
||||
mkdirSync(skillsBase, { recursive: true });
|
||||
cpSync(src, dst, { recursive: true });
|
||||
c.log?.(`${harness}: skill installed at ${dst}`);
|
||||
}
|
||||
|
||||
function uninstallSkill(c: InstallCtx, skillsBase: string): void {
|
||||
rmSync(join(skillsBase, "hindsight-coding-agent"), { recursive: true, force: true });
|
||||
function uninstallSkill(c: InstallCtx, harness: string): void {
|
||||
rmSync(join(skillsBaseFor(c, harness), "hindsight-coding-agent"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── per-harness adapters ────────────────────────────────────────────────────────
|
||||
@@ -377,43 +390,61 @@ const opencode2: HarnessInstaller = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Prime Agent (PrimeIntellect) — a persistent plugin loaded as an extension. Register the built
|
||||
* `dist/prime-agent.js` in the `extensions` array of `~/.prime/agent/settings.json`; Prime Agent
|
||||
* loads that file's default export at session start. The entry path contains MARKER (the package is
|
||||
* `hindsight-coding-agents`), so uninstall's MARKER filter removes exactly what install added.
|
||||
* The pi-family extension hosts: pi and its fork Prime Agent (PrimeIntellect). Both load a
|
||||
* persistent extension by absolute path from the `extensions` array of their own `settings.json`,
|
||||
* calling that file's default export at session start — they differ only in the config directory
|
||||
* (`~/.pi/agent` vs `~/.prime/agent`), the executable name, and which dist bundle reports which
|
||||
* harness. The entry path runs through the package root, which contains MARKER, so uninstall's
|
||||
* MARKER filter removes exactly what install added and leaves every other extension alone.
|
||||
*
|
||||
* The skill goes to Prime Agent's OWN `~/.prime/agent/skills`, not the shared `~/.agents/skills`
|
||||
* root it also reads: `uninstallSkill` removes a fixed directory name, so installing to the shared
|
||||
* root would make `uninstall prime-agent` delete Codex's and dsh's copy too (#3772).
|
||||
* These installs are the ONLY supported route for either host, and the package deliberately carries
|
||||
* no `pi` key. Both hosts read that same `pkg.pi.extensions` when this package is installed as a
|
||||
* distributed pi package, so it can only ever name one bundle — and the host it did not name would
|
||||
* load the other's, reporting the wrong harness, taking that harness's config section and stamping
|
||||
* it on every document it retained. It used to name Prime Agent's, which is exactly how pi
|
||||
* mis-attributed before it had an entry of its own. A key that is right for at most one of two
|
||||
* hosts is worse than none, so there is none; installer.test.ts holds that line.
|
||||
*
|
||||
* The skills directory (SKILL_DIRS) is per-host rather than shared: both read `~/.agents/skills`
|
||||
* too, but that is the root Codex and dsh install into, and uninstallSkill removes by a fixed
|
||||
* directory name — so putting ours there would make uninstalling one host delete the other hosts'
|
||||
* copy. Each writes its OWN skills directory instead.
|
||||
*/
|
||||
const primeAgent: HarnessInstaller = {
|
||||
name: "prime-agent",
|
||||
detect: (c) => onPath("prime-agent") || existsSync(join(c.home, ".prime", "agent")),
|
||||
install(c) {
|
||||
const path = join(c.home, ".prime", "agent", "settings.json");
|
||||
const cfg = readJson(path);
|
||||
const entry = join(c.pkgRoot, "dist", "prime-agent.js");
|
||||
const exts: string[] = Array.isArray(cfg.extensions) ? cfg.extensions : [];
|
||||
cfg.extensions = [...exts.filter((p) => !String(p).includes(MARKER)), entry];
|
||||
writeJson(path, cfg);
|
||||
installSkill(c, "prime-agent", join(c.home, ".prime", "agent", "skills"));
|
||||
c.log?.(`prime-agent: extension registered in ${path}`);
|
||||
},
|
||||
uninstall(c) {
|
||||
const path = join(c.home, ".prime", "agent", "settings.json");
|
||||
if (existsSync(path)) {
|
||||
function piFamilyInstaller(harness: string, configDir: string[]): HarnessInstaller {
|
||||
const settings = (c: InstallCtx) => join(c.home, ...configDir, "settings.json");
|
||||
return {
|
||||
name: harness,
|
||||
// Both hosts name their executable exactly as we name the harness, so the harness id doubles
|
||||
// as the PATH probe here — unlike, say, antigravity-cli, whose binary is `agy`.
|
||||
detect: (c) => onPath(harness) || existsSync(join(c.home, ...configDir)),
|
||||
install(c) {
|
||||
const path = settings(c);
|
||||
const cfg = readJson(path);
|
||||
const entry = join(c.pkgRoot, "dist", `${harness}.js`);
|
||||
const exts: string[] = Array.isArray(cfg.extensions) ? cfg.extensions : [];
|
||||
cfg.extensions = [...exts.filter((p) => !String(p).includes(MARKER)), entry];
|
||||
writeJson(path, cfg);
|
||||
installSkill(c, harness);
|
||||
c.log?.(`${harness}: extension registered in ${path}`);
|
||||
},
|
||||
uninstall(c) {
|
||||
// Before the settings guard on purpose: a hand-deleted settings.json must not strand the skill.
|
||||
uninstallSkill(c, harness);
|
||||
const path = settings(c);
|
||||
if (!existsSync(path)) return;
|
||||
const cfg = readJson(path);
|
||||
if (Array.isArray(cfg.extensions)) {
|
||||
cfg.extensions = cfg.extensions.filter((p: string) => !String(p).includes(MARKER));
|
||||
if (!cfg.extensions.length) delete cfg.extensions;
|
||||
writeJson(path, cfg);
|
||||
}
|
||||
}
|
||||
// Outside the settings guard on purpose: a hand-deleted settings.json must not strand the skill.
|
||||
uninstallSkill(c, join(c.home, ".prime", "agent", "skills"));
|
||||
c.log?.("prime-agent: extension entry + skill removed");
|
||||
},
|
||||
};
|
||||
c.log?.(`${harness}: extension entry + skill removed`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pi = piFamilyInstaller("pi", [".pi", "agent"]);
|
||||
const primeAgent = piFamilyInstaller("prime-agent", [".prime", "agent"]);
|
||||
|
||||
/**
|
||||
* Kilo Code CLI — an opencode fork, so registration is opencode's: append our entry to the config's
|
||||
@@ -485,7 +516,7 @@ const claudeCode: HarnessInstaller = {
|
||||
c.log?.(`claude-code: hooks merged into ${path}`);
|
||||
// Companion SKILL: every skills-capable host gets it (claude/antigravity/cursor native dirs;
|
||||
// codex via the ~/.agents/skills standard).
|
||||
installSkill(c, "claude-code", join(c.home, ".claude", "skills"));
|
||||
installSkill(c, "claude-code");
|
||||
const mcp = c.claudeMcp ?? defaultClaudeMcp;
|
||||
// `claude mcp add` REFUSES when the name is taken ("MCP server hindsight already exists in
|
||||
// user config") — so on a machine that already had Hindsight, a re-install could never
|
||||
@@ -528,7 +559,7 @@ const claudeCode: HarnessInstaller = {
|
||||
}
|
||||
const mcp = c.claudeMcp ?? defaultClaudeMcp;
|
||||
mcp(["mcp", "remove", "--scope", "user", "hindsight"]);
|
||||
uninstallSkill(c, join(c.home, ".claude", "skills"));
|
||||
uninstallSkill(c, "claude-code");
|
||||
c.log?.("claude-code: hooks + MCP registration + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -593,7 +624,7 @@ const codex: HarnessInstaller = {
|
||||
writeFileSync(tomlPath, next);
|
||||
c.log?.(`codex: wrote ${additions.length} section(s) to ${tomlPath}`);
|
||||
}
|
||||
installSkill(c, "codex", join(c.home, ".agents", "skills")); // agentskills-standard shared dir
|
||||
installSkill(c, "codex"); // agentskills-standard shared dir
|
||||
},
|
||||
uninstall(c) {
|
||||
const hooksPath = join(c.home, ".codex", "hooks.json");
|
||||
@@ -604,7 +635,7 @@ const codex: HarnessInstaller = {
|
||||
writeJson(hooksPath, cfg);
|
||||
}
|
||||
}
|
||||
uninstallSkill(c, join(c.home, ".agents", "skills"));
|
||||
uninstallSkill(c, "codex");
|
||||
const tomlPath = join(c.home, ".codex", "config.toml");
|
||||
if (existsSync(tomlPath)) {
|
||||
const toml = readFileSync(tomlPath, "utf8");
|
||||
@@ -674,7 +705,7 @@ const antigravity: HarnessInstaller = {
|
||||
);
|
||||
}
|
||||
c.log?.(`antigravity-cli: hooks merged into ${hooksPath}, MCP into ${mcpPath}`);
|
||||
installSkill(c, "antigravity-cli", join(c.home, ".gemini", "config", "skills"));
|
||||
installSkill(c, "antigravity-cli");
|
||||
},
|
||||
uninstall(c) {
|
||||
const hooksPath = join(c.home, ".gemini", "config", "hooks.json");
|
||||
@@ -706,7 +737,7 @@ const antigravity: HarnessInstaller = {
|
||||
writeJson(settingsPath, settings);
|
||||
}
|
||||
}
|
||||
uninstallSkill(c, join(c.home, ".gemini", "config", "skills"));
|
||||
uninstallSkill(c, "antigravity-cli");
|
||||
c.log?.("antigravity-cli: hooks + MCP entry + status line + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -1120,7 +1151,7 @@ const cursor: HarnessInstaller = {
|
||||
};
|
||||
writeJson(mcpPath, mcp);
|
||||
c.log?.(`cursor-cli: hooks merged into ${hooksPath}, MCP into ${mcpPath}`);
|
||||
installSkill(c, "cursor-cli", join(c.home, ".cursor", "skills"));
|
||||
installSkill(c, "cursor-cli");
|
||||
},
|
||||
uninstall(c) {
|
||||
const hooksPath = join(c.home, ".cursor", "hooks.json");
|
||||
@@ -1140,7 +1171,7 @@ const cursor: HarnessInstaller = {
|
||||
writeJson(mcpPath, mcp);
|
||||
}
|
||||
}
|
||||
uninstallSkill(c, join(c.home, ".cursor", "skills"));
|
||||
uninstallSkill(c, "cursor-cli");
|
||||
c.log?.("cursor-cli: hooks + MCP entry + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -1161,7 +1192,7 @@ const copilot: HarnessInstaller = {
|
||||
hindsight: mcpServerEntry(c.dist, "copilot-cli"),
|
||||
};
|
||||
writeJson(mcpPath, mcp);
|
||||
installSkill(c, "copilot-cli", join(c.home, ".copilot", "skills"));
|
||||
installSkill(c, "copilot-cli");
|
||||
c.log?.(`copilot-cli: hooks installed at ${hooksPath}, MCP into ${mcpPath}`);
|
||||
},
|
||||
uninstall(c) {
|
||||
@@ -1176,7 +1207,7 @@ const copilot: HarnessInstaller = {
|
||||
writeJson(mcpPath, mcp);
|
||||
}
|
||||
}
|
||||
uninstallSkill(c, join(c.home, ".copilot", "skills"));
|
||||
uninstallSkill(c, "copilot-cli");
|
||||
c.log?.("copilot-cli: hooks + MCP entry + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -1211,7 +1242,7 @@ const grok: HarnessInstaller = {
|
||||
copyFileSync(path, `${path}.hindsight-backup`);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${withoutOurs.replace(/\n*$/, "\n")}${block}`);
|
||||
installSkill(c, "grok-build", join(c.home, ".grok", "skills"));
|
||||
installSkill(c, "grok-build");
|
||||
c.log?.(`grok-build: native hooks + MCP installed in ${path}`);
|
||||
},
|
||||
uninstall(c) {
|
||||
@@ -1221,7 +1252,7 @@ const grok: HarnessInstaller = {
|
||||
const cleaned = existing.replace(GROK_BLOCK_RE, "\n");
|
||||
if (cleaned !== existing) writeFileSync(path, cleaned);
|
||||
}
|
||||
uninstallSkill(c, join(c.home, ".grok", "skills"));
|
||||
uninstallSkill(c, "grok-build");
|
||||
c.log?.("grok-build: native hooks + MCP + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -1260,7 +1291,7 @@ const cline: HarnessInstaller = {
|
||||
hindsight: mcpServerEntry(c.dist, "cline-cli"),
|
||||
};
|
||||
writeJson(mcpPath, mcp);
|
||||
installSkill(c, "cline-cli", join(c.home, ".cline", "data", "settings", "skills"));
|
||||
installSkill(c, "cline-cli");
|
||||
c.log?.(
|
||||
installed
|
||||
? "cline-cli: native plugin + MCP + skill installed"
|
||||
@@ -1280,7 +1311,7 @@ const cline: HarnessInstaller = {
|
||||
writeJson(mcpPath, mcp);
|
||||
}
|
||||
}
|
||||
uninstallSkill(c, join(c.home, ".cline", "data", "settings", "skills"));
|
||||
uninstallSkill(c, "cline-cli");
|
||||
c.log?.("cline-cli: native plugin + MCP + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -1448,7 +1479,7 @@ const dsh: HarnessInstaller = {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, others ? `${others}\n\n${block}` : block);
|
||||
// dsh's skill provider scans the shared agentskills root, the same one Codex reads.
|
||||
installSkill(c, "dsh", join(c.home, ".agents", "skills"));
|
||||
installSkill(c, "dsh");
|
||||
c.log?.(`dsh: plugin registered in ${path} (applies to every dsh profile)`);
|
||||
},
|
||||
uninstall(c) {
|
||||
@@ -1460,7 +1491,7 @@ const dsh: HarnessInstaller = {
|
||||
// BOOT on anything else, so removing the last block must leave an empty list behind.
|
||||
if (others !== existing.trim()) writeFileSync(path, others ? `${others}\n` : "[]\n");
|
||||
}
|
||||
uninstallSkill(c, join(c.home, ".agents", "skills"));
|
||||
uninstallSkill(c, "dsh");
|
||||
c.log?.("dsh: plugin entry + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -1477,7 +1508,7 @@ const qwen: HarnessInstaller = {
|
||||
mergeHarnessHooks(settings.hooks, "qwen-code", c.dist);
|
||||
writeJson(path, settings);
|
||||
c.log?.(`qwen-code: hooks merged into ${path}`);
|
||||
installSkill(c, "qwen-code", join(c.home, ".qwen", "skills"));
|
||||
installSkill(c, "qwen-code");
|
||||
const mcp = c.qwenMcp ?? defaultQwenMcp;
|
||||
// Same rationale as claude-code: `qwen mcp add` refuses a name that already exists, so a
|
||||
// re-install could never repoint a stale server. Remove first (a no-op when absent).
|
||||
@@ -1515,7 +1546,7 @@ const qwen: HarnessInstaller = {
|
||||
}
|
||||
const mcp = c.qwenMcp ?? defaultQwenMcp;
|
||||
mcp(["mcp", "remove", "hindsight"]);
|
||||
uninstallSkill(c, join(c.home, ".qwen", "skills"));
|
||||
uninstallSkill(c, "qwen-code");
|
||||
c.log?.("qwen-code: hooks + MCP registration + skill removed");
|
||||
},
|
||||
};
|
||||
@@ -1533,6 +1564,7 @@ export const INSTALLERS: HarnessInstaller[] = [
|
||||
opencode,
|
||||
opencode2,
|
||||
kilo,
|
||||
pi,
|
||||
primeAgent,
|
||||
claudeCode,
|
||||
codex,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* pi's extension entrypoint (`pi`, npm `@earendil-works/pi-coding-agent`).
|
||||
*
|
||||
* pi loads extensions listed in the `extensions` array of `~/.pi/agent/settings.json` and calls each
|
||||
* one's default export with its `pi` API. The whole adapter lives in src/harness/pi-extension.ts,
|
||||
* shared with Prime Agent — pi's fork — which registers the same default-export shape from its own
|
||||
* settings file.
|
||||
*
|
||||
* pi has no hooks system, which is why it is a persistent-extension harness rather than a
|
||||
* hook-binary harness like claude-code/cursor/codex.
|
||||
*
|
||||
* This entry exists — rather than pointing pi at dist/prime-agent.js — purely so the harness reports
|
||||
* as "pi": that selects the `harnesses.pi` config section, feeds `{harness}` bank templating, and
|
||||
* keeps pi sessions attributable in diagnostics separately from Prime Agent's.
|
||||
*/
|
||||
import { createPiExtension, type ExtensionFactory } from "./harness/pi-extension";
|
||||
|
||||
const extension: ExtensionFactory = createPiExtension("pi");
|
||||
|
||||
export { extension };
|
||||
export default extension;
|
||||
@@ -1,174 +1,21 @@
|
||||
/**
|
||||
* Prime Agent's extension entrypoint.
|
||||
* Prime Agent's extension entrypoint (`prime-agent`, npm `prime-agent`).
|
||||
*
|
||||
* Prime Agent (PrimeIntellect) loads distributed packages listed in `~/.prime/agent/settings.json`
|
||||
* and calls each extension's default export with its `pi` API. This wires Prime Agent's
|
||||
* `before_agent_start` (recall + system-prompt injection) and `agent_end` (transcript write-back)
|
||||
* onto the shared RuntimeCore — the same reflect-and-inject core every Hindsight harness uses — and
|
||||
* registers the `hindsight_*` knowledge tools natively via `pi.registerTool`. The memory behaviour
|
||||
* itself stays in RuntimeCore; this file only adapts Prime Agent's API at its boundary.
|
||||
* Prime Agent (PrimeIntellect) is a FORK OF PI: same extension API, same `extensions` array in
|
||||
* `settings.json` (under `~/.prime/agent/` rather than `~/.pi/agent/`), same `before_agent_start` /
|
||||
* `agent_end` events and `registerTool`. So it needs no adapter of its own and drives the exact pi
|
||||
* runtime — see src/harness/pi-extension.ts.
|
||||
*
|
||||
* Prime Agent has no hooks system, which is why it is a persistent-extension harness rather than a
|
||||
* hook-binary harness like claude-code/cursor/codex.
|
||||
*
|
||||
* This entry exists — rather than pointing Prime Agent at dist/pi.js — purely so the harness reports
|
||||
* as "prime-agent": that selects the `harnesses.prime-agent` config section, feeds `{harness}` bank
|
||||
* templating, and keeps Prime Agent sessions attributable in diagnostics separately from pi's.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { resolveHostMemory } from "./core/host-client";
|
||||
import { diag } from "./core/diag";
|
||||
import type { ToolSpec } from "./core/knowledge-tools";
|
||||
import { RuntimeCore } from "./core/runtime";
|
||||
import { type PaMessage, readPrimeAgentMessages } from "./core/transcript-prime-agent";
|
||||
import { createPiExtension, type ExtensionFactory } from "./harness/pi-extension";
|
||||
|
||||
const HARNESS = "prime-agent";
|
||||
|
||||
// ── Structural subset of Prime Agent's extension API (@earendil-works/pi-coding-agent) ──────────
|
||||
// Declared locally so this package takes no dependency on the fast-moving Prime Agent SDK; the real
|
||||
// runtime passes a compatible object at load time.
|
||||
|
||||
interface BeforeAgentStartEvent {
|
||||
type: "before_agent_start";
|
||||
/** The raw user prompt text. */
|
||||
prompt: string;
|
||||
/** The fully assembled system prompt for this turn. */
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
/** The conversation messages for the completed agent loop. */
|
||||
messages: readonly PaMessage[];
|
||||
}
|
||||
|
||||
interface BeforeAgentStartResult {
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
interface SessionManagerLike {
|
||||
getSessionId(): string;
|
||||
}
|
||||
|
||||
interface ExtensionContext {
|
||||
hasUI: boolean;
|
||||
ui: { notify(message: string, type?: "info" | "warning" | "error"): void };
|
||||
sessionManager: SessionManagerLike;
|
||||
}
|
||||
|
||||
/** A JSON-Schema-shaped parameters object. Prime Agent forwards it to the model provider verbatim. */
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
interface ToolDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: JsonSchema;
|
||||
execute(
|
||||
toolCallId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ content: { type: "text"; text: string }[]; details: unknown }>;
|
||||
}
|
||||
|
||||
interface ExtensionAPI {
|
||||
on(
|
||||
event: "before_agent_start",
|
||||
handler: (
|
||||
event: BeforeAgentStartEvent,
|
||||
ctx: ExtensionContext
|
||||
) => Promise<BeforeAgentStartResult | void> | BeforeAgentStartResult | void
|
||||
): void;
|
||||
on(
|
||||
event: "agent_end",
|
||||
handler: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void> | void
|
||||
): void;
|
||||
registerTool(definition: ToolDefinition): void;
|
||||
}
|
||||
|
||||
export type ExtensionFactory = (pi: ExtensionAPI) => void;
|
||||
|
||||
/**
|
||||
* Adapt a harness-agnostic ToolSpec (MCP-shaped, shared by every harness) to a Prime Agent native
|
||||
* tool. The spec's Zod raw shape is converted to a JSON Schema for `parameters` — Prime Agent passes
|
||||
* that straight to the model provider (it does not run TypeBox validation on tool args), so a plain
|
||||
* JSON Schema is exactly what the tool needs. The spec's handler returns an MCP `{content:[{text}]}`
|
||||
* result and never throws, so we surface the joined text back to the model.
|
||||
*/
|
||||
export function toPrimeAgentTool(spec: ToolSpec): ToolDefinition {
|
||||
const parameters = z.toJSONSchema(z.object(spec.inputSchema)) as JsonSchema;
|
||||
return {
|
||||
name: spec.name,
|
||||
label: spec.name,
|
||||
description: spec.description,
|
||||
parameters,
|
||||
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
||||
const r = await spec.handler(params);
|
||||
const text = r.content?.map((c) => c.text).join("\n") || "";
|
||||
return { content: [{ type: "text", text }], details: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the host-specific Prime Agent hooks testable without importing Prime Agent's SDK. RuntimeCore
|
||||
* is the shared lifecycle implementation; this adapter only converts Prime Agent messages at its
|
||||
* boundary and never calls Hindsight directly.
|
||||
*/
|
||||
export function createPrimeAgentHooks(
|
||||
core: Pick<RuntimeCore, "onPrompt" | "getInjection" | "onTranscript">,
|
||||
sessionStart?: Promise<void>
|
||||
) {
|
||||
let sessionStartAwaited = false;
|
||||
return {
|
||||
async beforeAgentStart(
|
||||
event: { prompt: string; systemPrompt: string },
|
||||
sessionId: string
|
||||
): Promise<BeforeAgentStartResult | undefined> {
|
||||
if (!sessionStartAwaited) {
|
||||
sessionStartAwaited = true;
|
||||
// Awaiting the shared SessionStart lifecycle before the first prompt preserves the invariant
|
||||
// that a brand-new bank skips its first auto-reflect instead of spending that synthesis
|
||||
// before it has any knowledge (mirrors the other harnesses).
|
||||
await sessionStart;
|
||||
}
|
||||
const prompt = event.prompt.trim();
|
||||
if (prompt) await core.onPrompt(sessionId, prompt);
|
||||
const injection = core.getInjection(sessionId);
|
||||
if (!injection) {
|
||||
diag(HARNESS, "inject_empty", { session: sessionId });
|
||||
return undefined;
|
||||
}
|
||||
diag(HARNESS, "inject_ok", { session: sessionId, chars: injection.length });
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${injection}` };
|
||||
},
|
||||
async agentEnd(event: { messages: readonly PaMessage[] }, sessionId: string): Promise<void> {
|
||||
const turns = readPrimeAgentMessages(event.messages);
|
||||
if (turns.length) await core.onTranscript(sessionId, turns);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(repoPath: string): RuntimeCore | undefined {
|
||||
const { cfg, bankId, client } = resolveHostMemory(HARNESS, repoPath);
|
||||
if (cfg.disabled) return undefined; // global switch, per-bank opt-out or optInOnly
|
||||
return new RuntimeCore(client, bankId, cfg, HARNESS, repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime Agent extension factory. Loaded once per session in the project directory; resolves config
|
||||
* and bank from `process.cwd()`, registers the knowledge tools, kicks off the shared cold-seed, and
|
||||
* wires the recall/retain hooks.
|
||||
*/
|
||||
const extension: ExtensionFactory = (pi) => {
|
||||
const repoPath = process.cwd();
|
||||
const core = createRuntime(repoPath);
|
||||
if (!core) return;
|
||||
|
||||
for (const spec of core.toolSpecs()) pi.registerTool(toPrimeAgentTool(spec));
|
||||
|
||||
// Fire-and-forget cold seed (bank check + background git seed + knowledge preamble); the first
|
||||
// before_agent_start awaits it via createPrimeAgentHooks.
|
||||
const sessionStart = core.seedIfCold(repoPath);
|
||||
const hooks = createPrimeAgentHooks(core, sessionStart);
|
||||
|
||||
pi.on("before_agent_start", (event, ctx) =>
|
||||
hooks.beforeAgentStart(event, ctx.sessionManager.getSessionId())
|
||||
);
|
||||
pi.on("agent_end", (event, ctx) => hooks.agentEnd(event, ctx.sessionManager.getSessionId()));
|
||||
};
|
||||
const extension: ExtensionFactory = createPiExtension("prime-agent");
|
||||
|
||||
export { extension };
|
||||
export default extension;
|
||||
|
||||
@@ -25,8 +25,11 @@ export default defineConfig({
|
||||
cline: "src/cline.ts",
|
||||
// DeepSeek Harness loads this as a native Cordis plugin — see src/dsh.ts.
|
||||
dsh: "src/dsh.ts",
|
||||
// Prime Agent loads this module as an extension (default export) by absolute path from its
|
||||
// settings.json `extensions` array, so it must be self-contained like the hook bins.
|
||||
// pi and its fork Prime Agent each load their own module as an extension (default export) by
|
||||
// absolute path from their settings.json `extensions` array, so both must be self-contained
|
||||
// like the hook bins. They share src/harness/pi-extension.ts, which `splitting: false` inlines
|
||||
// into each bundle rather than a shared chunk.
|
||||
pi: "src/pi.ts",
|
||||
"prime-agent": "src/prime-agent.ts",
|
||||
"qwen-hook": "src/qwen-hook.ts",
|
||||
"qwen-sessionstart-hook": "src/qwen-sessionstart-hook.ts",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Long-term project memory for **coding agents**, backed by [Hindsight](https://vectorize.io/hindsight).
|
||||
One package, several agents: a shared reflect-and-inject core with a thin entry point per agent
|
||||
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
|
||||
(**opencode**, **opencode 2**, **Kilo CLI**, **Cline CLI**, **pi**, **Prime Agent**, **DeepSeek Harness**, **Claude Code**, **Codex CLI**, **DeepAgents Dcode**, **Antigravity CLI**, **Cursor CLI**, **GitHub Copilot CLI**, **Devin CLI**, **Grok Build**). Ingestion is fully
|
||||
automatic — there is no setup command: a repo's git history and conversations flow into its memory
|
||||
bank in the background as you work.
|
||||
|
||||
@@ -174,14 +174,35 @@ npx @vectorize-io/hindsight-coding-agents install cline-cli
|
||||
|
||||
A native plugin via `cline plugin install`, plus MCP and the companion skill.
|
||||
|
||||
#### pi
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-coding-agents install pi
|
||||
```
|
||||
|
||||
An extension entry in `~/.pi/agent/settings.json`, plus the companion skill in
|
||||
`~/.pi/agent/skills` — native tools, no MCP needed.
|
||||
|
||||
This command is the only supported route, for pi and for Prime Agent below. Installing us as a pi
|
||||
package (`pi install npm:@vectorize-io/hindsight-coding-agents`) is deliberately not wired: both
|
||||
hosts read the same `pi` key of a package's `package.json`, and that key can only name one entry —
|
||||
whichever host it did not name would load the other's bundle and report itself as the wrong agent,
|
||||
taking that harness's config section and stamping every document it retains with it. So the package
|
||||
carries no `pi` key at all, and each host is pointed at its own bundle by the install command above.
|
||||
|
||||
#### Prime Agent
|
||||
|
||||
```bash
|
||||
npx @vectorize-io/hindsight-coding-agents install prime-agent
|
||||
```
|
||||
|
||||
An extension entry in `~/.prime/agent/settings.json` — native tools, no MCP needed — plus the
|
||||
companion skill in `~/.prime/agent/skills`.
|
||||
Prime Agent is a fork of pi, so it is wired the same way: an extension entry, here in
|
||||
`~/.prime/agent/settings.json`, plus the companion skill in `~/.prime/agent/skills` — native tools,
|
||||
no MCP needed. Installing both is fine and expected:
|
||||
each host loads its own entry from its own settings file, and like every other pair of agents they
|
||||
**share one bank per repo** (the default `coding-agent::{gitProject}`), so what you tell pi is there
|
||||
when you open Prime Agent. Separate entries are what keeps each side attributable — its own
|
||||
`harnesses.<name>` config section, and its own agent stamped on every document it retains.
|
||||
|
||||
#### DeepSeek Harness
|
||||
|
||||
@@ -269,7 +290,8 @@ transcripts are needed either way; going through them directly is simply the sho
|
||||
**How sessions are matched.** A conversation is imported only when the session itself records the
|
||||
directory it ran in — never inferred from a file or folder name. Claude Code writes that directory
|
||||
on its entries, Codex in its `session_meta` header and DeepSeek Harness in its session-log header,
|
||||
so all three can be attributed exactly, including sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
|
||||
and pi and Prime Agent in their session header, so all five can be attributed exactly, including
|
||||
sessions started in a subdirectory of the repo. Guessing was tempting (Claude names its history
|
||||
folders after the project path) but unsafe: `/` and `.` both encode to `-`, so `repo-sub` is either
|
||||
the subdirectory `repo/sub` or an unrelated sibling repo — and a wrong guess files someone else's
|
||||
conversation into your bank. Sessions that record nothing are skipped and the count is reported.
|
||||
@@ -379,7 +401,7 @@ what reads it:
|
||||
| host | reads the file | an edit applies |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------- |
|
||||
| hook harnesses (Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, Grok Build, Antigravity CLI, Devin) | once per hook invocation — each hook is its own short-lived process | on your next prompt |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| persistent plugins (opencode, opencode 2, Kilo CLI, Cline CLI, pi, Prime Agent, DeepSeek Harness) | once per workspace, when the host loads the plugin | after restarting the agent |
|
||||
| the MCP server behind the `hindsight_*` tools | once at startup | in your next session |
|
||||
|
||||
`apiToken` is the exception. Every host re-reads it when the server rejects a request, so enabling
|
||||
|
||||
Reference in New Issue
Block a user