mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
feat: add /upload-trace command to upload session trace to Hugging Face
Adds a runner-handled /upload-trace slash command (admin-gated, like /clear)
that uploads the current session's Claude Code transcript to the user's own
private {hf_user}/nanoclaw-traces dataset, browsable in the HF Agent Trace
Viewer. The transcript is already in the format the viewer auto-detects, so
the command just locates the newest one and pushes it via the Hub commit API.
Auth is handled by the OneCLI gateway: curl goes out through the injected
HTTPS_PROXY, which adds the user's HF token — no credential ever touches
agent code. A missing/unassigned token yields a clear setup message.
- container/agent-runner/src/upload-trace.ts: isUploadTraceCommand() + uploadTrace()
- poll-loop.ts: recognize and handle /upload-trace in the runner
- command-gate.ts: admin-gate /upload-trace on the host
- upload-trace.test.ts: unit + integration coverage for the command
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import { TIMEZONE, formatLocalTime } from './timezone.js';
|
||||
*/
|
||||
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
|
||||
|
||||
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
|
||||
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']);
|
||||
|
||||
export interface CommandInfo {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
stripInternalTags,
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
@@ -161,6 +162,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isUploadTraceCommand(msg)) {
|
||||
log('Uploading session trace to Hugging Face');
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: uploadTrace() }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
normalMessages.push(msg);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
|
||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import type { MessageInRow } from './db/messages-in.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import { runPollLoop } from './poll-loop.js';
|
||||
import { isUploadTraceCommand } from './upload-trace.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('isUploadTraceCommand', () => {
|
||||
const make = (text: unknown) => ({ content: JSON.stringify({ text }) }) as MessageInRow;
|
||||
|
||||
it('matches /upload-trace (case-insensitive, with args)', () => {
|
||||
expect(isUploadTraceCommand(make('/upload-trace'))).toBe(true);
|
||||
expect(isUploadTraceCommand(make('/UPLOAD-TRACE'))).toBe(true);
|
||||
expect(isUploadTraceCommand(make(' /upload-trace now '))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match other text or commands', () => {
|
||||
expect(isUploadTraceCommand(make('hello'))).toBe(false);
|
||||
expect(isUploadTraceCommand(make('/upload'))).toBe(false);
|
||||
expect(isUploadTraceCommand(make('/clear'))).toBe(false);
|
||||
expect(isUploadTraceCommand({ content: 'not json' } as MessageInRow)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — /upload-trace command', () => {
|
||||
it('handles the command in the runner, writes a status, skips query', async () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES ('m-upload-trace', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||
)
|
||||
.run(JSON.stringify({ text: '/upload-trace' }));
|
||||
|
||||
// If the provider were ever queried it would emit this — asserting its
|
||||
// absence proves the runner intercepted /upload-trace instead of the LLM.
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 5000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 5000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
// A status line from uploadTrace() — never the provider's reply.
|
||||
const text = JSON.parse(out[0].content).text as string;
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(text).not.toBe('should not run');
|
||||
|
||||
// Command message was completed (not left pending).
|
||||
expect(getPendingMessages()).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
||||
return Promise.race([
|
||||
runPollLoop({ provider, providerName: 'mock', cwd: '/tmp' }),
|
||||
new Promise<void>((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}),
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MessageInRow } from './db/messages-in.js';
|
||||
|
||||
/**
|
||||
* `/upload-trace` command: upload this session's Claude Code transcript to the user's
|
||||
* own private `{hf_user}/nanoclaw-traces` dataset, browsable in the HF Agent
|
||||
* Trace Viewer. The transcript the Claude provider keeps under
|
||||
* `~/.claude/projects/<dir>/<sessionId>.jsonl` is already in the format the
|
||||
* viewer auto-detects, so this just locates the newest one and pushes it.
|
||||
*
|
||||
* Auth is the OneCLI gateway's job: curl goes out through the injected
|
||||
* HTTPS_PROXY, which adds the user's HF token. We never see the raw token, and
|
||||
* a 401 from `whoami` is our "not signed in" signal.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Narrow check for /upload-trace — the runner handles this command directly
|
||||
* (no LLM turn). Admin-gated by the host router before it reaches the container.
|
||||
*/
|
||||
export function isUploadTraceCommand(msg: MessageInRow): boolean {
|
||||
let text = '';
|
||||
try {
|
||||
text = (JSON.parse(msg.content)?.text ?? '').trim();
|
||||
} catch {
|
||||
return false; // non-JSON content is never a command
|
||||
}
|
||||
return text.toLowerCase().startsWith('/upload-trace');
|
||||
}
|
||||
|
||||
/** Newest Claude Code transcript jsonl (the current session). */
|
||||
function newestTranscript(): string | null {
|
||||
const projects = path.join(os.homedir(), '.claude', 'projects');
|
||||
let best: { p: string; m: number } | null = null;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(projects);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(path.join(projects, dir));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.jsonl')) continue;
|
||||
const p = path.join(projects, dir, f);
|
||||
const m = fs.statSync(p).mtimeMs;
|
||||
if (!best || m > best.m) best = { p, m };
|
||||
}
|
||||
}
|
||||
return best?.p ?? null;
|
||||
}
|
||||
|
||||
function curl(args: string[], input?: string): { ok: boolean; out: string } {
|
||||
const r = spawnSync('curl', args, { input, encoding: 'utf-8' });
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') };
|
||||
}
|
||||
|
||||
/** Returns a user-facing status line. Never throws. */
|
||||
export function uploadTrace(): string {
|
||||
const file = newestTranscript();
|
||||
if (!file) return 'No transcript to upload for this session yet.';
|
||||
|
||||
const who = curl(['-sf', 'https://huggingface.co/api/whoami-v2']);
|
||||
if (!who.ok) {
|
||||
return [
|
||||
"Can't upload — no Hugging Face token is available to this agent. To set it up:",
|
||||
'',
|
||||
'1. Create a token with WRITE access at https://huggingface.co/settings/tokens',
|
||||
' (New token → type "Write" → copy it).',
|
||||
'',
|
||||
'2. Add it to the OneCLI vault. Open the dashboard — remotely at https://app.onecli.sh/',
|
||||
' or on the host at http://127.0.0.1:10254 — then Secrets → New secret,',
|
||||
' paste the token, and set the host pattern to huggingface.co',
|
||||
'',
|
||||
'3. Assign it to this agent — new agents start with no secrets attached.',
|
||||
' In the same dashboard, open this agent and set its secret mode to "all"; or from the host run:',
|
||||
' onecli agents list # find this agent\'s id',
|
||||
' onecli agents set-secret-mode --id <agent-id> --mode all',
|
||||
'',
|
||||
'Then run /upload-trace again — no restart needed.',
|
||||
].join('\n');
|
||||
}
|
||||
let user: string | undefined;
|
||||
try {
|
||||
user = JSON.parse(who.out)?.name;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (!user) return 'Could not resolve your Hugging Face username.';
|
||||
|
||||
const repo = `${user}/nanoclaw-traces`;
|
||||
// Idempotent create — ignore failure (already exists / no-op). The
|
||||
// Content-Type header is required: without it curl sends form-encoding and
|
||||
// the Hub rejects the body with 400 (expected string at "name").
|
||||
curl([
|
||||
'-sf',
|
||||
'-X',
|
||||
'POST',
|
||||
'https://huggingface.co/api/repos/create',
|
||||
'-H',
|
||||
'Content-Type: application/json',
|
||||
'-d',
|
||||
JSON.stringify({ type: 'dataset', name: 'nanoclaw-traces', private: true }),
|
||||
]);
|
||||
|
||||
const content = fs.readFileSync(file).toString('base64');
|
||||
const repoPath = `sessions/${path.basename(file)}`;
|
||||
const ndjson =
|
||||
JSON.stringify({ key: 'header', value: { summary: 'add session trace' } }) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
key: 'file',
|
||||
value: { path: repoPath, encoding: 'base64', content },
|
||||
}) +
|
||||
'\n';
|
||||
|
||||
const commit = curl(
|
||||
[
|
||||
'-sf',
|
||||
'-X',
|
||||
'POST',
|
||||
`https://huggingface.co/api/datasets/${repo}/commit/main`,
|
||||
'-H',
|
||||
'Content-Type: application/x-ndjson',
|
||||
'--data-binary',
|
||||
'@-',
|
||||
],
|
||||
ndjson,
|
||||
);
|
||||
if (!commit.ok) {
|
||||
return 'Upload to Hugging Face failed (the transcript may be too large for an inline commit).';
|
||||
}
|
||||
return `Uploaded → https://huggingface.co/datasets/${repo}/blob/main/${repoPath}`;
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { getDb, hasTable } from './db/connection.js';
|
||||
export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string };
|
||||
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
|
||||
|
||||
/**
|
||||
* Classify a message and decide whether it should reach the container.
|
||||
|
||||
Reference in New Issue
Block a user