clearclaw 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -7
- package/dist/channel/slack.d.ts +18 -1
- package/dist/channel/slack.js +227 -27
- package/dist/channel/telegram.d.ts +20 -1
- package/dist/channel/telegram.js +146 -33
- package/dist/config.d.ts +37 -1
- package/dist/config.js +118 -0
- package/dist/engine/acp.d.ts +9 -4
- package/dist/engine/acp.js +301 -29
- package/dist/engine/claude-code.d.ts +19 -2
- package/dist/engine/claude-code.js +121 -33
- package/dist/engine/mcp-http.d.ts +9 -0
- package/dist/engine/mcp-http.js +59 -0
- package/dist/engine/registry.d.ts +10 -7
- package/dist/engine/registry.js +99 -16
- package/dist/engine/session-transcript.d.ts +14 -0
- package/dist/engine/session-transcript.js +38 -0
- package/dist/format.d.ts +1 -1
- package/dist/format.js +42 -7
- package/dist/index.js +18 -23
- package/dist/orchestrator.d.ts +38 -4
- package/dist/orchestrator.js +854 -167
- package/dist/plan-usage.d.ts +6 -0
- package/dist/plan-usage.js +31 -0
- package/dist/prompt.d.ts +6 -2
- package/dist/prompt.js +75 -21
- package/dist/scheduler.d.ts +15 -0
- package/dist/scheduler.js +82 -0
- package/dist/transcribe.d.ts +13 -0
- package/dist/transcribe.js +157 -0
- package/dist/types.d.ts +77 -5
- package/package.json +11 -4
- package/patches/README.md +13 -0
- package/patches/codex-acp-1.11.0.patch +38 -0
- package/prompts/SYSTEM.md +66 -16
- package/scripts/patch-codex-acp.mjs +32 -0
- package/prompts/ONBOARDING.md +0 -56
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { EngineEvent, PlanUsageState } from "./types.js";
|
|
2
|
+
/** Latest normalized readings shared by workspaces on the same engine/account. */
|
|
3
|
+
export declare function recordPlanUsage(state: PlanUsageState, event: Extract<EngineEvent, {
|
|
4
|
+
type: "plan_usage";
|
|
5
|
+
}>): void;
|
|
6
|
+
export declare function formatPlanUsage(state?: PlanUsageState, maxLength?: number): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Latest normalized readings shared by workspaces on the same engine/account. */
|
|
2
|
+
export function recordPlanUsage(state, event) {
|
|
3
|
+
for (const window of event.windows)
|
|
4
|
+
state.windows.set(window.id, window);
|
|
5
|
+
if (event.isUsingOverage !== undefined)
|
|
6
|
+
state.isUsingOverage = event.isUsingOverage;
|
|
7
|
+
}
|
|
8
|
+
export function formatPlanUsage(state, maxLength = 4096) {
|
|
9
|
+
// A window with nothing to report is omitted: "5h ?" tells the reader less than
|
|
10
|
+
// its absence does, and the seeded windows start out unreported.
|
|
11
|
+
const parts = [];
|
|
12
|
+
for (const window of state?.windows.values() ?? []) {
|
|
13
|
+
if (window.usedPercent !== undefined)
|
|
14
|
+
parts.push(`${window.label} ${Math.round(window.usedPercent)}%`);
|
|
15
|
+
else if (window.limited)
|
|
16
|
+
parts.push(`${window.label} limited`);
|
|
17
|
+
}
|
|
18
|
+
if (state?.isUsingOverage)
|
|
19
|
+
parts.push("overage");
|
|
20
|
+
const kept = [];
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
const remaining = parts.length - kept.length - 1;
|
|
23
|
+
const candidate = [...kept, part].join(", ") + (remaining ? ` +${remaining} more` : "");
|
|
24
|
+
if (candidate.length > maxLength)
|
|
25
|
+
break;
|
|
26
|
+
kept.push(part);
|
|
27
|
+
}
|
|
28
|
+
const omitted = parts.length - kept.length;
|
|
29
|
+
return kept.join(", ") + (omitted ? `${kept.length ? " " : ""}+${omitted} more` : "");
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=plan-usage.js.map
|
package/dist/prompt.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Assemble the system prompt from framework and user instruction files.
|
|
3
3
|
* Framework content comes first, user content appended after.
|
|
4
|
-
*
|
|
4
|
+
* `prompt` is undefined if no content was found in either directory;
|
|
5
|
+
* `skipped` names files that existed but could not be read.
|
|
5
6
|
*/
|
|
6
|
-
export declare function assemblePrompt(frameworkDir: string, instructionsDir: string):
|
|
7
|
+
export declare function assemblePrompt(frameworkDir: string, instructionsDir: string): Promise<{
|
|
8
|
+
prompt: string | undefined;
|
|
9
|
+
skipped: string[];
|
|
10
|
+
}>;
|
package/dist/prompt.js
CHANGED
|
@@ -1,35 +1,89 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import log from "./logger.js";
|
|
4
|
+
// A cloud-synced instructions dir (OneDrive/iCloud "dataless" placeholder) can
|
|
5
|
+
// block a read indefinitely. Reading it synchronously froze the whole relay.
|
|
6
|
+
const READ_TIMEOUT_MS = 5000;
|
|
7
|
+
/** Settle with `op`, or with null once READ_TIMEOUT_MS passes first. */
|
|
8
|
+
async function withReadTimeout(op) {
|
|
9
|
+
// ponytail: Promise.race, not AbortSignal — an abort can't interrupt a read
|
|
10
|
+
// already blocked in libuv's threadpool, which is exactly what a
|
|
11
|
+
// cloud-placeholder file does.
|
|
12
|
+
let timer;
|
|
13
|
+
const timeout = new Promise((resolve) => {
|
|
14
|
+
timer = setTimeout(() => resolve(null), READ_TIMEOUT_MS);
|
|
15
|
+
});
|
|
16
|
+
try {
|
|
17
|
+
return await Promise.race([op, timeout]);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Read a file, giving up after READ_TIMEOUT_MS. Returns null on timeout or error. */
|
|
24
|
+
async function readFileOrSkip(file) {
|
|
25
|
+
try {
|
|
26
|
+
const content = await withReadTimeout(fsp.readFile(file, "utf-8"));
|
|
27
|
+
if (content === null) {
|
|
28
|
+
log.warn("[prompt] timed out reading %s after %dms — skipping", file, READ_TIMEOUT_MS);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return content.trim();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
log.warn({ err }, "[prompt] could not read %s — skipping", file);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
3
38
|
/**
|
|
4
39
|
* Read all .md files from a directory, sorted alphabetically.
|
|
5
|
-
*
|
|
40
|
+
* Unreadable files are reported by name instead of silently dropped.
|
|
6
41
|
*/
|
|
7
|
-
function readMarkdownFiles(dir) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
.
|
|
13
|
-
|
|
14
|
-
|
|
42
|
+
async function readMarkdownFiles(dir) {
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
// Listing a cloud-synced dir can block just like reading one (seen with
|
|
46
|
+
// Google Drive under a launchd daemon before macOS granted access).
|
|
47
|
+
entries = await withReadTimeout(fsp.readdir(dir));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "ENOENT")
|
|
51
|
+
return { contents: [], skipped: [] };
|
|
52
|
+
// Cloud-synced dirs (Google Drive File Provider) can transiently refuse a listing with EPERM.
|
|
53
|
+
log.warn({ err }, "[prompt] could not list %s — skipping", dir);
|
|
54
|
+
return { contents: [], skipped: [dir] };
|
|
55
|
+
}
|
|
56
|
+
if (entries === null) {
|
|
57
|
+
log.warn("[prompt] timed out listing %s after %dms — skipping", dir, READ_TIMEOUT_MS);
|
|
58
|
+
return { contents: [], skipped: [dir] };
|
|
59
|
+
}
|
|
60
|
+
const names = entries.filter((f) => f.endsWith(".md")).sort();
|
|
61
|
+
const results = await Promise.all(names.map(async (f) => ({ name: f, text: await readFileOrSkip(path.join(dir, f)) })));
|
|
62
|
+
return {
|
|
63
|
+
contents: results.map((r) => r.text).filter((text) => Boolean(text)),
|
|
64
|
+
skipped: results.filter((r) => r.text === null).map((r) => r.name),
|
|
65
|
+
};
|
|
15
66
|
}
|
|
16
67
|
/**
|
|
17
68
|
* Assemble the system prompt from framework and user instruction files.
|
|
18
69
|
* Framework content comes first, user content appended after.
|
|
19
|
-
*
|
|
70
|
+
* `prompt` is undefined if no content was found in either directory;
|
|
71
|
+
* `skipped` names files that existed but could not be read.
|
|
20
72
|
*/
|
|
21
|
-
export function assemblePrompt(frameworkDir, instructionsDir) {
|
|
22
|
-
const framework = readMarkdownFiles(frameworkDir);
|
|
23
|
-
const user = readMarkdownFiles(instructionsDir);
|
|
24
|
-
|
|
25
|
-
|
|
73
|
+
export async function assemblePrompt(frameworkDir, instructionsDir) {
|
|
74
|
+
const framework = await readMarkdownFiles(frameworkDir);
|
|
75
|
+
const user = await readMarkdownFiles(instructionsDir);
|
|
76
|
+
const skipped = [...framework.skipped, ...user.skipped];
|
|
77
|
+
if (framework.contents.length === 0 && user.contents.length === 0) {
|
|
78
|
+
return { prompt: undefined, skipped };
|
|
79
|
+
}
|
|
26
80
|
const parts = [];
|
|
27
|
-
if (framework.length > 0) {
|
|
28
|
-
parts.push(framework.join("\n\n---\n\n"));
|
|
81
|
+
if (framework.contents.length > 0) {
|
|
82
|
+
parts.push(framework.contents.join("\n\n---\n\n"));
|
|
29
83
|
}
|
|
30
|
-
if (user.length > 0) {
|
|
31
|
-
parts.push(user.join("\n\n---\n\n"));
|
|
84
|
+
if (user.contents.length > 0) {
|
|
85
|
+
parts.push(user.contents.join("\n\n---\n\n"));
|
|
32
86
|
}
|
|
33
|
-
return parts.join("\n\n");
|
|
87
|
+
return { prompt: parts.join("\n\n"), skipped };
|
|
34
88
|
}
|
|
35
89
|
//# sourceMappingURL=prompt.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Config, ScheduleEntry } from "./config.js";
|
|
2
|
+
import type { InboundMessage } from "./types.js";
|
|
3
|
+
export declare class Scheduler {
|
|
4
|
+
private config;
|
|
5
|
+
private inject;
|
|
6
|
+
private jobs;
|
|
7
|
+
constructor(config: Config, inject: (msg: Pick<InboundMessage, "origin" | "text">) => void);
|
|
8
|
+
start(): void;
|
|
9
|
+
stop(): void;
|
|
10
|
+
add(entry: ScheduleEntry): void;
|
|
11
|
+
remove(id: string): void;
|
|
12
|
+
toggle(id: string, enabled: boolean): void;
|
|
13
|
+
list(): ScheduleEntry[];
|
|
14
|
+
private createJob;
|
|
15
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import log from "./logger.js";
|
|
3
|
+
export class Scheduler {
|
|
4
|
+
config;
|
|
5
|
+
inject;
|
|
6
|
+
jobs = new Map();
|
|
7
|
+
constructor(config, inject) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.inject = inject;
|
|
10
|
+
}
|
|
11
|
+
start() {
|
|
12
|
+
const schedules = this.config.listSchedules();
|
|
13
|
+
let started = 0;
|
|
14
|
+
for (const entry of schedules) {
|
|
15
|
+
if (entry.enabled) {
|
|
16
|
+
this.createJob(entry);
|
|
17
|
+
started++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (started > 0) {
|
|
21
|
+
log.info("[scheduler] started %d schedule(s)", started);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
stop() {
|
|
25
|
+
for (const [id, job] of this.jobs) {
|
|
26
|
+
job.stop();
|
|
27
|
+
log.info("[scheduler] stopped job %s", id);
|
|
28
|
+
}
|
|
29
|
+
this.jobs.clear();
|
|
30
|
+
}
|
|
31
|
+
add(entry) {
|
|
32
|
+
this.config.addSchedule(entry);
|
|
33
|
+
if (entry.enabled) {
|
|
34
|
+
this.createJob(entry);
|
|
35
|
+
}
|
|
36
|
+
log.info("[scheduler] added %s: %s → %s", entry.id, entry.cron, entry.prompt.slice(0, 60));
|
|
37
|
+
}
|
|
38
|
+
remove(id) {
|
|
39
|
+
const job = this.jobs.get(id);
|
|
40
|
+
if (job) {
|
|
41
|
+
job.stop();
|
|
42
|
+
this.jobs.delete(id);
|
|
43
|
+
}
|
|
44
|
+
this.config.removeSchedule(id);
|
|
45
|
+
log.info("[scheduler] removed %s", id);
|
|
46
|
+
}
|
|
47
|
+
toggle(id, enabled) {
|
|
48
|
+
this.config.updateSchedule(id, { enabled });
|
|
49
|
+
if (enabled) {
|
|
50
|
+
const entry = this.config.listSchedules().find((s) => s.id === id);
|
|
51
|
+
if (entry && !this.jobs.has(id)) {
|
|
52
|
+
this.createJob(entry);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const job = this.jobs.get(id);
|
|
57
|
+
if (job) {
|
|
58
|
+
job.stop();
|
|
59
|
+
this.jobs.delete(id);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
log.info("[scheduler] %s → %s", id, enabled ? "enabled" : "disabled");
|
|
63
|
+
}
|
|
64
|
+
list() {
|
|
65
|
+
return this.config.listSchedules();
|
|
66
|
+
}
|
|
67
|
+
createJob(entry) {
|
|
68
|
+
const opts = {};
|
|
69
|
+
if (entry.timezone)
|
|
70
|
+
opts.timezone = entry.timezone;
|
|
71
|
+
const job = new Cron(entry.cron, opts, () => {
|
|
72
|
+
log.info("[scheduler] firing %s: %s", entry.id, entry.prompt.slice(0, 60));
|
|
73
|
+
this.inject({ origin: { kind: "scheduler", scheduleId: entry.id }, text: entry.prompt });
|
|
74
|
+
if (!job.nextRun()) {
|
|
75
|
+
this.remove(entry.id);
|
|
76
|
+
log.info("[scheduler] one-shot %s auto-deleted", entry.id);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
this.jobs.set(entry.id, job);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Attachment } from "./types.js";
|
|
2
|
+
export declare function isAudio(att: Attachment): boolean;
|
|
3
|
+
/** Interim/Text frames replace the current segment; Endpoint finalizes it. */
|
|
4
|
+
export declare function reduceTranscriptFrames(frames: Array<{
|
|
5
|
+
type: string;
|
|
6
|
+
data?: string;
|
|
7
|
+
}>): string;
|
|
8
|
+
/** The service's last word on a stream is an Endpoint; anything else means text may still be coming. */
|
|
9
|
+
export declare function isComplete(frames: Array<{
|
|
10
|
+
type: string;
|
|
11
|
+
}>): boolean;
|
|
12
|
+
/** Speech-to-text via the stream Claude Code's /voice uses, authenticated with the Claude.ai OAuth token. */
|
|
13
|
+
export declare function transcribeAudio(att: Attachment): Promise<string>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const RATE = 16000;
|
|
6
|
+
const FRAME_BYTES = RATE * 2 / 10; // 100 ms of s16le mono
|
|
7
|
+
const STREAM_URL = "wss://api.anthropic.com/api/ws/speech_to_text/voice_stream?" + new URLSearchParams({
|
|
8
|
+
encoding: "linear16", sample_rate: String(RATE), channels: "1", endpointing_ms: "300", utterance_end_ms: "1000",
|
|
9
|
+
language: "en", use_conversation_engine: "true", stt_provider: "deepgram-nova3",
|
|
10
|
+
});
|
|
11
|
+
export function isAudio(att) {
|
|
12
|
+
return att.mimeType.startsWith("audio/");
|
|
13
|
+
}
|
|
14
|
+
/** Interim/Text frames replace the current segment; Endpoint finalizes it. */
|
|
15
|
+
export function reduceTranscriptFrames(frames) {
|
|
16
|
+
const finals = [];
|
|
17
|
+
let current = "";
|
|
18
|
+
for (const f of frames) {
|
|
19
|
+
if (f.type === "TranscriptInterim" || f.type === "TranscriptText")
|
|
20
|
+
current = f.data ?? "";
|
|
21
|
+
else if (f.type === "TranscriptEndpoint") {
|
|
22
|
+
if (current)
|
|
23
|
+
finals.push(current);
|
|
24
|
+
current = "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...finals, current].filter(Boolean).join(" ").trim();
|
|
28
|
+
}
|
|
29
|
+
/** The service's last word on a stream is an Endpoint; anything else means text may still be coming. */
|
|
30
|
+
export function isComplete(frames) {
|
|
31
|
+
return frames.at(-1)?.type === "TranscriptEndpoint";
|
|
32
|
+
}
|
|
33
|
+
/** Speech-to-text via the stream Claude Code's /voice uses, authenticated with the Claude.ai OAuth token. */
|
|
34
|
+
export async function transcribeAudio(att) {
|
|
35
|
+
const token = readClaudeToken();
|
|
36
|
+
if (!token)
|
|
37
|
+
throw new Error("no Claude.ai credentials");
|
|
38
|
+
const pcm = await decodeToPcm16k(att.buffer);
|
|
39
|
+
const text = await voiceStream(pcm, token);
|
|
40
|
+
if (!text)
|
|
41
|
+
throw new Error("empty transcript");
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
function readClaudeToken() {
|
|
45
|
+
try {
|
|
46
|
+
const raw = process.platform === "darwin"
|
|
47
|
+
? execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
|
|
48
|
+
: fs.readFileSync(path.join(os.homedir(), ".claude", ".credentials.json"), "utf8");
|
|
49
|
+
const oauth = JSON.parse(raw).claudeAiOauth;
|
|
50
|
+
if (!oauth?.accessToken || !(oauth.expiresAt > Date.now()))
|
|
51
|
+
return null;
|
|
52
|
+
return oauth.accessToken;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// The service requires an explicit PCM encoding; it does not accept OGG/Opus.
|
|
59
|
+
function decodeToPcm16k(input) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const ff = spawn("ffmpeg", ["-loglevel", "error", "-i", "pipe:0", "-f", "s16le", "-ac", "1", "-ar", String(RATE), "pipe:1"], { timeout: 30_000 });
|
|
62
|
+
const out = [];
|
|
63
|
+
let stderr = "";
|
|
64
|
+
ff.stdout.on("data", (c) => out.push(c));
|
|
65
|
+
ff.stderr.on("data", (c) => (stderr += c));
|
|
66
|
+
ff.on("error", (e) => reject(new Error(`ffmpeg failed: ${e.message}`)));
|
|
67
|
+
ff.on("close", (code) => {
|
|
68
|
+
const pcm = Buffer.concat(out);
|
|
69
|
+
if (code === 0 && pcm.length > 0)
|
|
70
|
+
resolve(pcm);
|
|
71
|
+
else
|
|
72
|
+
reject(new Error(`ffmpeg failed: ${stderr.trim().split("\n").pop() || `exit ${code}`}`));
|
|
73
|
+
});
|
|
74
|
+
ff.stdin.on("error", () => { }); // ffmpeg can exit before reading all input; "close" reports why
|
|
75
|
+
ff.stdin.end(input);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function voiceStream(pcm, token) {
|
|
79
|
+
// The service trims ~0.5 s at each end; pad with silence so the first and last words survive.
|
|
80
|
+
const pad = Buffer.alloc(RATE); // 0.5 s
|
|
81
|
+
const audio = Buffer.concat([pad, pcm, pad]);
|
|
82
|
+
const durationMs = (pcm.length / 2 / RATE) * 1000;
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const ws = new WebSocket(STREAM_URL, { headers: {
|
|
85
|
+
Authorization: `Bearer ${token}`, "User-Agent": "claude-cli/2.1.284 (external, cli)",
|
|
86
|
+
"x-app": "cli", "anthropic-client-platform": "claude_code_cli",
|
|
87
|
+
} }); // Node (undici) accepts { headers }; the DOM typings do not
|
|
88
|
+
ws.binaryType = "arraybuffer";
|
|
89
|
+
const frames = [];
|
|
90
|
+
let closing = false;
|
|
91
|
+
let done = false;
|
|
92
|
+
let keepalive;
|
|
93
|
+
let idle;
|
|
94
|
+
let safety;
|
|
95
|
+
const finish = (err) => {
|
|
96
|
+
if (done)
|
|
97
|
+
return;
|
|
98
|
+
done = true;
|
|
99
|
+
clearInterval(keepalive);
|
|
100
|
+
clearTimeout(idle);
|
|
101
|
+
clearTimeout(safety);
|
|
102
|
+
try {
|
|
103
|
+
ws.close();
|
|
104
|
+
}
|
|
105
|
+
catch { /* already closed */ }
|
|
106
|
+
if (err)
|
|
107
|
+
reject(err);
|
|
108
|
+
else
|
|
109
|
+
resolve(reduceTranscriptFrames(frames));
|
|
110
|
+
};
|
|
111
|
+
// Finish on idle rather than on the first Endpoint after CloseStream: with unpaced
|
|
112
|
+
// sending, that endpoint can arrive before the tail of the audio is transcribed.
|
|
113
|
+
// A partial transcript is never a success: an incomplete stream waits for the safety timeout.
|
|
114
|
+
const bump = () => {
|
|
115
|
+
if (!closing)
|
|
116
|
+
return;
|
|
117
|
+
clearTimeout(idle);
|
|
118
|
+
idle = setTimeout(() => { if (isComplete(frames))
|
|
119
|
+
finish(); }, 1500);
|
|
120
|
+
};
|
|
121
|
+
safety = setTimeout(() => finish(new Error("timed out")), 15_000 + durationMs);
|
|
122
|
+
ws.onopen = () => {
|
|
123
|
+
const keepAlive = () => ws.send(JSON.stringify({ type: "KeepAlive" }));
|
|
124
|
+
keepAlive();
|
|
125
|
+
keepalive = setInterval(keepAlive, 8000);
|
|
126
|
+
for (let off = 0; off < audio.length; off += FRAME_BYTES)
|
|
127
|
+
ws.send(audio.subarray(off, off + FRAME_BYTES));
|
|
128
|
+
closing = true;
|
|
129
|
+
ws.send(JSON.stringify({ type: "CloseStream" }));
|
|
130
|
+
bump();
|
|
131
|
+
};
|
|
132
|
+
ws.onmessage = (ev) => {
|
|
133
|
+
let msg;
|
|
134
|
+
try {
|
|
135
|
+
msg = JSON.parse(typeof ev.data === "string" ? ev.data : Buffer.from(ev.data).toString());
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
bump();
|
|
141
|
+
if (msg.type === "TranscriptError" || msg.type === "error") {
|
|
142
|
+
finish(new Error(`voice stream error: ${JSON.stringify(msg.data ?? msg).slice(0, 200)}`));
|
|
143
|
+
}
|
|
144
|
+
else if (msg.type) {
|
|
145
|
+
frames.push({ type: msg.type, data: typeof msg.data === "string" ? msg.data : undefined });
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
ws.onerror = () => finish(new Error("voice stream error: connection failed"));
|
|
149
|
+
ws.onclose = (ev) => {
|
|
150
|
+
if (closing && isComplete(frames))
|
|
151
|
+
finish();
|
|
152
|
+
else
|
|
153
|
+
finish(new Error(`voice stream error: closed ${ev.code} ${ev.reason}`.trim()));
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=transcribe.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -4,9 +4,13 @@ export interface ChannelEvents {
|
|
|
4
4
|
}
|
|
5
5
|
export interface Channel {
|
|
6
6
|
name: string;
|
|
7
|
+
/** Character budget for the chat status surface, e.g. Slack channel topic. */
|
|
8
|
+
statusMaxLength?: number;
|
|
7
9
|
connect(): Promise<void>;
|
|
8
10
|
disconnect(): Promise<void>;
|
|
9
11
|
ownsId(chatId: string): boolean;
|
|
12
|
+
/** This user's own top-level DM with the bot — home's only valid chat. */
|
|
13
|
+
isRootDM(chatId: string, userId: string): boolean;
|
|
10
14
|
sendMessage(chatId: string, text: string, opts?: MessageOpts): Promise<string[]>;
|
|
11
15
|
sendInteractive(chatId: string, text: string, buttons: Button[][]): Promise<ButtonResponse>;
|
|
12
16
|
editMessage(chatId: string, handle: string, text: string, opts?: MessageOpts): Promise<void>;
|
|
@@ -17,6 +21,15 @@ export interface Channel {
|
|
|
17
21
|
setTyping(chatId: string, isTyping: boolean): Promise<void>;
|
|
18
22
|
sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
|
|
19
23
|
reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Group an existing chat under its Project, creating the grouping if it does not exist yet.
|
|
26
|
+
* Returns a note when grouping did not happen, so the caller can tell the user.
|
|
27
|
+
*/
|
|
28
|
+
groupProjectChat?(projectName: string, chatId: string): Promise<string | undefined>;
|
|
29
|
+
/** Create a peer chat. Returns an opaque chat ID; grouping is a separate step. */
|
|
30
|
+
createProjectChat?(projectName: string, anchor: string, title: string): Promise<string>;
|
|
31
|
+
/** Close a workspace's chat and remove its Project grouping, regardless of origin. */
|
|
32
|
+
closeProjectChat?(chatId: string, projectName?: string): Promise<void>;
|
|
20
33
|
on<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
|
|
21
34
|
off<K extends keyof ChannelEvents>(event: K, listener: (...args: ChannelEvents[K]) => void): this;
|
|
22
35
|
emit<K extends keyof ChannelEvents>(event: K, ...args: ChannelEvents[K]): boolean;
|
|
@@ -49,10 +62,24 @@ export interface SessionInfo {
|
|
|
49
62
|
lastModified: number;
|
|
50
63
|
gitBranch?: string;
|
|
51
64
|
}
|
|
65
|
+
/** Chronological conversation text, excluding tool payloads and internal reasoning. */
|
|
66
|
+
export interface SessionMessage {
|
|
67
|
+
role: "user" | "assistant";
|
|
68
|
+
text: string;
|
|
69
|
+
}
|
|
70
|
+
export interface SessionHistoryOpts {
|
|
71
|
+
sessionId: string;
|
|
72
|
+
cwd: string;
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
}
|
|
52
75
|
export interface Engine {
|
|
53
76
|
name: string;
|
|
77
|
+
/** Known account windows to show as unknown before the first observation. */
|
|
78
|
+
planUsageWindows?: readonly PlanUsageWindow[];
|
|
54
79
|
runTurn(opts: RunTurnOpts): AsyncIterable<EngineEvent>;
|
|
55
80
|
listSessions(cwd: string): Promise<SessionInfo[]>;
|
|
81
|
+
/** Read history without submitting a new prompt. Reject on unsupported/failed retrieval. */
|
|
82
|
+
getSessionMessages(opts: SessionHistoryOpts): Promise<SessionMessage[]>;
|
|
56
83
|
}
|
|
57
84
|
export interface RunTurnOpts {
|
|
58
85
|
sessionId: string | null;
|
|
@@ -64,6 +91,7 @@ export interface RunTurnOpts {
|
|
|
64
91
|
appendSystemPrompt?: string;
|
|
65
92
|
mcpServers?: Record<string, McpServerConfig>;
|
|
66
93
|
signal?: AbortSignal;
|
|
94
|
+
model?: string;
|
|
67
95
|
}
|
|
68
96
|
interface ToolCallBase {
|
|
69
97
|
toolName: string;
|
|
@@ -112,11 +140,24 @@ export interface PermissionResponse {
|
|
|
112
140
|
updatedInput?: Record<string, unknown>;
|
|
113
141
|
}
|
|
114
142
|
export interface TurnStats {
|
|
143
|
+
modelLabel?: string;
|
|
115
144
|
model: string | null;
|
|
116
145
|
contextUsed: number;
|
|
117
146
|
contextWindow: number;
|
|
118
147
|
toolCalls: Record<string, number>;
|
|
119
148
|
}
|
|
149
|
+
/** Account quota, distinct from a session's context/token usage. Reset times are Unix seconds. */
|
|
150
|
+
export interface PlanUsageWindow {
|
|
151
|
+
id: string;
|
|
152
|
+
label: string;
|
|
153
|
+
usedPercent?: number;
|
|
154
|
+
resetsAt?: number;
|
|
155
|
+
limited?: boolean;
|
|
156
|
+
}
|
|
157
|
+
export interface PlanUsageState {
|
|
158
|
+
windows: Map<string, PlanUsageWindow>;
|
|
159
|
+
isUsingOverage?: boolean;
|
|
160
|
+
}
|
|
120
161
|
export type EngineEvent = {
|
|
121
162
|
type: "text";
|
|
122
163
|
text: string;
|
|
@@ -131,9 +172,13 @@ export type EngineEvent = {
|
|
|
131
172
|
toolName: string;
|
|
132
173
|
output: string;
|
|
133
174
|
} | {
|
|
134
|
-
type: "
|
|
135
|
-
|
|
136
|
-
|
|
175
|
+
type: "plan_usage";
|
|
176
|
+
windows: PlanUsageWindow[];
|
|
177
|
+
isUsingOverage?: boolean;
|
|
178
|
+
} | {
|
|
179
|
+
type: "session";
|
|
180
|
+
sessionId: string;
|
|
181
|
+
model?: string;
|
|
137
182
|
} | {
|
|
138
183
|
type: "done";
|
|
139
184
|
sessionId: string;
|
|
@@ -142,13 +187,27 @@ export type EngineEvent = {
|
|
|
142
187
|
type: "error";
|
|
143
188
|
message: string;
|
|
144
189
|
};
|
|
190
|
+
export interface EngineHandoff {
|
|
191
|
+
engine: string;
|
|
192
|
+
sessionId: string;
|
|
193
|
+
cwd: string;
|
|
194
|
+
}
|
|
145
195
|
export interface Workspace {
|
|
146
196
|
name: string;
|
|
147
197
|
cwd: string;
|
|
148
|
-
chat_id: string;
|
|
198
|
+
chat_id: string | null;
|
|
149
199
|
current_session_id: string | null;
|
|
200
|
+
engine_handoff?: EngineHandoff;
|
|
150
201
|
behavior?: "assistant" | "relay";
|
|
151
202
|
engine?: string;
|
|
203
|
+
model?: string;
|
|
204
|
+
project?: string;
|
|
205
|
+
description?: string;
|
|
206
|
+
spawnedFrom?: string;
|
|
207
|
+
pending_brief?: {
|
|
208
|
+
fromWorkspace: string;
|
|
209
|
+
text: string;
|
|
210
|
+
};
|
|
152
211
|
}
|
|
153
212
|
export interface UserInfo {
|
|
154
213
|
id: string;
|
|
@@ -160,6 +219,8 @@ export interface Attachment {
|
|
|
160
219
|
buffer: Buffer;
|
|
161
220
|
mimeType: string;
|
|
162
221
|
filename?: string;
|
|
222
|
+
/** Absolute path where the orchestrator saved this file to disk. */
|
|
223
|
+
savedAs?: string;
|
|
163
224
|
}
|
|
164
225
|
/** Context from a replied-to / quoted message. */
|
|
165
226
|
export interface ReplyContext {
|
|
@@ -169,10 +230,21 @@ export interface ReplyContext {
|
|
|
169
230
|
mediaType?: string;
|
|
170
231
|
}
|
|
171
232
|
export type ChatType = "dm" | "group";
|
|
233
|
+
/** Where an inbound message came from. */
|
|
234
|
+
export type MessageOrigin = {
|
|
235
|
+
kind: "user";
|
|
236
|
+
user: UserInfo;
|
|
237
|
+
} | {
|
|
238
|
+
kind: "scheduler";
|
|
239
|
+
scheduleId: string;
|
|
240
|
+
} | {
|
|
241
|
+
kind: "peer";
|
|
242
|
+
workspaceName: string;
|
|
243
|
+
};
|
|
172
244
|
export interface InboundMessage {
|
|
173
245
|
chatId: string;
|
|
174
246
|
chatType: ChatType;
|
|
175
|
-
|
|
247
|
+
origin: MessageOrigin;
|
|
176
248
|
text: string;
|
|
177
249
|
messageId?: string;
|
|
178
250
|
replyTo?: ReplyContext;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clearclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Your coding agent, from your phone — personal assistant + project workspaces on your machine, built on the CLI's permissions, config, and memory instead of replacing them.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,26 +27,32 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"dist/**/*.js",
|
|
29
29
|
"dist/**/*.d.ts",
|
|
30
|
-
"prompts/**/*.md"
|
|
30
|
+
"prompts/**/*.md",
|
|
31
|
+
"scripts/patch-codex-acp.mjs",
|
|
32
|
+
"patches/**"
|
|
31
33
|
],
|
|
32
34
|
"scripts": {
|
|
33
35
|
"start": "tsx src/index.ts daemon",
|
|
34
36
|
"dev": "tsx --watch src/index.ts daemon",
|
|
35
37
|
"dev:relay": "nodemon --watch dist --exec 'node dist/index.js daemon'",
|
|
36
38
|
"build": "tsc",
|
|
37
|
-
"check": "tsc --noEmit"
|
|
39
|
+
"check": "tsc --noEmit",
|
|
40
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
41
|
+
"postinstall": "node scripts/patch-codex-acp.mjs"
|
|
38
42
|
},
|
|
39
43
|
"dependencies": {
|
|
44
|
+
"@agentclientprotocol/codex-acp": "1.11.0",
|
|
40
45
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
41
46
|
"@anthropic-ai/claude-agent-sdk": "^0.2.89",
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
42
48
|
"@slack/bolt": "^4.6.0",
|
|
49
|
+
"croner": "^10.0.1",
|
|
43
50
|
"diff": "^8.0.3",
|
|
44
51
|
"grammy": "^1.30.0",
|
|
45
52
|
"mime": "^4.1.0",
|
|
46
53
|
"pino": "^10.3.1",
|
|
47
54
|
"pino-pretty": "^13.1.3",
|
|
48
55
|
"pino-roll": "^4.0.0",
|
|
49
|
-
"slackify-markdown": "^5.0.0",
|
|
50
56
|
"telegramify-markdown": "^1.3.3"
|
|
51
57
|
},
|
|
52
58
|
"devDependencies": {
|
|
@@ -54,6 +60,7 @@
|
|
|
54
60
|
"@types/diff": "^7.0.2",
|
|
55
61
|
"@types/node": "^22.0.0",
|
|
56
62
|
"nodemon": "^3.1.14",
|
|
63
|
+
"telegram": "^2.26.22",
|
|
57
64
|
"tsx": "^4.19.0",
|
|
58
65
|
"typescript": "^5.7.0"
|
|
59
66
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Codex ACP usage metadata backport
|
|
2
|
+
|
|
3
|
+
ClearClaw temporarily pins `@agentclientprotocol/codex-acp` to npm version `1.11.0` and backports the usage notification changes from [codex-acp PR #334](https://github.com/agentclientprotocol/codex-acp/pull/334), head [`b670a4e4914fbd5ffe2d008091e64921dc2684d4`](https://github.com/agentclientprotocol/codex-acp/commit/b670a4e4914fbd5ffe2d008091e64921dc2684d4). The upstream adapter and these adapted changes are Apache-2.0 licensed.
|
|
4
|
+
|
|
5
|
+
The npm release distributes a bundled `dist/index.js`, so the patch targets that bundle. It forwards account limit updates as ACP `usage_update` notifications and includes all cached quota buckets in `_meta["_codex/rateLimits"]` on context usage updates. Notifications retain the upstream requirement that context usage and a positive context window are known. This does not fetch quotas on demand.
|
|
6
|
+
|
|
7
|
+
Only the emission changes are backported. Version 1.11.0's existing `mergeRateLimitSnapshot` logic remains intact, including preservation of account-level fields on sparse updates.
|
|
8
|
+
|
|
9
|
+
`scripts/patch-codex-acp.mjs` runs after installation and uses ClearClaw's existing `diff` dependency. It resolves the installed adapter, checks its exact version and original SHA-256 hash, applies this patch, and verifies the resulting hash. An already patched bundle is accepted, making repeat installation idempotent. Unexpected source fails installation instead of silently dropping quota data.
|
|
10
|
+
|
|
11
|
+
The installer modifies the resolved dependency in place. In an installation where npm hoists and shares that exact adapter version, other consumers also receive these additive usage metadata notifications. Both the script and patch are included in ClearClaw's published package. Installations using `--ignore-scripts` must explicitly run `node scripts/patch-codex-acp.mjs` from the ClearClaw package directory before relying on Codex quota reporting.
|
|
12
|
+
|
|
13
|
+
Remove the installer and patch when an upstream release includes this metadata contract, then update the pinned dependency and verify usage notifications.
|