mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add built-in grok_local adapter (#6087)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so adapter quality directly affects what runtimes the control plane can supervise. > - Local CLI adapters are one of the core execution surfaces because they turn real coding tools into Paperclip-managed employees with heartbeats, transcripts, and reviewability. > - Grok Build was installed on the Paperclip host, but Paperclip had no built-in `grok_local` adapter, so the runtime could not be configured through the normal server/UI/CLI adapter path. > - That gap needed to be closed with the same built-in registry, environment diagnostics, transcript parsing, and skill/instructions behavior that the other local adapters already rely on. > - After the initial adapter landed, a real follow-up run showed that Grok streaming text was being rendered one fragment per line, which made transcripts harder to read even though the runtime itself was working. > - This pull request adds the built-in `grok_local` adapter end-to-end and then fixes the transcript parser so streamed Grok output is coalesced into readable assistant/thinking blocks. > - The benefit is that Grok Build becomes a first-class Paperclip runtime with a usable operator experience instead of a partially wired runtime with noisy transcript output. ## What Changed - Added a new built-in `@paperclipai/adapter-grok-local` package with server, UI, and CLI entrypoints. - Implemented Grok execution, session handling, environment diagnostics, config building, skill syncing, and parser coverage inside the new adapter package. - Registered `grok_local` across the built-in adapter inventories and capability/display metadata in server, UI, CLI, and shared constants. - Added adapter route coverage for the new built-in type. - Fixed Grok transcript readability by emitting streamed `text` and `thought` fragments as deltas so the shared transcript builder coalesces them into readable message blocks. - Added regression tests for the Grok parser and transcript coalescing behavior. ## Verification - `pnpm vitest run packages/adapters/grok-local/src/ui/parse-stdout.test.ts ui/src/adapters/transcript.test.ts` - `pnpm --filter @paperclipai/adapter-grok-local build` - Manual runtime verification on the Paperclip host during implementation and follow-up review: - confirmed the Grok CLI was installed and authenticated - confirmed the worktree dev server could be restarted cleanly and health-checked after the parser follow-up - No screenshots attached. This change is primarily adapter plumbing plus transcript formatting behavior; reviewers can verify via the Grok-backed run surfaces directly. ## Risks - This adds a new built-in adapter, so any missed registration surface could create inconsistencies between server, UI, and CLI behavior. - The adapter depends on Grok Build's current event/output shape; if upstream Grok streaming JSON changes, transcript parsing or session extraction may need follow-up updates. - The transcript readability fix intentionally changes how Grok fragments are grouped, so any downstream code that implicitly expected one entry per fragment would behave differently. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex via Paperclip `codex_local` agent runtime. - GPT-5-class coding model with tool use, shell execution, file editing, and repo inspection enabled. - Exact backend model ID/context window were not surfaced to the agent in this Paperclip session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
63821bfe4c
commit
ab8b471685
35 changed files with 2031 additions and 0 deletions
|
|
@ -28,6 +28,7 @@ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||||
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
|
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
|
||||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||||
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
||||||
|
COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/
|
||||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-grok-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||||
import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli";
|
import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli";
|
||||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||||
|
import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli";
|
||||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||||
|
|
@ -51,6 +52,11 @@ const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||||
formatStdoutEvent: printGeminiStreamEvent,
|
formatStdoutEvent: printGeminiStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const grokLocalCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "grok_local",
|
||||||
|
formatStdoutEvent: printGrokStreamEvent,
|
||||||
|
};
|
||||||
|
|
||||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||||
|
|
@ -66,6 +72,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||||
cursorLocalCLIAdapter,
|
cursorLocalCLIAdapter,
|
||||||
cursorCloudCLIAdapter,
|
cursorCloudCLIAdapter,
|
||||||
geminiLocalCLIAdapter,
|
geminiLocalCLIAdapter,
|
||||||
|
grokLocalCLIAdapter,
|
||||||
openclawGatewayCLIAdapter,
|
openclawGatewayCLIAdapter,
|
||||||
processCLIAdapter,
|
processCLIAdapter,
|
||||||
httpCLIAdapter,
|
httpCLIAdapter,
|
||||||
|
|
|
||||||
60
packages/adapters/grok-local/package.json
Normal file
60
packages/adapters/grok-local/package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "@paperclipai/adapter-grok-local",
|
||||||
|
"version": "0.3.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/paperclipai/paperclip",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/paperclipai/paperclip",
|
||||||
|
"directory": "packages/adapters/grok-local"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./server": "./src/server/index.ts",
|
||||||
|
"./ui": "./src/ui/index.ts",
|
||||||
|
"./cli": "./src/cli/index.ts"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
},
|
||||||
|
"./ui": {
|
||||||
|
"types": "./dist/ui/index.d.ts",
|
||||||
|
"import": "./dist/ui/index.js"
|
||||||
|
},
|
||||||
|
"./cli": {
|
||||||
|
"types": "./dist/cli/index.d.ts",
|
||||||
|
"import": "./dist/cli/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/adapters/grok-local/src/cli/format-event.test.ts
Normal file
24
packages/adapters/grok-local/src/cli/format-event.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { printGrokStreamEvent } from "./format-event.js";
|
||||||
|
|
||||||
|
describe("printGrokStreamEvent", () => {
|
||||||
|
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
spy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints thought/text/end events", () => {
|
||||||
|
printGrokStreamEvent(JSON.stringify({ type: "thought", data: "Plan" }), false);
|
||||||
|
printGrokStreamEvent(JSON.stringify({ type: "text", data: "hello" }), false);
|
||||||
|
printGrokStreamEvent(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), false);
|
||||||
|
|
||||||
|
expect(spy.mock.calls.flat()).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining("thinking: Plan"),
|
||||||
|
expect.stringContaining("assistant: hello"),
|
||||||
|
expect.stringContaining("Grok run completed"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
packages/adapters/grok-local/src/cli/format-event.ts
Normal file
59
packages/adapters/grok-local/src/cli/format-event.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import pc from "picocolors";
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printGrokStreamEvent(raw: string, _debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
console.log(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = asString(parsed.type).trim();
|
||||||
|
if (type === "thought") {
|
||||||
|
const text = asString(parsed.data);
|
||||||
|
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
const text = asString(parsed.data);
|
||||||
|
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "end") {
|
||||||
|
const stopReason = asString(parsed.stopReason);
|
||||||
|
const sessionId = asString(parsed.sessionId);
|
||||||
|
const details = [stopReason ? `stopReason=${stopReason}` : "", sessionId ? `session=${sessionId}` : ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
console.log(pc.blue(`Grok run completed${details ? ` (${details})` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const text =
|
||||||
|
asString(parsed.data) ||
|
||||||
|
asString(parsed.message) ||
|
||||||
|
asString(parsed.error) ||
|
||||||
|
"Grok error";
|
||||||
|
console.log(pc.red(`error: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(parsed);
|
||||||
|
console.log(pc.gray(`event: ${type || "unknown"} ${payload ? JSON.stringify(payload) : line}`));
|
||||||
|
}
|
||||||
1
packages/adapters/grok-local/src/cli/index.ts
Normal file
1
packages/adapters/grok-local/src/cli/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { printGrokStreamEvent } from "./format-event.js";
|
||||||
45
packages/adapters/grok-local/src/index.ts
Normal file
45
packages/adapters/grok-local/src/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
export const type = "grok_local";
|
||||||
|
export const label = "Grok Build (local)";
|
||||||
|
|
||||||
|
export const DEFAULT_GROK_LOCAL_MODEL = "grok-build";
|
||||||
|
|
||||||
|
export const models = [
|
||||||
|
{ id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const agentConfigurationDoc = `# grok_local agent configuration
|
||||||
|
|
||||||
|
Adapter: grok_local
|
||||||
|
|
||||||
|
Use when:
|
||||||
|
- You want Paperclip to run the native Grok Build CLI locally on the host machine
|
||||||
|
- You want resumable Grok sessions across heartbeats via \`--resume\`
|
||||||
|
- You want Paperclip-managed instructions and skills staged into the execution workspace using Grok's native discovery paths (\`Agents.md\` and \`.claude/skills\`)
|
||||||
|
|
||||||
|
Don't use when:
|
||||||
|
- You need a webhook-style external invocation (use http or openclaw_gateway)
|
||||||
|
- You only need a one-shot script without an AI coding agent loop (use process)
|
||||||
|
- Grok CLI is not installed or authenticated on the machine that runs Paperclip
|
||||||
|
|
||||||
|
Core fields:
|
||||||
|
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||||
|
- instructionsFilePath (string, optional): absolute path to a markdown instructions file. Paperclip stages it into the execution workspace as \`Agents.md\` when safe, otherwise falls back to \`--rules @file\`
|
||||||
|
- promptTemplate (string, optional): run prompt template
|
||||||
|
- model (string, optional): Grok model id. Defaults to grok-build.
|
||||||
|
- permissionMode (string, optional): Grok permission mode. Defaults to \`dontAsk\`
|
||||||
|
- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\`
|
||||||
|
- maxTurns (number, optional): maximum agent turns for the run
|
||||||
|
- command (string, optional): defaults to "grok"
|
||||||
|
- extraArgs (string[], optional): additional CLI args
|
||||||
|
- env (object, optional): KEY=VALUE environment variables
|
||||||
|
|
||||||
|
Operational fields:
|
||||||
|
- timeoutSec (number, optional): run timeout in seconds
|
||||||
|
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Runs use \`grok --single\` with \`--output-format streaming-json\`.
|
||||||
|
- Sessions resume with \`--resume <sessionId>\` when the saved session cwd matches the current cwd.
|
||||||
|
- Paperclip stages desired runtime skills into \`.claude/skills\` inside the execution workspace so Grok discovers them as project skills.
|
||||||
|
- Use \`grok models\` to inspect authentication and available models on the host.
|
||||||
|
`;
|
||||||
187
packages/adapters/grok-local/src/server/execute.test.ts
Normal file
187
packages/adapters/grok-local/src/server/execute.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||||
|
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||||
|
const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({
|
||||||
|
workspaceRemoteDir: null,
|
||||||
|
restoreWorkspace: async () => {},
|
||||||
|
})));
|
||||||
|
const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok"));
|
||||||
|
const runProcessMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
|
||||||
|
adapterExecutionTargetIsRemote: () => false,
|
||||||
|
adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd,
|
||||||
|
overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target,
|
||||||
|
adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }),
|
||||||
|
adapterExecutionTargetSessionMatches: () => true,
|
||||||
|
describeAdapterExecutionTarget: () => "local",
|
||||||
|
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
|
||||||
|
ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock,
|
||||||
|
prepareAdapterExecutionTargetRuntime: prepareRuntimeMock,
|
||||||
|
readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" },
|
||||||
|
resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock,
|
||||||
|
resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec,
|
||||||
|
runAdapterExecutionTargetProcess: runProcessMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { execute } from "./execute.js";
|
||||||
|
|
||||||
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
|
async function makeTempRoot() {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-local-"));
|
||||||
|
tempRoots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(candidate: string): Promise<boolean> {
|
||||||
|
return fs.access(candidate).then(() => true).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("grok_local execute", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ensureRuntimeInstalledMock.mockClear();
|
||||||
|
ensureCommandMock.mockClear();
|
||||||
|
prepareRuntimeMock.mockClear();
|
||||||
|
resolveCommandForLogsMock.mockClear();
|
||||||
|
runProcessMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages Grok-native instructions and skills into the workspace for the run and cleans them up afterward", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const instructionsPath = path.join(root, "managed", "AGENTS.md");
|
||||||
|
const skillSource = path.join(root, "runtime-skills", "paperclip");
|
||||||
|
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
|
||||||
|
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
|
||||||
|
await fs.mkdir(skillSource, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
|
||||||
|
|
||||||
|
runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => {
|
||||||
|
expect(args).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"--output-format",
|
||||||
|
"streaming-json",
|
||||||
|
"--always-approve",
|
||||||
|
"--permission-mode",
|
||||||
|
"dontAsk",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(await fs.readFile(path.join(root, "Agents.md"), "utf8")).toContain("You are Grok.");
|
||||||
|
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip", "SKILL.md"))).toBe(true);
|
||||||
|
await options.onLog?.("stdout", '{"type":"text","data":"done"}\n');
|
||||||
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
stdout: [
|
||||||
|
JSON.stringify({ type: "text", data: "done" }),
|
||||||
|
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
|
||||||
|
].join("\n"),
|
||||||
|
stderr: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
|
||||||
|
const ctx: AdapterExecutionContext = {
|
||||||
|
runId: "run-1",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Grok Agent",
|
||||||
|
adapterType: "grok_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
cwd: root,
|
||||||
|
instructionsFilePath: instructionsPath,
|
||||||
|
paperclipRuntimeSkills: [{
|
||||||
|
key: "paperclip",
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
source: skillSource,
|
||||||
|
required: false,
|
||||||
|
}],
|
||||||
|
paperclipSkillSync: { desiredSkills: ["paperclip"] },
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
authToken: "run-token",
|
||||||
|
onLog: async (stream: "stdout" | "stderr", chunk: string) => {
|
||||||
|
logs.push({ stream, chunk });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await execute(ctx);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
exitCode: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
summary: "done",
|
||||||
|
sessionId: "sess-1",
|
||||||
|
sessionDisplayId: "sess-1",
|
||||||
|
});
|
||||||
|
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
|
||||||
|
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
|
||||||
|
expect(logs.map((entry) => entry.chunk)).not.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up staged assets when setup fails before the Grok process starts", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const instructionsPath = path.join(root, "managed", "AGENTS.md");
|
||||||
|
const skillSource = path.join(root, "runtime-skills", "paperclip");
|
||||||
|
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
|
||||||
|
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
|
||||||
|
await fs.mkdir(skillSource, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
|
||||||
|
ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed"));
|
||||||
|
|
||||||
|
const ctx: AdapterExecutionContext = {
|
||||||
|
runId: "run-setup-fail",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Grok Agent",
|
||||||
|
adapterType: "grok_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
cwd: root,
|
||||||
|
instructionsFilePath: instructionsPath,
|
||||||
|
paperclipRuntimeSkills: [{
|
||||||
|
key: "paperclip",
|
||||||
|
runtimeName: "paperclip",
|
||||||
|
source: skillSource,
|
||||||
|
required: false,
|
||||||
|
}],
|
||||||
|
paperclipSkillSync: { desiredSkills: ["paperclip"] },
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
authToken: "run-token",
|
||||||
|
onLog: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(execute(ctx)).rejects.toThrow("grok not installed");
|
||||||
|
expect(runProcessMock).not.toHaveBeenCalled();
|
||||||
|
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
|
||||||
|
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
583
packages/adapters/grok-local/src/server/execute.ts
Normal file
583
packages/adapters/grok-local/src/server/execute.ts
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||||
|
import {
|
||||||
|
adapterExecutionTargetIsRemote,
|
||||||
|
adapterExecutionTargetRemoteCwd,
|
||||||
|
adapterExecutionTargetSessionIdentity,
|
||||||
|
adapterExecutionTargetSessionMatches,
|
||||||
|
describeAdapterExecutionTarget,
|
||||||
|
ensureAdapterExecutionTargetCommandResolvable,
|
||||||
|
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||||
|
overrideAdapterExecutionTargetRemoteCwd,
|
||||||
|
prepareAdapterExecutionTargetRuntime,
|
||||||
|
readAdapterExecutionTarget,
|
||||||
|
resolveAdapterExecutionTargetCommandForLogs,
|
||||||
|
resolveAdapterExecutionTargetTimeoutSec,
|
||||||
|
runAdapterExecutionTargetProcess,
|
||||||
|
} from "@paperclipai/adapter-utils/execution-target";
|
||||||
|
import {
|
||||||
|
asBoolean,
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
asStringArray,
|
||||||
|
buildInvocationEnvForLogs,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
ensurePathInEnv,
|
||||||
|
joinPromptSections,
|
||||||
|
materializePaperclipSkillCopy,
|
||||||
|
parseObject,
|
||||||
|
readPaperclipIssueWorkModeFromContext,
|
||||||
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
renderTemplate,
|
||||||
|
renderPaperclipWakePrompt,
|
||||||
|
resolvePaperclipDesiredSkillNames,
|
||||||
|
stringifyPaperclipWakePayload,
|
||||||
|
refreshPaperclipWorkspaceEnvForExecution,
|
||||||
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||||
|
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
|
||||||
|
|
||||||
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function firstNonEmptyLine(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||||
|
const raw = env[key];
|
||||||
|
return typeof raw === "string" && raw.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||||
|
const paperclipKeys = Object.keys(env)
|
||||||
|
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||||
|
.sort();
|
||||||
|
if (paperclipKeys.length === 0) return "";
|
||||||
|
return [
|
||||||
|
"Paperclip runtime note:",
|
||||||
|
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||||
|
"Do not assume these variables are missing without checking your shell environment.",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApiAccessNote(env: Record<string, string>): string {
|
||||||
|
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
|
||||||
|
return [
|
||||||
|
"Paperclip API access note:",
|
||||||
|
"Use shell commands with curl to make Paperclip API requests when needed.",
|
||||||
|
"Include X-Paperclip-Run-Id on mutating requests.",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageCleanup = {
|
||||||
|
kind: "file" | "dir";
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StagedGrokAssets = {
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
stagedSkillsCount: number;
|
||||||
|
stagedInstructionsPath: string | null;
|
||||||
|
rulesFilePath: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function pathExists(candidate: string): Promise<boolean> {
|
||||||
|
return fs.access(candidate).then(() => true).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageGrokProjectAssets(input: {
|
||||||
|
cwd: string;
|
||||||
|
instructionsFilePath: string;
|
||||||
|
skillEntries: Array<{ key: string; runtimeName: string; source: string }>;
|
||||||
|
desiredSkillNames: string[];
|
||||||
|
onLog: AdapterExecutionContext["onLog"];
|
||||||
|
}): Promise<StagedGrokAssets> {
|
||||||
|
const cleanup: StageCleanup[] = [];
|
||||||
|
const ensureCleanupDir = (candidate: string) => {
|
||||||
|
cleanup.push({ kind: "dir", path: candidate });
|
||||||
|
};
|
||||||
|
const ensureCleanupFile = (candidate: string) => {
|
||||||
|
cleanup.push({ kind: "file", path: candidate });
|
||||||
|
};
|
||||||
|
|
||||||
|
let stagedInstructionsPath: string | null = null;
|
||||||
|
let rulesFilePath: string | null = null;
|
||||||
|
let stagedSkillsCount = 0;
|
||||||
|
|
||||||
|
const instructionsTarget = path.join(input.cwd, "Agents.md");
|
||||||
|
if (input.instructionsFilePath) {
|
||||||
|
if (!await pathExists(instructionsTarget)) {
|
||||||
|
await fs.copyFile(input.instructionsFilePath, instructionsTarget);
|
||||||
|
ensureCleanupFile(instructionsTarget);
|
||||||
|
stagedInstructionsPath = instructionsTarget;
|
||||||
|
} else if (path.resolve(instructionsTarget) !== path.resolve(input.instructionsFilePath)) {
|
||||||
|
rulesFilePath = input.instructionsFilePath;
|
||||||
|
await input.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Grok workspace already contains ${instructionsTarget}; using --rules @${input.instructionsFilePath} instead of overwriting it.\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const canonicalAgents = path.join(input.cwd, "AGENTS.md");
|
||||||
|
if (!await pathExists(instructionsTarget) && await pathExists(canonicalAgents)) {
|
||||||
|
await fs.copyFile(canonicalAgents, instructionsTarget);
|
||||||
|
ensureCleanupFile(instructionsTarget);
|
||||||
|
stagedInstructionsPath = instructionsTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredSet = new Set(input.desiredSkillNames);
|
||||||
|
const selectedSkills = input.skillEntries.filter((entry) => desiredSet.has(entry.key));
|
||||||
|
if (selectedSkills.length > 0) {
|
||||||
|
const claudeDir = path.join(input.cwd, ".claude");
|
||||||
|
const skillsRoot = path.join(claudeDir, "skills");
|
||||||
|
if (!await pathExists(claudeDir)) {
|
||||||
|
await fs.mkdir(claudeDir, { recursive: true });
|
||||||
|
ensureCleanupDir(claudeDir);
|
||||||
|
}
|
||||||
|
if (!await pathExists(skillsRoot)) {
|
||||||
|
await fs.mkdir(skillsRoot, { recursive: true });
|
||||||
|
ensureCleanupDir(skillsRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const skill of selectedSkills) {
|
||||||
|
const target = path.join(skillsRoot, skill.runtimeName);
|
||||||
|
if (await pathExists(target)) {
|
||||||
|
await input.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Grok skill target already exists at ${target}; leaving it unchanged.\n`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await materializePaperclipSkillCopy(skill.source, target);
|
||||||
|
ensureCleanupDir(target);
|
||||||
|
stagedSkillsCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stagedSkillsCount,
|
||||||
|
stagedInstructionsPath,
|
||||||
|
rulesFilePath,
|
||||||
|
cleanup: async () => {
|
||||||
|
for (const entry of [...cleanup].reverse()) {
|
||||||
|
if (entry.kind === "file") {
|
||||||
|
await fs.rm(entry.path, { force: true }).catch(() => undefined);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await fs.rm(entry.path, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||||
|
return hasNonEmptyEnvValue(env, "XAI_API_KEY") ? "api" : "subscription";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||||
|
const executionTarget = readAdapterExecutionTarget({
|
||||||
|
executionTarget: ctx.executionTarget,
|
||||||
|
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
|
||||||
|
});
|
||||||
|
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
||||||
|
|
||||||
|
const promptTemplate = asString(
|
||||||
|
config.promptTemplate,
|
||||||
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||||
|
);
|
||||||
|
const command = asString(config.command, "grok");
|
||||||
|
const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
|
||||||
|
const permissionMode = asString(config.permissionMode, "dontAsk").trim() || "dontAsk";
|
||||||
|
const reasoningEffort = asString(config.reasoningEffort, "").trim();
|
||||||
|
const maxTurns = asNumber(config.maxTurns, 0);
|
||||||
|
const alwaysApprove = asBoolean(config.alwaysApprove, true);
|
||||||
|
const disableWebSearch = asBoolean(config.disableWebSearch, true);
|
||||||
|
|
||||||
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||||
|
const workspaceSource = asString(workspaceContext.source, "");
|
||||||
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||||
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||||
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||||
|
const agentHome = asString(workspaceContext.agentHome, "");
|
||||||
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||||
|
? context.paperclipWorkspaces.filter(
|
||||||
|
(value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const configuredCwd = asString(config.cwd, "");
|
||||||
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||||
|
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
|
|
||||||
|
const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
|
const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries);
|
||||||
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
|
const stagedAssets = await stageGrokProjectAssets({
|
||||||
|
cwd,
|
||||||
|
instructionsFilePath,
|
||||||
|
skillEntries: grokSkillEntries,
|
||||||
|
desiredSkillNames: desiredGrokSkillNames,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const hasExplicitApiKey =
|
||||||
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
|
env.PAPERCLIP_RUN_ID = runId;
|
||||||
|
const wakeTaskId =
|
||||||
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||||
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||||
|
null;
|
||||||
|
const wakeReason =
|
||||||
|
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||||
|
? context.wakeReason.trim()
|
||||||
|
: null;
|
||||||
|
const wakeCommentId =
|
||||||
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||||
|
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||||
|
null;
|
||||||
|
const approvalId =
|
||||||
|
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||||
|
? context.approvalId.trim()
|
||||||
|
: null;
|
||||||
|
const approvalStatus =
|
||||||
|
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||||
|
? context.approvalStatus.trim()
|
||||||
|
: null;
|
||||||
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
|
? context.issueIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||||
|
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
||||||
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
|
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
||||||
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||||
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||||
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||||
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||||
|
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
|
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||||
|
refreshPaperclipWorkspaceEnvForExecution({
|
||||||
|
env,
|
||||||
|
envConfig,
|
||||||
|
workspaceCwd: effectiveWorkspaceCwd,
|
||||||
|
workspaceSource,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRepoUrl,
|
||||||
|
workspaceRepoRef,
|
||||||
|
workspaceHints,
|
||||||
|
agentHome,
|
||||||
|
executionTargetIsRemote,
|
||||||
|
executionCwd: effectiveExecutionCwd,
|
||||||
|
});
|
||||||
|
if (!hasExplicitApiKey && authToken) {
|
||||||
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
|
||||||
|
executionTarget,
|
||||||
|
asNumber(config.timeoutSec, 0),
|
||||||
|
);
|
||||||
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||||
|
runId,
|
||||||
|
target: executionTarget,
|
||||||
|
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||||
|
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (executionTargetIsRemote) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||||
|
);
|
||||||
|
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
|
||||||
|
runId,
|
||||||
|
target: executionTarget,
|
||||||
|
adapterKey: "grok",
|
||||||
|
workspaceLocalDir: cwd,
|
||||||
|
timeoutSec,
|
||||||
|
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
|
||||||
|
detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command,
|
||||||
|
});
|
||||||
|
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
|
||||||
|
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
|
||||||
|
refreshPaperclipWorkspaceEnvForExecution({
|
||||||
|
env,
|
||||||
|
envConfig,
|
||||||
|
workspaceCwd: effectiveWorkspaceCwd,
|
||||||
|
workspaceSource,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRepoUrl,
|
||||||
|
workspaceRepoRef,
|
||||||
|
workspaceHints,
|
||||||
|
agentHome,
|
||||||
|
executionTargetIsRemote,
|
||||||
|
executionCwd: effectiveExecutionCwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
|
||||||
|
const effectiveEnv = Object.fromEntries(
|
||||||
|
Object.entries({ ...process.env, ...env }).filter(
|
||||||
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||||
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
|
||||||
|
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
|
||||||
|
timeoutSec,
|
||||||
|
});
|
||||||
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||||
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||||
|
runtimeEnv,
|
||||||
|
includeRuntimeKeys: ["HOME"],
|
||||||
|
resolvedCommand,
|
||||||
|
});
|
||||||
|
const billingType = resolveBillingType(effectiveEnv);
|
||||||
|
|
||||||
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||||
|
const canResumeSession =
|
||||||
|
runtimeSessionId.length > 0 &&
|
||||||
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||||
|
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||||
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Grok session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
||||||
|
);
|
||||||
|
} else if (runtimeSessionId && !canResumeSession) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Grok session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandNotes = (() => {
|
||||||
|
const notes: string[] = ["Prompt is passed to Grok via --single in headless mode."];
|
||||||
|
if (alwaysApprove) notes.push("Added --always-approve for unattended execution.");
|
||||||
|
if (stagedAssets.stagedInstructionsPath) {
|
||||||
|
notes.push(`Staged project instructions at ${stagedAssets.stagedInstructionsPath} for native Grok discovery.`);
|
||||||
|
}
|
||||||
|
if (stagedAssets.rulesFilePath) {
|
||||||
|
notes.push(`Applied fallback instructions via --rules @${stagedAssets.rulesFilePath}.`);
|
||||||
|
}
|
||||||
|
if (stagedAssets.stagedSkillsCount > 0) {
|
||||||
|
notes.push(`Staged ${stagedAssets.stagedSkillsCount} Paperclip skill(s) into .claude/skills for native Grok discovery.`);
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
agentId: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
runId,
|
||||||
|
company: { id: agent.companyId },
|
||||||
|
agent,
|
||||||
|
run: { id: runId, source: "on_demand" },
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||||
|
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||||
|
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||||
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
|
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||||
|
const apiAccessNote = renderApiAccessNote(env);
|
||||||
|
const prompt = joinPromptSections([
|
||||||
|
wakePrompt,
|
||||||
|
sessionHandoffNote,
|
||||||
|
paperclipEnvNote,
|
||||||
|
apiAccessNote,
|
||||||
|
renderedPrompt,
|
||||||
|
]);
|
||||||
|
const promptMetrics = {
|
||||||
|
promptChars: prompt.length,
|
||||||
|
wakePromptChars: wakePrompt.length,
|
||||||
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
|
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||||
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
|
const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"];
|
||||||
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
|
if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model);
|
||||||
|
if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort);
|
||||||
|
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||||
|
if (permissionMode) args.push("--permission-mode", permissionMode);
|
||||||
|
if (alwaysApprove) args.push("--always-approve");
|
||||||
|
if (disableWebSearch) args.push("--disable-web-search");
|
||||||
|
if (stagedAssets.rulesFilePath) args.push("--rules", `@${stagedAssets.rulesFilePath}`);
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
args.push("--single", prompt);
|
||||||
|
return args;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAttempt = async (resumeSessionId: string | null) => {
|
||||||
|
const args = buildArgs(resumeSessionId);
|
||||||
|
if (onMeta) {
|
||||||
|
await onMeta({
|
||||||
|
adapterType: "grok_local",
|
||||||
|
command: resolvedCommand,
|
||||||
|
cwd: effectiveExecutionCwd,
|
||||||
|
commandNotes,
|
||||||
|
commandArgs: args.map((value, index) => (
|
||||||
|
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||||
|
)),
|
||||||
|
env: loggedEnv,
|
||||||
|
prompt,
|
||||||
|
promptMetrics,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onSpawn,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
proc,
|
||||||
|
parsed: parseGrokJsonl(proc.stdout),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toResult = (
|
||||||
|
attempt: {
|
||||||
|
proc: {
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
timedOut: boolean;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
parsed: ReturnType<typeof parseGrokJsonl>;
|
||||||
|
},
|
||||||
|
clearSessionOnMissingSession = false,
|
||||||
|
isRetry = false,
|
||||||
|
): AdapterExecutionResult => {
|
||||||
|
if (attempt.proc.timedOut) {
|
||||||
|
return {
|
||||||
|
exitCode: attempt.proc.exitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
|
clearSession: clearSessionOnMissingSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = (attempt.proc.exitCode ?? 0) !== 0;
|
||||||
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
|
const fallbackErrorMessage =
|
||||||
|
parsedError ||
|
||||||
|
stderrLine ||
|
||||||
|
`Grok exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||||
|
|
||||||
|
const canFallbackToRuntimeSession = !isRetry;
|
||||||
|
const resolvedSessionId = attempt.parsed.sessionId
|
||||||
|
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||||
|
const resolvedSessionParams = resolvedSessionId
|
||||||
|
? ({
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
cwd: effectiveExecutionCwd,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
...(executionTargetIsRemote
|
||||||
|
? {
|
||||||
|
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
} as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: attempt.proc.exitCode,
|
||||||
|
signal: attempt.proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: failed ? fallbackErrorMessage : null,
|
||||||
|
usage: {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cachedInputTokens: 0,
|
||||||
|
},
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
sessionParams: resolvedSessionParams,
|
||||||
|
sessionDisplayId: resolvedSessionId,
|
||||||
|
provider: "xai",
|
||||||
|
biller: billingType === "api" ? "xai" : "grok",
|
||||||
|
model,
|
||||||
|
billingType,
|
||||||
|
costUsd: null,
|
||||||
|
resultJson: {
|
||||||
|
stopReason: attempt.parsed.stopReason,
|
||||||
|
requestId: attempt.parsed.requestId,
|
||||||
|
...(failed ? { stderr: attempt.proc.stderr } : {}),
|
||||||
|
},
|
||||||
|
summary: attempt.parsed.summary,
|
||||||
|
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = await runAttempt(sessionId);
|
||||||
|
if (
|
||||||
|
sessionId &&
|
||||||
|
!initial.proc.timedOut &&
|
||||||
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
|
isGrokUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||||
|
) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Grok resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
|
);
|
||||||
|
const retry = await runAttempt(null);
|
||||||
|
return toResult(retry, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResult(initial);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
restoreRemoteWorkspace?.(),
|
||||||
|
stagedAssets.cleanup(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
packages/adapters/grok-local/src/server/index.ts
Normal file
66
packages/adapters/grok-local/src/server/index.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function readNonEmptyString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionCodec: AdapterSessionCodec = {
|
||||||
|
deserialize(raw: unknown) {
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(record.sessionId) ??
|
||||||
|
readNonEmptyString(record.session_id) ??
|
||||||
|
readNonEmptyString(record.sessionID);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const cwd =
|
||||||
|
readNonEmptyString(record.cwd) ??
|
||||||
|
readNonEmptyString(record.workdir) ??
|
||||||
|
readNonEmptyString(record.folder);
|
||||||
|
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||||
|
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||||
|
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
serialize(params: Record<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
const sessionId =
|
||||||
|
readNonEmptyString(params.sessionId) ??
|
||||||
|
readNonEmptyString(params.session_id) ??
|
||||||
|
readNonEmptyString(params.sessionID);
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const cwd =
|
||||||
|
readNonEmptyString(params.cwd) ??
|
||||||
|
readNonEmptyString(params.workdir) ??
|
||||||
|
readNonEmptyString(params.folder);
|
||||||
|
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||||
|
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||||
|
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(repoUrl ? { repoUrl } : {}),
|
||||||
|
...(repoRef ? { repoRef } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getDisplayId(params: Record<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
return (
|
||||||
|
readNonEmptyString(params.sessionId) ??
|
||||||
|
readNonEmptyString(params.session_id) ??
|
||||||
|
readNonEmptyString(params.sessionID)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { execute } from "./execute.js";
|
||||||
|
export { listGrokSkills, syncGrokSkills } from "./skills.js";
|
||||||
|
export { testEnvironment } from "./test.js";
|
||||||
|
export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js";
|
||||||
38
packages/adapters/grok-local/src/server/parse.test.ts
Normal file
38
packages/adapters/grok-local/src/server/parse.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
|
||||||
|
|
||||||
|
describe("parseGrokJsonl", () => {
|
||||||
|
it("collects streamed thought/text content and final session metadata", () => {
|
||||||
|
const parsed = parseGrokJsonl([
|
||||||
|
JSON.stringify({ type: "thought", data: "Plan" }),
|
||||||
|
JSON.stringify({ type: "thought", data: " first." }),
|
||||||
|
JSON.stringify({ type: "text", data: "hel" }),
|
||||||
|
JSON.stringify({ type: "text", data: "lo" }),
|
||||||
|
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
|
||||||
|
].join("\n"));
|
||||||
|
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
sessionId: "sess-1",
|
||||||
|
summary: "hello",
|
||||||
|
thought: "Plan first.",
|
||||||
|
errorMessage: null,
|
||||||
|
stopReason: "EndTurn",
|
||||||
|
requestId: "req-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads structured error payloads", () => {
|
||||||
|
const parsed = parseGrokJsonl([
|
||||||
|
JSON.stringify({ type: "error", error: { message: "Authentication required" } }),
|
||||||
|
].join("\n"));
|
||||||
|
|
||||||
|
expect(parsed.errorMessage).toBe("Authentication required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isGrokUnknownSessionError", () => {
|
||||||
|
it("detects stale resume failures", () => {
|
||||||
|
expect(isGrokUnknownSessionError("", "session not found")).toBe(true);
|
||||||
|
expect(isGrokUnknownSessionError("", "everything fine")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
packages/adapters/grok-local/src/server/parse.ts
Normal file
87
packages/adapters/grok-local/src/server/parse.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
export interface ParsedGrokJsonl {
|
||||||
|
sessionId: string | null;
|
||||||
|
summary: string;
|
||||||
|
thought: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
stopReason: string | null;
|
||||||
|
requestId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
const rec = parseObject(value);
|
||||||
|
const message =
|
||||||
|
asString(rec.message, "").trim() ||
|
||||||
|
asString(rec.error, "").trim() ||
|
||||||
|
asString(rec.detail, "").trim() ||
|
||||||
|
asString(rec.code, "").trim();
|
||||||
|
if (message) return message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(rec);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGrokJsonl(stdout: string): ParsedGrokJsonl {
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
let stopReason: string | null = null;
|
||||||
|
let requestId: string | null = null;
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
const thoughtParts: string[] = [];
|
||||||
|
const textParts: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const event = parseJson(line);
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
const type = asString(event.type, "").trim();
|
||||||
|
if (type === "thought") {
|
||||||
|
const text = asString(event.data, "");
|
||||||
|
if (text) thoughtParts.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
const text = asString(event.data, "");
|
||||||
|
if (text) textParts.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "end") {
|
||||||
|
sessionId = asString(event.sessionId, "").trim() || sessionId;
|
||||||
|
stopReason = asString(event.stopReason, "").trim() || stopReason;
|
||||||
|
requestId = asString(event.requestId, "").trim() || requestId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const text = errorText(event.error ?? event.message ?? event.detail ?? event.data).trim();
|
||||||
|
if (text) errorMessage = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
summary: textParts.join("").trim(),
|
||||||
|
thought: thoughtParts.join("").trim(),
|
||||||
|
errorMessage,
|
||||||
|
stopReason,
|
||||||
|
requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGrokUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||||
|
const haystack = `${stdout}\n${stderr}`
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|invalid\s+session/i.test(haystack);
|
||||||
|
}
|
||||||
80
packages/adapters/grok-local/src/server/skills.ts
Normal file
80
packages/adapters/grok-local/src/server/skills.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type {
|
||||||
|
AdapterSkillContext,
|
||||||
|
AdapterSkillEntry,
|
||||||
|
AdapterSkillSnapshot,
|
||||||
|
} from "@paperclipai/adapter-utils";
|
||||||
|
import {
|
||||||
|
readPaperclipRuntimeSkillEntries,
|
||||||
|
resolvePaperclipDesiredSkillNames,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
async function buildGrokSkillSnapshot(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Promise<AdapterSkillSnapshot> {
|
||||||
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
|
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||||
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||||
|
const desiredSet = new Set(desiredSkills);
|
||||||
|
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
runtimeName: entry.runtimeName,
|
||||||
|
desired: desiredSet.has(entry.key),
|
||||||
|
managed: true,
|
||||||
|
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||||
|
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||||
|
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: entry.source,
|
||||||
|
targetPath: null,
|
||||||
|
detail: desiredSet.has(entry.key)
|
||||||
|
? "Will be copied into `.claude/skills` in the execution workspace on the next run."
|
||||||
|
: null,
|
||||||
|
required: Boolean(entry.required),
|
||||||
|
requiredReason: entry.requiredReason ?? null,
|
||||||
|
}));
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
for (const desiredSkill of desiredSkills) {
|
||||||
|
if (availableByKey.has(desiredSkill)) continue;
|
||||||
|
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||||
|
entries.push({
|
||||||
|
key: desiredSkill,
|
||||||
|
runtimeName: null,
|
||||||
|
desired: true,
|
||||||
|
managed: true,
|
||||||
|
state: "missing",
|
||||||
|
origin: "external_unknown",
|
||||||
|
originLabel: "External or unavailable",
|
||||||
|
readOnly: false,
|
||||||
|
sourcePath: null,
|
||||||
|
targetPath: null,
|
||||||
|
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType: "grok_local",
|
||||||
|
supported: true,
|
||||||
|
mode: "ephemeral",
|
||||||
|
desiredSkills,
|
||||||
|
entries,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGrokSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||||
|
return buildGrokSkillSnapshot(ctx.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncGrokSkills(
|
||||||
|
ctx: AdapterSkillContext,
|
||||||
|
_desiredSkills: string[],
|
||||||
|
): Promise<AdapterSkillSnapshot> {
|
||||||
|
return buildGrokSkillSnapshot(ctx.config);
|
||||||
|
}
|
||||||
142
packages/adapters/grok-local/src/server/test.test.ts
Normal file
142
packages/adapters/grok-local/src/server/test.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||||
|
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||||
|
const runProcessMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
|
||||||
|
describeAdapterExecutionTarget: () => "local",
|
||||||
|
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
|
||||||
|
ensureAdapterExecutionTargetDirectory: ensureDirectoryMock,
|
||||||
|
resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) =>
|
||||||
|
configuredCwd || fallbackCwd,
|
||||||
|
runAdapterExecutionTargetProcess: runProcessMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { parseGrokModelsOutput, testEnvironment } from "./test.js";
|
||||||
|
|
||||||
|
describe("parseGrokModelsOutput", () => {
|
||||||
|
it("extracts auth state and models from `grok models` output", () => {
|
||||||
|
expect(parseGrokModelsOutput([
|
||||||
|
"You are logged in with grok.com.",
|
||||||
|
"",
|
||||||
|
"Default model: grok-build",
|
||||||
|
"",
|
||||||
|
"Available models:",
|
||||||
|
" * grok-build (default)",
|
||||||
|
" * grok-code",
|
||||||
|
].join("\n"))).toEqual({
|
||||||
|
authenticated: true,
|
||||||
|
defaultModel: "grok-build",
|
||||||
|
models: ["grok-build", "grok-code"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("grok_local testEnvironment", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ensureDirectoryMock.mockClear();
|
||||||
|
ensureCommandMock.mockClear();
|
||||||
|
runProcessMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a healthy authenticated host with a working hello probe", async () => {
|
||||||
|
runProcessMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
stdout: [
|
||||||
|
"You are logged in with grok.com.",
|
||||||
|
"",
|
||||||
|
"Default model: grok-build",
|
||||||
|
"",
|
||||||
|
"Available models:",
|
||||||
|
" * grok-build (default)",
|
||||||
|
].join("\n"),
|
||||||
|
stderr: "",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
stdout: [
|
||||||
|
JSON.stringify({ type: "text", data: "hello" }),
|
||||||
|
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
|
||||||
|
].join("\n"),
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await testEnvironment({
|
||||||
|
companyId: "company-1",
|
||||||
|
adapterType: "grok_local",
|
||||||
|
config: {
|
||||||
|
command: "grok",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
model: "grok-build",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("pass");
|
||||||
|
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"grok_command_resolvable",
|
||||||
|
"grok_models_probe_passed",
|
||||||
|
"grok_model_configured",
|
||||||
|
"grok_hello_probe_passed",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(runProcessMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.any(String),
|
||||||
|
null,
|
||||||
|
"grok",
|
||||||
|
expect.arrayContaining([
|
||||||
|
"--output-format",
|
||||||
|
"streaming-json",
|
||||||
|
"--always-approve",
|
||||||
|
"--permission-mode",
|
||||||
|
"dontAsk",
|
||||||
|
"--disable-web-search",
|
||||||
|
"--single",
|
||||||
|
"Respond with exactly hello.",
|
||||||
|
]),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downgrades auth failures to warnings", async () => {
|
||||||
|
runProcessMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
exitCode: 1,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "Not logged in. Run `grok login`.",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
exitCode: 1,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "Not logged in. Run `grok login`.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await testEnvironment({
|
||||||
|
companyId: "company-1",
|
||||||
|
adapterType: "grok_local",
|
||||||
|
config: {
|
||||||
|
command: "grok",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("warn");
|
||||||
|
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"grok_auth_required",
|
||||||
|
"grok_hello_probe_auth_required",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
313
packages/adapters/grok-local/src/server/test.ts
Normal file
313
packages/adapters/grok-local/src/server/test.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
import type {
|
||||||
|
AdapterEnvironmentCheck,
|
||||||
|
AdapterEnvironmentTestContext,
|
||||||
|
AdapterEnvironmentTestResult,
|
||||||
|
} from "@paperclipai/adapter-utils";
|
||||||
|
import {
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
asStringArray,
|
||||||
|
ensurePathInEnv,
|
||||||
|
parseObject,
|
||||||
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import {
|
||||||
|
describeAdapterExecutionTarget,
|
||||||
|
ensureAdapterExecutionTargetCommandResolvable,
|
||||||
|
ensureAdapterExecutionTargetDirectory,
|
||||||
|
resolveAdapterExecutionTargetCwd,
|
||||||
|
runAdapterExecutionTargetProcess,
|
||||||
|
} from "@paperclipai/adapter-utils/execution-target";
|
||||||
|
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||||
|
import { parseGrokJsonl } from "./parse.js";
|
||||||
|
|
||||||
|
export interface GrokModelsProbe {
|
||||||
|
authenticated: boolean;
|
||||||
|
defaultModel: string | null;
|
||||||
|
models: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||||
|
if (checks.some((check) => check.level === "error")) return "fail";
|
||||||
|
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||||
|
return "pass";
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyLine(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||||
|
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||||
|
if (!raw) return null;
|
||||||
|
const clean = raw.replace(/\s+/g, " ").trim();
|
||||||
|
const max = 240;
|
||||||
|
return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEnv(input: unknown): Record<string, string> {
|
||||||
|
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||||
|
if (typeof value === "string") env[key] = value;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROK_AUTH_REQUIRED_RE =
|
||||||
|
/(?:not\s+logged\s+in|login\s+required|run\s+`?grok\s+login`?|authentication\s+required|unauthorized|invalid\s+credentials)/i;
|
||||||
|
|
||||||
|
export function parseGrokModelsOutput(stdout: string): GrokModelsProbe {
|
||||||
|
const trimmedLines = stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const models: string[] = [];
|
||||||
|
let defaultModel: string | null = null;
|
||||||
|
let authenticated = false;
|
||||||
|
let inModelsBlock = false;
|
||||||
|
|
||||||
|
for (const line of trimmedLines) {
|
||||||
|
if (/logged in/i.test(line)) authenticated = true;
|
||||||
|
const defaultMatch = /^Default model:\s*(.+)$/i.exec(line);
|
||||||
|
if (defaultMatch?.[1]) {
|
||||||
|
defaultModel = defaultMatch[1].trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^Available models:/i.test(line)) {
|
||||||
|
inModelsBlock = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inModelsBlock) continue;
|
||||||
|
const bulletMatch = /^[*-]\s*(.+?)(?:\s+\(default\))?$/.exec(line);
|
||||||
|
if (bulletMatch?.[1]) {
|
||||||
|
models.push(bulletMatch[1].trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.length > 0) {
|
||||||
|
models.push(line.replace(/\s+\(default\)$/, "").trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated,
|
||||||
|
defaultModel,
|
||||||
|
models: Array.from(new Set(models.filter(Boolean))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testEnvironment(
|
||||||
|
ctx: AdapterEnvironmentTestContext,
|
||||||
|
): Promise<AdapterEnvironmentTestResult> {
|
||||||
|
const checks: AdapterEnvironmentCheck[] = [];
|
||||||
|
const config = parseObject(ctx.config);
|
||||||
|
const command = asString(config.command, "grok");
|
||||||
|
const target = ctx.executionTarget ?? null;
|
||||||
|
const targetIsRemote = target?.kind === "remote";
|
||||||
|
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
|
||||||
|
const targetLabel = targetIsRemote
|
||||||
|
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
|
||||||
|
: null;
|
||||||
|
const runId = `grok-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
if (targetLabel) {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_environment_target",
|
||||||
|
level: "info",
|
||||||
|
message: `Probing inside environment: ${targetLabel}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||||
|
cwd,
|
||||||
|
env: {},
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
code: "grok_cwd_valid",
|
||||||
|
level: "info",
|
||||||
|
message: `Working directory is valid: ${cwd}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_cwd_invalid",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||||
|
detail: cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = normalizeEnv(config.env);
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||||
|
checks.push({
|
||||||
|
code: "grok_command_resolvable",
|
||||||
|
level: "info",
|
||||||
|
message: `Command is executable: ${command}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_command_unresolvable",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Command is not executable",
|
||||||
|
detail: command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRunProbe =
|
||||||
|
checks.every((check) => check.code !== "grok_cwd_invalid" && check.code !== "grok_command_unresolvable");
|
||||||
|
|
||||||
|
const configuredModel = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
|
||||||
|
|
||||||
|
if (canRunProbe) {
|
||||||
|
const modelsProbe = await runAdapterExecutionTargetProcess(
|
||||||
|
runId,
|
||||||
|
target,
|
||||||
|
command,
|
||||||
|
["models"],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
|
||||||
|
graceSec: 5,
|
||||||
|
onLog: async () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const probeOutput = `${modelsProbe.stdout}\n${modelsProbe.stderr}`;
|
||||||
|
const parsedModels = parseGrokModelsOutput(modelsProbe.stdout);
|
||||||
|
const authRequired = GROK_AUTH_REQUIRED_RE.test(probeOutput);
|
||||||
|
|
||||||
|
if (modelsProbe.timedOut) {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_models_probe_timed_out",
|
||||||
|
level: "warn",
|
||||||
|
message: "`grok models` timed out.",
|
||||||
|
hint: "Retry the probe. If this persists, run `grok models` manually from the target environment.",
|
||||||
|
});
|
||||||
|
} else if ((modelsProbe.exitCode ?? 1) !== 0) {
|
||||||
|
checks.push({
|
||||||
|
code: authRequired ? "grok_auth_required" : "grok_models_probe_failed",
|
||||||
|
level: authRequired ? "warn" : "error",
|
||||||
|
message: authRequired
|
||||||
|
? "Grok CLI is not authenticated."
|
||||||
|
: "`grok models` failed.",
|
||||||
|
detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null),
|
||||||
|
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_models_probe_passed",
|
||||||
|
level: "info",
|
||||||
|
message: parsedModels.authenticated
|
||||||
|
? "Grok CLI authentication is configured."
|
||||||
|
: "`grok models` completed.",
|
||||||
|
detail: parsedModels.defaultModel ? `Default model: ${parsedModels.defaultModel}` : undefined,
|
||||||
|
});
|
||||||
|
if (parsedModels.models.length > 0) {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_models_discovered",
|
||||||
|
level: "info",
|
||||||
|
message: `Discovered ${parsedModels.models.length} Grok model(s).`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_models_empty",
|
||||||
|
level: "warn",
|
||||||
|
message: "Grok returned no available models.",
|
||||||
|
hint: "Run `grok models` manually and verify the account has access to a model.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (configuredModel) {
|
||||||
|
checks.push({
|
||||||
|
code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found",
|
||||||
|
level: parsedModels.models.includes(configuredModel) ? "info" : "warn",
|
||||||
|
message: parsedModels.models.includes(configuredModel)
|
||||||
|
? `Configured model: ${configuredModel}`
|
||||||
|
: `Configured model "${configuredModel}" not found in available models.`,
|
||||||
|
hint: parsedModels.models.includes(configuredModel)
|
||||||
|
? undefined
|
||||||
|
: "Run `grok models` and choose an available model id.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canRunProbe) {
|
||||||
|
const probeArgs = [
|
||||||
|
"--output-format",
|
||||||
|
"streaming-json",
|
||||||
|
"--always-approve",
|
||||||
|
"--permission-mode",
|
||||||
|
"dontAsk",
|
||||||
|
"--disable-web-search",
|
||||||
|
];
|
||||||
|
if (configuredModel && configuredModel !== DEFAULT_GROK_LOCAL_MODEL) {
|
||||||
|
probeArgs.push("--model", configuredModel);
|
||||||
|
}
|
||||||
|
probeArgs.push("--single", "Respond with exactly hello.");
|
||||||
|
|
||||||
|
const helloProbe = await runAdapterExecutionTargetProcess(
|
||||||
|
runId,
|
||||||
|
target,
|
||||||
|
command,
|
||||||
|
probeArgs,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
|
||||||
|
graceSec: 5,
|
||||||
|
onLog: async () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const parsed = parseGrokJsonl(helloProbe.stdout);
|
||||||
|
const detail = summarizeProbeDetail(helloProbe.stdout, helloProbe.stderr, parsed.errorMessage);
|
||||||
|
const authRequired = GROK_AUTH_REQUIRED_RE.test(`${helloProbe.stdout}\n${helloProbe.stderr}`);
|
||||||
|
|
||||||
|
if (helloProbe.timedOut) {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_hello_probe_timed_out",
|
||||||
|
level: "warn",
|
||||||
|
message: "Grok hello probe timed out.",
|
||||||
|
hint: "Retry the probe. If this persists, verify Grok can run a simple `--single` prompt manually.",
|
||||||
|
});
|
||||||
|
} else if ((helloProbe.exitCode ?? 1) !== 0) {
|
||||||
|
checks.push({
|
||||||
|
code: authRequired ? "grok_hello_probe_auth_required" : "grok_hello_probe_failed",
|
||||||
|
level: authRequired ? "warn" : "error",
|
||||||
|
message: authRequired
|
||||||
|
? "Grok CLI could not answer the hello probe because authentication is missing."
|
||||||
|
: "Grok hello probe failed.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
|
||||||
|
});
|
||||||
|
} else if (/\bhello\b/i.test(parsed.summary)) {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_hello_probe_passed",
|
||||||
|
level: "info",
|
||||||
|
message: "Grok hello probe succeeded.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "grok_hello_probe_unexpected_output",
|
||||||
|
level: "warn",
|
||||||
|
message: "Grok hello probe succeeded but returned unexpected output.",
|
||||||
|
...(detail ? { detail } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType: "grok_local",
|
||||||
|
status: summarizeStatus(checks),
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
26
packages/adapters/grok-local/src/ui/build-config.test.ts
Normal file
26
packages/adapters/grok-local/src/ui/build-config.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildGrokLocalConfig } from "./build-config.js";
|
||||||
|
|
||||||
|
describe("buildGrokLocalConfig", () => {
|
||||||
|
it("maps create-form values into adapter config", () => {
|
||||||
|
expect(buildGrokLocalConfig({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
instructionsFilePath: "/tmp/AGENTS.md",
|
||||||
|
model: "grok-build",
|
||||||
|
thinkingEffort: "high",
|
||||||
|
envVars: "XAI_API_KEY=secret\n",
|
||||||
|
extraArgs: "--check, --verbatim",
|
||||||
|
} as never)).toEqual({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
instructionsFilePath: "/tmp/AGENTS.md",
|
||||||
|
model: "grok-build",
|
||||||
|
timeoutSec: 0,
|
||||||
|
graceSec: 20,
|
||||||
|
reasoningEffort: "high",
|
||||||
|
env: {
|
||||||
|
XAI_API_KEY: { type: "plain", value: "secret" },
|
||||||
|
},
|
||||||
|
extraArgs: ["--check", "--verbatim"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/adapters/grok-local/src/ui/build-config.ts
Normal file
74
packages/adapters/grok-local/src/ui/build-config.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
|
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvVars(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||||
|
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||||
|
const env: Record<string, unknown> = {};
|
||||||
|
for (const [key, raw] of Object.entries(bindings)) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
env[key] = { type: "plain", value: raw };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||||
|
const rec = raw as Record<string, unknown>;
|
||||||
|
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||||
|
env[key] = { type: "plain", value: rec.value };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||||
|
env[key] = {
|
||||||
|
type: "secret_ref",
|
||||||
|
secretId: rec.secretId,
|
||||||
|
...(typeof rec.version === "number" || rec.version === "latest"
|
||||||
|
? { version: rec.version }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGrokLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
|
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||||
|
ac.model = v.model || DEFAULT_GROK_LOCAL_MODEL;
|
||||||
|
ac.timeoutSec = 0;
|
||||||
|
ac.graceSec = 20;
|
||||||
|
if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort;
|
||||||
|
const env = parseEnvBindings(v.envBindings);
|
||||||
|
const legacy = parseEnvVars(v.envVars);
|
||||||
|
for (const [key, value] of Object.entries(legacy)) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||||
|
env[key] = { type: "plain", value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
|
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
2
packages/adapters/grok-local/src/ui/index.ts
Normal file
2
packages/adapters/grok-local/src/ui/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { parseGrokStdoutLine } from "./parse-stdout.js";
|
||||||
|
export { buildGrokLocalConfig } from "./build-config.js";
|
||||||
27
packages/adapters/grok-local/src/ui/parse-stdout.test.ts
Normal file
27
packages/adapters/grok-local/src/ui/parse-stdout.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseGrokStdoutLine } from "./parse-stdout.js";
|
||||||
|
|
||||||
|
describe("parseGrokStdoutLine", () => {
|
||||||
|
const ts = "2026-05-15T00:00:00.000Z";
|
||||||
|
|
||||||
|
it("maps thought/text/end events into transcript entries", () => {
|
||||||
|
expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([
|
||||||
|
{ kind: "thinking", ts, text: "Plan first.", delta: true },
|
||||||
|
]);
|
||||||
|
expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([
|
||||||
|
{ kind: "assistant", ts, text: "hello", delta: true },
|
||||||
|
]);
|
||||||
|
expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([
|
||||||
|
{ kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces structured Grok error payload text", () => {
|
||||||
|
expect(parseGrokStdoutLine(JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
error: { message: "Authentication required" },
|
||||||
|
}), ts)).toEqual([
|
||||||
|
{ kind: "stderr", ts, text: "Authentication required" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
packages/adapters/grok-local/src/ui/parse-stdout.ts
Normal file
61
packages/adapters/grok-local/src/ui/parse-stdout.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function safeJsonParse(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record) return "";
|
||||||
|
return asString(record.message) || asString(record.detail) || asString(record.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
|
if (!parsed) {
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = asString(parsed.type).trim();
|
||||||
|
|
||||||
|
if (type === "thought") {
|
||||||
|
const text = asString(parsed.data);
|
||||||
|
return text ? [{ kind: "thinking", ts, text, delta: true }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "text") {
|
||||||
|
const text = asString(parsed.data);
|
||||||
|
return text ? [{ kind: "assistant", ts, text, delta: true }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const text = asString(parsed.data) || asString(parsed.message) || extractErrorText(parsed.error);
|
||||||
|
return text ? [{ kind: "stderr", ts, text }] : [{ kind: "stderr", ts, text: "Grok error" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "end") {
|
||||||
|
const stopReason = asString(parsed.stopReason).trim();
|
||||||
|
const sessionId = asString(parsed.sessionId).trim();
|
||||||
|
const parts = [
|
||||||
|
stopReason ? `stop_reason=${stopReason}` : "",
|
||||||
|
sessionId ? `session=${sessionId}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
return [{ kind: "system", ts, text: parts.join(" ") || "run completed" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }];
|
||||||
|
}
|
||||||
8
packages/adapters/grok-local/tsconfig.json
Normal file
8
packages/adapters/grok-local/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,11 @@
|
||||||
"name": "@paperclipai/adapter-gemini-local",
|
"name": "@paperclipai/adapter-gemini-local",
|
||||||
"publishFromCi": true
|
"publishFromCi": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"dir": "packages/adapters/grok-local",
|
||||||
|
"name": "@paperclipai/adapter-grok-local",
|
||||||
|
"publishFromCi": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"dir": "packages/adapters/opencode-local",
|
"dir": "packages/adapters/opencode-local",
|
||||||
"name": "@paperclipai/adapter-opencode-local",
|
"name": "@paperclipai/adapter-opencode-local",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-grok-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,15 @@ describe("adapter routes", () => {
|
||||||
expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true);
|
expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true);
|
||||||
expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true);
|
expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true);
|
||||||
|
|
||||||
|
const grokAdapter = res.body.find((a: any) => a.type === "grok_local");
|
||||||
|
expect(grokAdapter).toBeDefined();
|
||||||
|
expect(grokAdapter.capabilities).toMatchObject({
|
||||||
|
supportsInstructionsBundle: true,
|
||||||
|
supportsSkills: true,
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
|
requiresMaterializedRuntimeSkills: true,
|
||||||
|
});
|
||||||
|
|
||||||
// hermes_local currently supports skills + local JWT, but not the managed
|
// hermes_local currently supports skills + local JWT, but not the managed
|
||||||
// instructions bundle flow because the bundled adapter does not consume
|
// instructions bundle flow because the bundled adapter does not consume
|
||||||
// instructionsFilePath at runtime.
|
// instructionsFilePath at runtime.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([
|
||||||
"cursor_cloud",
|
"cursor_cloud",
|
||||||
"cursor",
|
"cursor",
|
||||||
"gemini_local",
|
"gemini_local",
|
||||||
|
"grok_local",
|
||||||
"openclaw_gateway",
|
"openclaw_gateway",
|
||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,17 @@ import {
|
||||||
models as geminiModels,
|
models as geminiModels,
|
||||||
modelProfiles as geminiModelProfiles,
|
modelProfiles as geminiModelProfiles,
|
||||||
} from "@paperclipai/adapter-gemini-local";
|
} from "@paperclipai/adapter-gemini-local";
|
||||||
|
import {
|
||||||
|
execute as grokExecute,
|
||||||
|
listGrokSkills,
|
||||||
|
syncGrokSkills,
|
||||||
|
testEnvironment as grokTestEnvironment,
|
||||||
|
sessionCodec as grokSessionCodec,
|
||||||
|
} from "@paperclipai/adapter-grok-local/server";
|
||||||
|
import {
|
||||||
|
agentConfigurationDoc as grokAgentConfigurationDoc,
|
||||||
|
models as grokModels,
|
||||||
|
} from "@paperclipai/adapter-grok-local";
|
||||||
import {
|
import {
|
||||||
execute as openCodeExecute,
|
execute as openCodeExecute,
|
||||||
listOpenCodeSkills,
|
listOpenCodeSkills,
|
||||||
|
|
@ -349,6 +360,27 @@ const geminiLocalAdapter: ServerAdapterModule = {
|
||||||
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const grokLocalAdapter: ServerAdapterModule = {
|
||||||
|
type: "grok_local",
|
||||||
|
execute: grokExecute,
|
||||||
|
testEnvironment: grokTestEnvironment,
|
||||||
|
listSkills: listGrokSkills,
|
||||||
|
syncSkills: syncGrokSkills,
|
||||||
|
sessionCodec: grokSessionCodec,
|
||||||
|
sessionManagement: getAdapterSessionManagement("grok_local") ?? undefined,
|
||||||
|
models: grokModels,
|
||||||
|
supportsLocalAgentJwt: true,
|
||||||
|
supportsInstructionsBundle: true,
|
||||||
|
instructionsPathKey: "instructionsFilePath",
|
||||||
|
requiresMaterializedRuntimeSkills: true,
|
||||||
|
getRuntimeCommandSpec: (config) => ({
|
||||||
|
command: readConfiguredCommand(config, "grok"),
|
||||||
|
detectCommand: readConfiguredCommand(config, "grok"),
|
||||||
|
installCommand: null,
|
||||||
|
}),
|
||||||
|
agentConfigurationDoc: grokAgentConfigurationDoc,
|
||||||
|
};
|
||||||
|
|
||||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
execute: openclawGatewayExecute,
|
execute: openclawGatewayExecute,
|
||||||
|
|
@ -486,6 +518,7 @@ function registerBuiltInAdapters() {
|
||||||
cursorCloudAdapter,
|
cursorCloudAdapter,
|
||||||
cursorLocalAdapter,
|
cursorLocalAdapter,
|
||||||
geminiLocalAdapter,
|
geminiLocalAdapter,
|
||||||
|
grokLocalAdapter,
|
||||||
openclawGatewayAdapter,
|
openclawGatewayAdapter,
|
||||||
hermesLocalAdapter,
|
hermesLocalAdapter,
|
||||||
processAdapter,
|
processAdapter,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-grok-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,11 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
||||||
description: "Local Gemini agent",
|
description: "Local Gemini agent",
|
||||||
icon: Gem,
|
icon: Gem,
|
||||||
},
|
},
|
||||||
|
grok_local: {
|
||||||
|
label: "Grok Build",
|
||||||
|
description: "Local Grok Build agent",
|
||||||
|
icon: Bot,
|
||||||
|
},
|
||||||
opencode_local: {
|
opencode_local: {
|
||||||
label: "OpenCode",
|
label: "OpenCode",
|
||||||
description: "Local multi-provider agent",
|
description: "Local multi-provider agent",
|
||||||
|
|
|
||||||
51
ui/src/adapters/grok-local/config-fields.tsx
Normal file
51
ui/src/adapters/grok-local/config-fields.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
DraftInput,
|
||||||
|
Field,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
const instructionsFileHint =
|
||||||
|
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Paperclip stages it into the Grok workspace as Agents.md when possible.";
|
||||||
|
|
||||||
|
export function GrokLocalConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
hideInstructionsFile,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
if (hideInstructionsFile) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.instructionsFilePath ?? ""
|
||||||
|
: eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"instructionsFilePath",
|
||||||
|
String(config.instructionsFilePath ?? ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ instructionsFilePath: v })
|
||||||
|
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="/absolute/path/to/AGENTS.md"
|
||||||
|
/>
|
||||||
|
<ChoosePathButton />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/grok-local/index.ts
Normal file
12
ui/src/adapters/grok-local/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseGrokStdoutLine } from "@paperclipai/adapter-grok-local/ui";
|
||||||
|
import { buildGrokLocalConfig } from "@paperclipai/adapter-grok-local/ui";
|
||||||
|
import { GrokLocalConfigFields } from "./config-fields";
|
||||||
|
|
||||||
|
export const grokLocalUIAdapter: UIAdapterModule = {
|
||||||
|
type: "grok_local",
|
||||||
|
label: "Grok Build (local)",
|
||||||
|
parseStdoutLine: parseGrokStdoutLine,
|
||||||
|
ConfigFields: GrokLocalConfigFields,
|
||||||
|
buildAdapterConfig: buildGrokLocalConfig,
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ import { codexLocalUIAdapter } from "./codex-local";
|
||||||
import { cursorCloudUIAdapter } from "./cursor-cloud";
|
import { cursorCloudUIAdapter } from "./cursor-cloud";
|
||||||
import { cursorLocalUIAdapter } from "./cursor";
|
import { cursorLocalUIAdapter } from "./cursor";
|
||||||
import { geminiLocalUIAdapter } from "./gemini-local";
|
import { geminiLocalUIAdapter } from "./gemini-local";
|
||||||
|
import { grokLocalUIAdapter } from "./grok-local";
|
||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { piLocalUIAdapter } from "./pi-local";
|
import { piLocalUIAdapter } from "./pi-local";
|
||||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||||
|
|
@ -56,6 +57,7 @@ function registerBuiltInUIAdapters() {
|
||||||
codexLocalUIAdapter,
|
codexLocalUIAdapter,
|
||||||
cursorCloudUIAdapter,
|
cursorCloudUIAdapter,
|
||||||
geminiLocalUIAdapter,
|
geminiLocalUIAdapter,
|
||||||
|
grokLocalUIAdapter,
|
||||||
hermesLocalUIAdapter,
|
hermesLocalUIAdapter,
|
||||||
openCodeLocalUIAdapter,
|
openCodeLocalUIAdapter,
|
||||||
piLocalUIAdapter,
|
piLocalUIAdapter,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildTranscript, type RunLogChunk } from "./transcript";
|
import { buildTranscript, type RunLogChunk } from "./transcript";
|
||||||
|
import { grokLocalUIAdapter } from "./grok-local";
|
||||||
import type { UIAdapterModule } from "./types";
|
import type { UIAdapterModule } from "./types";
|
||||||
|
|
||||||
describe("buildTranscript", () => {
|
describe("buildTranscript", () => {
|
||||||
|
|
@ -182,4 +183,20 @@ describe("buildTranscript", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coalesces grok_local streaming text fragments into one assistant entry", () => {
|
||||||
|
const entries = buildTranscript(
|
||||||
|
[
|
||||||
|
{ ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "Hello " })}\n` },
|
||||||
|
{ ts, stream: "stdout", chunk: `${JSON.stringify({ type: "text", data: "world" })}\n` },
|
||||||
|
{ ts, stream: "stdout", chunk: `${JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" })}\n` },
|
||||||
|
],
|
||||||
|
grokLocalUIAdapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entries).toEqual([
|
||||||
|
{ kind: "assistant", ts, text: "Hello world", delta: true },
|
||||||
|
{ kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
|
||||||
codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true },
|
codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true },
|
||||||
cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
|
cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
|
||||||
gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
|
gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
|
||||||
|
grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false },
|
||||||
opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
|
opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
|
||||||
pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false },
|
pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false },
|
||||||
hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false },
|
hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false },
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export default defineConfig({
|
||||||
"packages/adapters/cursor-cloud",
|
"packages/adapters/cursor-cloud",
|
||||||
"packages/adapters/cursor-local",
|
"packages/adapters/cursor-local",
|
||||||
"packages/adapters/gemini-local",
|
"packages/adapters/gemini-local",
|
||||||
|
"packages/adapters/grok-local",
|
||||||
"packages/adapters/opencode-local",
|
"packages/adapters/opencode-local",
|
||||||
"packages/adapters/pi-local",
|
"packages/adapters/pi-local",
|
||||||
"packages/plugins/sdk",
|
"packages/plugins/sdk",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue