hob-bot 1.0.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -6
- package/README.zh-CN.md +76 -6
- package/dist/web/assets/DesktopView-CEBJFAIN.js +5 -0
- package/dist/web/assets/index-Di5lyTHO.css +1 -0
- package/dist/web/assets/index-y09i1RGd.js +267 -0
- package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
- package/dist/web/index.html +2 -2
- package/dist/web/mock/desktop.jpg +0 -0
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- package/dist/web/mock/sunset.html +90 -0
- package/package.json +7 -1
- package/server/src/actions.ts +16 -56
- package/server/src/assistant-skills.ts +215 -0
- package/server/src/boot.ts +1 -0
- package/server/src/claude-status.ts +47 -0
- package/server/src/config.ts +3 -1
- package/server/src/delegations.ts +21 -11
- package/server/src/desktop-control.ts +152 -0
- package/server/src/desktop-events.ts +63 -0
- package/server/src/desktop-public.ts +43 -0
- package/server/src/desktop-tools.ts +161 -0
- package/server/src/desktop.ts +76 -0
- package/server/src/embedding.ts +52 -0
- package/server/src/files.ts +63 -43
- package/server/src/http.ts +325 -13
- package/server/src/job-view.ts +52 -0
- package/server/src/jobs.ts +326 -44
- package/server/src/knowledge-index.ts +123 -0
- package/server/src/knowledge-tools.ts +94 -0
- package/server/src/knowledge.ts +451 -0
- package/server/src/lore-entry.ts +10 -0
- package/server/src/main.ts +6 -0
- package/server/src/mcp.ts +745 -0
- package/server/src/memory.ts +11 -4
- package/server/src/now.ts +33 -0
- package/server/src/overview.ts +4 -4
- package/server/src/pages.ts +1 -1
- package/server/src/pi.ts +92 -16
- package/server/src/prompt.ts +51 -156
- package/server/src/providers.ts +3 -2
- package/server/src/push.ts +39 -5
- package/server/src/router-activity.ts +6 -0
- package/server/src/router-tools.ts +184 -39
- package/server/src/router.ts +154 -17
- package/server/src/routines.ts +14 -4
- package/server/src/runtime.ts +15 -0
- package/server/src/settings.ts +43 -1
- package/server/src/store.ts +4 -3
- package/server/src/transcript.ts +57 -4
- package/server/src/worker-tools.ts +14 -12
- package/shared/knowledge.ts +92 -0
- package/shared/mcp.ts +91 -0
- package/shared/skills.ts +18 -0
- package/shared/types.ts +104 -20
- package/vendor/claude-bridge/HOB-PATCH.md +10 -0
- package/vendor/claude-bridge/LICENSE +21 -0
- package/vendor/claude-bridge/package.json +62 -0
- package/vendor/claude-bridge/src/agents-md.ts +14 -0
- package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
- package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
- package/vendor/claude-bridge/src/attachments.ts +135 -0
- package/vendor/claude-bridge/src/config.ts +105 -0
- package/vendor/claude-bridge/src/convert.ts +222 -0
- package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
- package/vendor/claude-bridge/src/index.ts +2503 -0
- package/vendor/claude-bridge/src/log-paths.ts +19 -0
- package/vendor/claude-bridge/src/mcp-server.ts +169 -0
- package/vendor/claude-bridge/src/models.ts +164 -0
- package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
- package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
- package/vendor/claude-bridge/src/query-state.ts +118 -0
- package/vendor/claude-bridge/src/rate-limit.ts +48 -0
- package/vendor/claude-bridge/src/session-cwd.ts +38 -0
- package/vendor/claude-bridge/src/session-verify.ts +38 -0
- package/vendor/claude-bridge/src/skills.ts +20 -0
- package/vendor/claude-bridge/src/transcript.ts +86 -0
- package/vendor/claude-bridge/src/usage.ts +53 -0
- package/vendor/lore-pi-extension/package.json +1 -1
- package/vendor/lore-pi-extension/tools.ts +8 -5
- package/dist/web/assets/index-BCGEZvgg.css +0 -1
- package/dist/web/assets/index-BqkIAlk9.js +0 -258
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eli Dickinson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-claude-bridge",
|
|
3
|
+
"version": "0.9.3",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Pi extension that uses Claude Code (via Agent SDK) as a model provider and adds an AskClaude tool.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"extension",
|
|
10
|
+
"claude-code",
|
|
11
|
+
"claude-agent-sdk"
|
|
12
|
+
],
|
|
13
|
+
"author": "Eli Dickinson",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/elidickinson/pi-claude-bridge.git"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"assets"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test:unit": "node --import tsx --import ./tests/lib/setup.mjs --test tests/unit-*.mjs",
|
|
30
|
+
"test": "set -a && [ -f .env.test ] && . .env.test; set +a; CLAUDE_BRIDGE_TEST_LOG_DIR=$(mktemp -d); export CLAUDE_BRIDGE_TEST_LOG_DIR; npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs && node tests/lib/check-deadlock-logs.mjs \"$CLAUDE_BRIDGE_TEST_LOG_DIR\"",
|
|
31
|
+
"test:usage": "tests/usage-test.sh",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"type": "module",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.293",
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
+
"cc-session-io": "^0.4.0",
|
|
39
|
+
"change-case": "^5.4.4"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": ">=0.86.1",
|
|
43
|
+
"@earendil-works/pi-coding-agent": ">=0.86.1",
|
|
44
|
+
"@earendil-works/pi-tui": ">=0.86.1",
|
|
45
|
+
"typebox": "*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@anthropic-ai/sdk": "^0.124.0",
|
|
49
|
+
"@earendil-works/pi-ai": "^1.2.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "^1.2.0",
|
|
51
|
+
"@earendil-works/pi-tui": "^1.2.0",
|
|
52
|
+
"@types/node": "^24.13.2",
|
|
53
|
+
"tsx": "^4.22.4",
|
|
54
|
+
"typebox": "^1.3.7",
|
|
55
|
+
"typescript": "^6.0.3"
|
|
56
|
+
},
|
|
57
|
+
"pi": {
|
|
58
|
+
"extensions": [
|
|
59
|
+
"./src/index.ts"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Pi owns context-file discovery; the bridge only formats the list Pi loaded so
|
|
2
|
+
// Claude receives the same instructions, in the same order, that Pi applies.
|
|
3
|
+
|
|
4
|
+
type ContextFile = { path: string; content: string };
|
|
5
|
+
|
|
6
|
+
export function formatProjectContext(contextFiles: ContextFile[]): string | undefined {
|
|
7
|
+
if (contextFiles.length === 0) return undefined;
|
|
8
|
+
|
|
9
|
+
let prompt = "<project_context>\n\nProject-specific instructions and guidelines:\n\n";
|
|
10
|
+
for (const { path, content } of contextFiles) {
|
|
11
|
+
prompt += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
|
|
12
|
+
}
|
|
13
|
+
return `${prompt}</project_context>`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { Config } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export type AskClaudeMode = "full" | "read" | "none";
|
|
6
|
+
|
|
7
|
+
export interface AskClaudeDefaults {
|
|
8
|
+
mode: AskClaudeMode;
|
|
9
|
+
isolated: boolean;
|
|
10
|
+
allowFull: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PACKAGE_DEFAULT_MODE: AskClaudeMode = "read";
|
|
14
|
+
const PACKAGE_DEFAULT_ISOLATED = false;
|
|
15
|
+
|
|
16
|
+
export function resolveAskClaudeDefaults(conf: Config["askClaude"]): AskClaudeDefaults {
|
|
17
|
+
const rawAllowFull: unknown = conf?.allowFullMode;
|
|
18
|
+
const allowFull = rawAllowFull == null ? true : rawAllowFull === true;
|
|
19
|
+
const configuredMode: unknown = conf?.defaultMode;
|
|
20
|
+
const mode = configuredMode == null
|
|
21
|
+
? PACKAGE_DEFAULT_MODE
|
|
22
|
+
: configuredMode === "full" || configuredMode === "read" || configuredMode === "none"
|
|
23
|
+
? configuredMode
|
|
24
|
+
// Unrecognized value: the most restrictive mode, not the package default.
|
|
25
|
+
: "none";
|
|
26
|
+
return { mode: !allowFull && mode === "full" ? PACKAGE_DEFAULT_MODE : mode, isolated: conf?.defaultIsolated ?? PACKAGE_DEFAULT_ISOLATED, allowFull };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveAskClaudeMode(mode: unknown, defaults: AskClaudeDefaults): AskClaudeMode {
|
|
30
|
+
const resolved = mode === undefined ? defaults.mode : mode;
|
|
31
|
+
if (resolved !== "full" && resolved !== "read" && resolved !== "none") throw new Error(`Invalid AskClaude mode: ${String(resolved)}`);
|
|
32
|
+
if (resolved === "full" && !defaults.allowFull) throw new Error("AskClaude full mode is disabled.");
|
|
33
|
+
return resolved;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function modeDescription(defaults: AskClaudeDefaults): string {
|
|
37
|
+
const mark = (mode: AskClaudeMode) => (mode === defaults.mode ? " (default)" : "");
|
|
38
|
+
const parts = [
|
|
39
|
+
`"read"${mark("read")}: questions about the codebase — review, analysis, explain.`,
|
|
40
|
+
`"none"${mark("none")}: general knowledge only (no file access).`,
|
|
41
|
+
];
|
|
42
|
+
if (defaults.allowFull) {
|
|
43
|
+
parts.push(`"full"${mark("full")}: allows writing and bash execution (careful: runs without feedback to pi).`);
|
|
44
|
+
}
|
|
45
|
+
return parts.join(" ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildAskClaudeParams(defaults: AskClaudeDefaults) {
|
|
49
|
+
const visibility = defaults.isolated
|
|
50
|
+
? "By default Claude sees only this prompt (isolated session)."
|
|
51
|
+
: "By default Claude sees the full conversation history.";
|
|
52
|
+
const trueDefault = defaults.isolated ? " (default)" : "";
|
|
53
|
+
const falseDefault = defaults.isolated ? "" : " (default)";
|
|
54
|
+
const modeValues: readonly AskClaudeMode[] = defaults.allowFull ? ["read", "full", "none"] : ["read", "none"];
|
|
55
|
+
return Type.Object({
|
|
56
|
+
prompt: Type.String({ description: `The question or task for Claude Code. ${visibility} Don't research up front, let Claude explore.` }),
|
|
57
|
+
mode: Type.Optional(StringEnum(modeValues, { description: modeDescription(defaults) })),
|
|
58
|
+
model: Type.Optional(Type.String({ description: 'Claude model (e.g. "opus", "sonnet", "haiku", or full ID). Defaults to "opus".' })),
|
|
59
|
+
thinking: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, { description: "Thinking effort level. Omit to use Claude Code's default." })),
|
|
60
|
+
isolated: Type.Optional(Type.Boolean({ description: `When true${trueDefault}, Claude sees only this prompt (clean session). When false${falseDefault}, Claude sees the full conversation history.` })),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function askClaudeToolDescription(defaults: AskClaudeDefaults, override?: string | null): string {
|
|
65
|
+
if (override != null) return override;
|
|
66
|
+
const suffix = " Prefer to handle straightforward tasks yourself.";
|
|
67
|
+
if (!defaults.allowFull) {
|
|
68
|
+
const middle = defaults.mode === "none"
|
|
69
|
+
? 'Defaults to no file access — pass mode "read" to let Claude Code explore the codebase; it can never make changes.'
|
|
70
|
+
: "Read-only — Claude Code can explore the codebase but not make changes.";
|
|
71
|
+
return `Delegate to Claude Code for a second opinion or analysis (code review, architecture questions, debugging theories). ${middle}${suffix}`;
|
|
72
|
+
}
|
|
73
|
+
const middle = defaults.mode === "full"
|
|
74
|
+
? 'Defaults to full mode — Claude Code writes files and runs bash without feedback to pi; pass mode "read" to keep it to exploration.'
|
|
75
|
+
: defaults.mode === "none"
|
|
76
|
+
? 'Defaults to no file access — pass mode "read" to let Claude Code explore the codebase, or "full" for a task that requires changes.'
|
|
77
|
+
: "Defaults to read-only mode — use full mode when the user wants to delegate a task that requires changes.";
|
|
78
|
+
return `Delegate to Claude Code for a second opinion or analysis (code review, architecture questions, debugging theories), or to autonomously handle a task. ${middle}${suffix}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function askClaudeCallTags(
|
|
82
|
+
args: { mode?: AskClaudeMode; model?: string; thinking?: string; isolated?: boolean },
|
|
83
|
+
defaults: AskClaudeDefaults,
|
|
84
|
+
): string[] {
|
|
85
|
+
const tags: string[] = [];
|
|
86
|
+
const mode = args.mode ?? defaults.mode;
|
|
87
|
+
if (mode !== PACKAGE_DEFAULT_MODE || (args.mode !== undefined && args.mode !== defaults.mode)) tags.push(`mode=${mode}`);
|
|
88
|
+
if (args.model) tags.push(`model=${args.model}`);
|
|
89
|
+
if (args.thinking) tags.push(`thinking=${args.thinking}`);
|
|
90
|
+
if ((args.isolated ?? defaults.isolated) !== PACKAGE_DEFAULT_ISOLATED) tags.push("isolated");
|
|
91
|
+
return tags;
|
|
92
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Status-line rendering helpers for the AskClaude tool.
|
|
2
|
+
//
|
|
3
|
+
// While Claude Code runs inside an AskClaude call, the pi TUI can't surface
|
|
4
|
+
// each tool_use individually — there's only one status row for the whole
|
|
5
|
+
// delegation. These helpers shape a tool_use record into a short, path-aware
|
|
6
|
+
// label (e.g. "Read(src/foo.ts)", "Bash(git log --oneline…)") and collapse
|
|
7
|
+
// runs of the same tool so the line doesn't flicker. Used only by
|
|
8
|
+
// promptAndWait; the provider path exposes tools directly through pi's TUI
|
|
9
|
+
// and doesn't need this.
|
|
10
|
+
|
|
11
|
+
export interface ToolCallState {
|
|
12
|
+
name: string;
|
|
13
|
+
status: string;
|
|
14
|
+
rawInput?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function extractPath(rawInput: unknown): string | undefined {
|
|
18
|
+
if (!rawInput || typeof rawInput !== "object") return undefined;
|
|
19
|
+
const input = rawInput as Record<string, unknown>;
|
|
20
|
+
if (typeof input.file_path === "string") return input.file_path;
|
|
21
|
+
if (typeof input.path === "string") return input.path;
|
|
22
|
+
if (typeof input.command === "string") return input.command.substring(0, 80);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shortPath(p: string): string {
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
if (p.startsWith(cwd + "/")) return p.slice(cwd.length + 1);
|
|
29
|
+
if (p.startsWith("/")) {
|
|
30
|
+
const parts = p.split("/");
|
|
31
|
+
if (parts.length > 3) return parts.slice(-2).join("/");
|
|
32
|
+
}
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatToolAction(tc: ToolCallState): string | undefined {
|
|
37
|
+
const path = extractPath(tc.rawInput);
|
|
38
|
+
const verb = tc.name.toLowerCase().split(/\s/)[0];
|
|
39
|
+
if (verb === "read" || verb === "readfile") {
|
|
40
|
+
return path ? `Read(${shortPath(path)})` : "Read";
|
|
41
|
+
} else if (verb === "glob") {
|
|
42
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
43
|
+
const pat = typeof input?.pattern === "string" ? input.pattern.slice(0, 40) : "";
|
|
44
|
+
return pat ? `Glob(${pat})` : "Glob";
|
|
45
|
+
} else if (verb === "edit" || verb === "write" || verb === "writefile" || verb === "multiedit") {
|
|
46
|
+
return path ? `Edit(${shortPath(path)})` : "Edit";
|
|
47
|
+
} else if (verb === "bashoutput") {
|
|
48
|
+
return undefined; // redundant with preceding Bash call
|
|
49
|
+
} else if (verb === "bash" || verb === "terminal") {
|
|
50
|
+
return path ? `Bash(${path})` : "Bash";
|
|
51
|
+
} else if (verb === "agent") {
|
|
52
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
53
|
+
return `Agent(${String(input?.description ?? "").slice(0, 40)})`;
|
|
54
|
+
} else if (verb === "grep") {
|
|
55
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
56
|
+
const pat = typeof input?.pattern === "string" ? input.pattern.slice(0, 40) : "";
|
|
57
|
+
return pat ? `Grep(${pat})` : "Grep";
|
|
58
|
+
} else if (verb === "skill") {
|
|
59
|
+
const input = tc.rawInput as Record<string, unknown> | undefined;
|
|
60
|
+
const name = typeof input?.skill === "string" ? input.skill.slice(0, 40) : "";
|
|
61
|
+
return name ? `Skill(${name})` : "Skill";
|
|
62
|
+
} else if (verb === "todowrite" || verb === "taskcreate" || verb === "taskupdate") {
|
|
63
|
+
const todos = Array.isArray((tc.rawInput as any)?.todos) ? (tc.rawInput as any).todos : [];
|
|
64
|
+
const current = todos.find((t: any) => t.status === "in_progress") ?? todos.find((t: any) => t.status === "pending");
|
|
65
|
+
const label = current ? String(current.content ?? "").slice(0, 40) : "";
|
|
66
|
+
return label || undefined;
|
|
67
|
+
} else if (verb === "askclaude") {
|
|
68
|
+
// Recursive — don't show AskClaude in its own action summary
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return tc.name;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildActionSummary(calls: Map<string, ToolCallState>): string {
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
let prevVerb = "";
|
|
77
|
+
for (const [, tc] of calls) {
|
|
78
|
+
const action = formatToolAction(tc);
|
|
79
|
+
if (!action) continue;
|
|
80
|
+
const verb = tc.name.toLowerCase().split(/\s/)[0];
|
|
81
|
+
// Collapse consecutive calls to the same tool — keep only the latest
|
|
82
|
+
if (verb === prevVerb) {
|
|
83
|
+
parts[parts.length - 1] = action;
|
|
84
|
+
} else {
|
|
85
|
+
parts.push(action);
|
|
86
|
+
}
|
|
87
|
+
prevVerb = verb;
|
|
88
|
+
}
|
|
89
|
+
return parts.join("; ");
|
|
90
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Carrying Claude Code's own attachments across a session rebuild.
|
|
2
|
+
//
|
|
3
|
+
// CC expands an `@file` mention itself — pi passes `@` through untouched — and
|
|
4
|
+
// writes the expansion as a `type: "attachment"` record in its session file. pi
|
|
5
|
+
// never sees it, so rebuilding a session from pi's history drops the file while
|
|
6
|
+
// keeping the prompt text that referred to it: the model silently loses
|
|
7
|
+
// something it was reasoning about, with nothing logged.
|
|
8
|
+
//
|
|
9
|
+
// Extracted from index.ts so tests can import it without activating the extension.
|
|
10
|
+
|
|
11
|
+
import type { JsonlRecord, ImportAttachment } from "cc-session-io";
|
|
12
|
+
import { messageContentToText } from "./convert.js";
|
|
13
|
+
|
|
14
|
+
// Only `@file` expansions are carried. They are the one thing pi genuinely never
|
|
15
|
+
// sees, so a rebuild is the only chance to keep them.
|
|
16
|
+
//
|
|
17
|
+
// `edited_text_file` is deliberately excluded even though it also carries file
|
|
18
|
+
// content. CC writes one after editing a file, and the edit itself is already in
|
|
19
|
+
// pi's history as a tool call and its result, so the attachment duplicates context
|
|
20
|
+
// the rebuild reproduces anyway. It also usually hangs off a *tool result* record
|
|
21
|
+
// rather than a prompt, which has no position in the ordinal scheme below — on
|
|
22
|
+
// real sessions that left 81 of them unresolvable (see
|
|
23
|
+
// diag/attachment-coverage.mjs). Half-carrying a kind is worse than not claiming
|
|
24
|
+
// it: the ones that slipped through would be an arbitrary subset.
|
|
25
|
+
//
|
|
26
|
+
// Everything else CC rewrites every turn (`skill_listing`, `task_reminder`,
|
|
27
|
+
// `agent_listing_delta`, `mcp_instructions_delta`, …) and loses nothing.
|
|
28
|
+
const CONTENT_BEARING = new Set(["file"]);
|
|
29
|
+
|
|
30
|
+
export type CarriedAttachment = {
|
|
31
|
+
attachment: { type: string; [key: string]: unknown };
|
|
32
|
+
/** Position of the parent among the session's text-bearing user records. */
|
|
33
|
+
userOrdinal: number;
|
|
34
|
+
/** That record's text, to verify the ordinal still points at the same turn. */
|
|
35
|
+
parentText: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type Rec = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
/** A user record holding a prompt, as opposed to one holding tool results. */
|
|
41
|
+
function userPromptText(record: Rec): string | undefined {
|
|
42
|
+
if (record.type !== "user") return undefined;
|
|
43
|
+
const content = (record.message as Rec | undefined)?.content;
|
|
44
|
+
if (Array.isArray(content) && content.some((b) => (b as Rec)?.type === "tool_result")) return undefined;
|
|
45
|
+
const text = messageContentToText(content as never);
|
|
46
|
+
return text ? text : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Content-bearing attachments in a session, each tagged with where its parent
|
|
51
|
+
* sits among the text-bearing user records.
|
|
52
|
+
*
|
|
53
|
+
* The ordinal is the mapping key rather than the record index: a rebuild does not
|
|
54
|
+
* reproduce the old record list one-for-one — `importMessages` splits a message
|
|
55
|
+
* carrying tool results into two records, and CC appends records of its own — but
|
|
56
|
+
* the sequence of user prompts is the same conversation either way.
|
|
57
|
+
*
|
|
58
|
+
* Attachments also chain to one another, so an ordinal is resolved transitively up
|
|
59
|
+
* the parent links until it reaches a prompt. Most real attachments are
|
|
60
|
+
* `edited_text_file` records CC writes after editing a file, which have nothing to
|
|
61
|
+
* do with at-mentions; only their position in the conversation matters here.
|
|
62
|
+
*/
|
|
63
|
+
export function collectCarriedAttachments(records: readonly JsonlRecord[]): CarriedAttachment[] {
|
|
64
|
+
const ordinalOf = new Map<string, number>();
|
|
65
|
+
const textOf = new Map<string, string>();
|
|
66
|
+
let ordinal = 0;
|
|
67
|
+
const carried: CarriedAttachment[] = [];
|
|
68
|
+
|
|
69
|
+
for (const raw of records) {
|
|
70
|
+
const record = raw as Rec;
|
|
71
|
+
const prompt = userPromptText(record);
|
|
72
|
+
if (prompt !== undefined) {
|
|
73
|
+
ordinalOf.set(record.uuid as string, ordinal++);
|
|
74
|
+
textOf.set(record.uuid as string, prompt);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (record.type !== "attachment") continue;
|
|
78
|
+
const parent = record.parentUuid as string | null;
|
|
79
|
+
// Attachments chain to each other — a run of them hangs off one prompt, and
|
|
80
|
+
// 63 of 179 in real sessions parent to another attachment rather than to a
|
|
81
|
+
// message. Inherit the ordinal so the whole run keys to the prompt that
|
|
82
|
+
// caused it. Recorded for every attachment, not just the ones carried, since
|
|
83
|
+
// a content-bearing one can chain off a `skill_listing` we ignore.
|
|
84
|
+
if (parent === null || !ordinalOf.has(parent)) continue;
|
|
85
|
+
const inherited = ordinalOf.get(parent)!;
|
|
86
|
+
ordinalOf.set(record.uuid as string, inherited);
|
|
87
|
+
textOf.set(record.uuid as string, textOf.get(parent)!);
|
|
88
|
+
|
|
89
|
+
const attachment = record.attachment as { type: string; [key: string]: unknown } | undefined;
|
|
90
|
+
if (!attachment || !CONTENT_BEARING.has(attachment.type)) continue;
|
|
91
|
+
carried.push({ attachment, userOrdinal: inherited, parentText: textOf.get(parent)! });
|
|
92
|
+
}
|
|
93
|
+
return carried;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve each carried attachment to a position in the array about to be
|
|
98
|
+
* imported — the messages *after* conversion and repair, since that is the index
|
|
99
|
+
* space `importMessages` reads. Repair is idempotent, so an already-repaired array
|
|
100
|
+
* passes through its second run unchanged and the indices stay valid.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately conservative: attaching a file to the wrong turn tells the model it
|
|
103
|
+
* saw something at a point it did not, which is worse than the loss this exists to
|
|
104
|
+
* prevent. So the ordinal has to land on a prompt whose text still matches; any
|
|
105
|
+
* disagreement is reported and dropped rather than approximated.
|
|
106
|
+
*/
|
|
107
|
+
export function placeCarriedAttachments(
|
|
108
|
+
carried: readonly CarriedAttachment[],
|
|
109
|
+
messages: readonly { role: string; content: unknown }[],
|
|
110
|
+
): { attachments: ImportAttachment[]; skipped: string[] } {
|
|
111
|
+
const prompts: { index: number; text: string }[] = [];
|
|
112
|
+
messages.forEach((msg, index) => {
|
|
113
|
+
if (msg.role !== "user") return;
|
|
114
|
+
if (Array.isArray(msg.content) && msg.content.some((b) => (b as Rec)?.type === "tool_result")) return;
|
|
115
|
+
const text = messageContentToText(msg.content as never);
|
|
116
|
+
if (text) prompts.push({ index, text });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const attachments: ImportAttachment[] = [];
|
|
120
|
+
const skipped: string[] = [];
|
|
121
|
+
for (const item of carried) {
|
|
122
|
+
const name = String(item.attachment.filename ?? item.attachment.type);
|
|
123
|
+
const candidate = prompts[item.userOrdinal];
|
|
124
|
+
if (!candidate) {
|
|
125
|
+
skipped.push(`${name}: prompt #${item.userOrdinal} is no longer in history`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (candidate.text !== item.parentText) {
|
|
129
|
+
skipped.push(`${name}: prompt #${item.userOrdinal} changed`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
attachments.push({ afterIndex: candidate.index, attachment: item.attachment });
|
|
133
|
+
}
|
|
134
|
+
return { attachments, skipped };
|
|
135
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Hob loads only the bridge configuration written in its own engine directory.
|
|
2
|
+
// Missing or unparseable files are reported and treated as an empty object.
|
|
3
|
+
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
6
|
+
import { dirname, join } from "path";
|
|
7
|
+
|
|
8
|
+
export interface Config {
|
|
9
|
+
/** Date (YYYY-MM-DD) the one-time startup notice was shown. Written by the extension, not the user. */
|
|
10
|
+
startupNoticeShown?: string;
|
|
11
|
+
/** Date (YYYY-MM-DD) the AskClaude deprecation notice was shown. Written by the extension, not the user. */
|
|
12
|
+
askClaudeDeprecationShown?: string;
|
|
13
|
+
askClaude?: {
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
name?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
defaultMode?: "full" | "read" | "none";
|
|
19
|
+
defaultIsolated?: boolean;
|
|
20
|
+
allowFullMode?: boolean;
|
|
21
|
+
appendSkills?: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** Low-level Claude Agent SDK plumbing. Most users won't need these. */
|
|
24
|
+
provider?: {
|
|
25
|
+
strictMcpConfig?: boolean;
|
|
26
|
+
autoMemoryEnabled?: boolean;
|
|
27
|
+
pathToClaudeCodeExecutable?: string;
|
|
28
|
+
// Subscription plan tier. Setting to "max" enables Opus 4.6 at 1M context
|
|
29
|
+
plan?: "pro" | "max";
|
|
30
|
+
// Set to true to opt into metered 1M context usage ("extra usage" in
|
|
31
|
+
// Anthropic billing). Enables Sonnet 4.6 [1m] on every plan and Opus 4.6
|
|
32
|
+
// [1m] on Pro.
|
|
33
|
+
longContextExtraUsage?: boolean;
|
|
34
|
+
// Model ids (e.g. "claude-future-9") whose declared 1M context Claude Code
|
|
35
|
+
// does not actually serve; pins them to the bare id at 200K.
|
|
36
|
+
forceTwoHundredK?: string[];
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function tryParseJson(path: string): Partial<Config> {
|
|
41
|
+
if (!existsSync(path)) return {};
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error(`claude-bridge: failed to parse ${path}: ${e}`);
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function claudeCodeSettings(provider: Config["provider"] = {}): { autoMemoryEnabled: boolean } {
|
|
51
|
+
return { autoMemoryEnabled: provider.autoMemoryEnabled ?? false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function globalConfigPath(): string {
|
|
55
|
+
return join(getAgentDir(), "claude-bridge.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Record today's date in the global config so the startup notice shows once, preserving every
|
|
59
|
+
* other field. Returns the config path for display either way.
|
|
60
|
+
*
|
|
61
|
+
* Parses directly rather than through tryParseJson, which reports an unparseable file as `{}`:
|
|
62
|
+
* spreading that would replace a user's whole config with just this marker the first time they
|
|
63
|
+
* leave a trailing comma in it. Losing the notice is the cheaper failure, so the write is
|
|
64
|
+
* skipped and the notice simply shows again next session. */
|
|
65
|
+
export function markStartupNoticeShown(): string {
|
|
66
|
+
const path = globalConfigPath();
|
|
67
|
+
let existing: Partial<Config> = {};
|
|
68
|
+
if (existsSync(path)) {
|
|
69
|
+
try {
|
|
70
|
+
existing = JSON.parse(readFileSync(path, "utf-8"));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
|
|
73
|
+
return path;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// en-CA renders YYYY-MM-DD in local time; toISOString() would report UTC.
|
|
77
|
+
const next = { ...existing, startupNoticeShown: new Date().toLocaleDateString("en-CA") };
|
|
78
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
79
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
80
|
+
return path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Record when the AskClaude deprecation notice was shown in the global config, preserving every
|
|
84
|
+
* other field. Returns the config path either way. */
|
|
85
|
+
export function markAskClaudeDeprecationNoticeShown(): string {
|
|
86
|
+
const path = globalConfigPath();
|
|
87
|
+
let existing: Partial<Config> = {};
|
|
88
|
+
if (existsSync(path)) {
|
|
89
|
+
try {
|
|
90
|
+
existing = JSON.parse(readFileSync(path, "utf-8"));
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
|
|
93
|
+
return path;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Match startupNoticeShown's local YYYY-MM-DD date format.
|
|
97
|
+
const next = { ...existing, askClaudeDeprecationShown: new Date().toLocaleDateString("en-CA") };
|
|
98
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
99
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
100
|
+
return path;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadConfig(_cwd: string): Config {
|
|
104
|
+
return tryParseJson(globalConfigPath());
|
|
105
|
+
}
|