@linkedclaw/cli 0.1.2 → 0.1.5
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 +248 -48
- package/dist/bin.js +8099 -4778
- package/dist/bin.js.map +1 -1
- package/package.json +17 -32
- package/src/arena/api.ts +154 -0
- package/src/arena/hash.ts +15 -0
- package/src/arena/types.ts +106 -0
- package/src/bin.ts +33 -0
- package/src/commands/agent.ts +264 -0
- package/src/commands/arena.ts +393 -0
- package/src/commands/auth.ts +116 -0
- package/src/commands/converge.ts +969 -0
- package/src/commands/provider.ts +245 -0
- package/src/commands/requester.ts +479 -0
- package/src/config.ts +85 -0
- package/src/context.ts +27 -0
- package/src/converge/api.ts +213 -0
- package/src/converge/hash.ts +35 -0
- package/src/converge/lock.ts +30 -0
- package/src/converge/staging.ts +83 -0
- package/src/converge/types.ts +91 -0
- package/src/converge/workspace.ts +92 -0
- package/src/errors.ts +41 -0
- package/src/handlers/subprocess.ts +185 -0
- package/src/output.ts +57 -0
- package/src/types.ts +90 -0
- package/test/agent-help.test.ts +207 -0
- package/test/arena-api.test.ts +211 -0
- package/test/arena-commands.test.ts +559 -0
- package/test/arena-hash.test.ts +33 -0
- package/test/cli-help.test.ts +82 -0
- package/test/converge-accept.test.ts +206 -0
- package/test/converge-decision.test.ts +274 -0
- package/test/converge-hash.test.ts +58 -0
- package/test/converge-help.test.ts +58 -0
- package/test/converge-lock.test.ts +48 -0
- package/test/converge-review.test.ts +135 -0
- package/test/converge-run.test.ts +286 -0
- package/test/converge-staging.test.ts +161 -0
- package/test/converge-status.test.ts +141 -0
- package/test/converge-workspace.test.ts +92 -0
- package/test/hire-flags.test.ts +55 -0
- package/test/recv-flags.test.ts +83 -0
- package/test/register-browser.test.ts +55 -0
- package/tsconfig.json +14 -0
- package/tsup.config.ts +25 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { LinkedClawError } from "../errors.js";
|
|
2
|
+
import type {
|
|
3
|
+
AgentListing,
|
|
4
|
+
CommonsLogEvent,
|
|
5
|
+
CruxDecisionRequest,
|
|
6
|
+
CruxDecisionResponse,
|
|
7
|
+
DebateRecord,
|
|
8
|
+
MandateRecord,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
interface FetchError extends Error {
|
|
12
|
+
code: number;
|
|
13
|
+
body: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeFetchError(code: number, body: unknown): FetchError {
|
|
17
|
+
const err = new LinkedClawError(`api_${code}`, `HTTP ${code}`) as unknown as FetchError;
|
|
18
|
+
(err as any).code = code;
|
|
19
|
+
(err as any).body = body;
|
|
20
|
+
return err;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function makeConvergeApi(cloudUrl: string, apiKey: string) {
|
|
24
|
+
async function apiFetch(path: string, opts: RequestInit = {}): Promise<unknown> {
|
|
25
|
+
const url = cloudUrl.replace(/\/$/, "") + path;
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
...opts,
|
|
28
|
+
headers: {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
Authorization: `Bearer ${apiKey}`,
|
|
31
|
+
...(opts.headers ?? {}),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
let body: unknown;
|
|
35
|
+
try {
|
|
36
|
+
body = await res.json();
|
|
37
|
+
} catch {
|
|
38
|
+
body = null;
|
|
39
|
+
}
|
|
40
|
+
if (!res.ok) throw makeFetchError(res.status, body);
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async getDebate(debateId: string): Promise<DebateRecord> {
|
|
46
|
+
return apiFetch(`/api/v1/debates/${debateId}`) as Promise<DebateRecord>;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async getCommonsLogEvents(
|
|
50
|
+
cid: string,
|
|
51
|
+
opts: { offset?: number; limit?: number } = {},
|
|
52
|
+
): Promise<{ events: CommonsLogEvent[]; next_offset: number }> {
|
|
53
|
+
// The cloud serializes events with `event_type` inside `payload` only
|
|
54
|
+
// (commons_logs.py:464). We hoist it onto the envelope here so callers
|
|
55
|
+
// can match on `ev.event_type` without reaching into payload every time.
|
|
56
|
+
// Also: server caps `limit` at 1000 — when the caller asks for more,
|
|
57
|
+
// page transparently and concatenate.
|
|
58
|
+
const requested = opts.limit ?? 1000;
|
|
59
|
+
const PAGE = 1000;
|
|
60
|
+
const offsetStart = opts.offset ?? 0;
|
|
61
|
+
let collected: CommonsLogEvent[] = [];
|
|
62
|
+
let cursor = offsetStart;
|
|
63
|
+
while (collected.length < requested) {
|
|
64
|
+
const params = new URLSearchParams();
|
|
65
|
+
params.set("offset", String(cursor));
|
|
66
|
+
params.set("limit", String(Math.min(PAGE, requested - collected.length)));
|
|
67
|
+
const page = (await apiFetch(
|
|
68
|
+
`/api/v1/commons-logs/${cid}/events?${params}`,
|
|
69
|
+
)) as { events: Array<Omit<CommonsLogEvent, "event_type"> & { event_type?: string }>; next_offset: number };
|
|
70
|
+
const hoisted: CommonsLogEvent[] = page.events.map((e) => ({
|
|
71
|
+
...e,
|
|
72
|
+
event_type: e.event_type ?? (e.payload as { event_type?: string })?.event_type ?? "",
|
|
73
|
+
}));
|
|
74
|
+
collected = collected.concat(hoisted);
|
|
75
|
+
if (page.events.length === 0 || page.next_offset === cursor) break;
|
|
76
|
+
cursor = page.next_offset;
|
|
77
|
+
}
|
|
78
|
+
return { events: collected, next_offset: cursor };
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
async discoverPaAgentId(): Promise<string> {
|
|
82
|
+
const result = (await apiFetch(
|
|
83
|
+
"/api/v1/agents?capability=convergence_synthesizer.v1",
|
|
84
|
+
)) as { agents?: AgentListing[] } | AgentListing[];
|
|
85
|
+
const listings: AgentListing[] = Array.isArray(result)
|
|
86
|
+
? result
|
|
87
|
+
: (result as any).agents ?? [];
|
|
88
|
+
if (listings.length === 0) {
|
|
89
|
+
throw new LinkedClawError("pa_not_found", "No agent found with capability convergence_synthesizer.v1");
|
|
90
|
+
}
|
|
91
|
+
return listings[0].agent_id;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async findExistingMandate(
|
|
95
|
+
principalAgentId: string,
|
|
96
|
+
delegateAgentId: string,
|
|
97
|
+
requiredScopes: string[],
|
|
98
|
+
): Promise<MandateRecord | null> {
|
|
99
|
+
// Server-side GET /api/v1/mandates lists all of the caller's mandates;
|
|
100
|
+
// it does not filter by agent. We filter client-side on principal_agent_id
|
|
101
|
+
// so we don't reuse a mandate scoped to a different agent of the same user.
|
|
102
|
+
const result = (await apiFetch(`/api/v1/mandates?kind=generalized`)) as
|
|
103
|
+
| { mandates?: MandateRecord[] }
|
|
104
|
+
| MandateRecord[];
|
|
105
|
+
const list: MandateRecord[] = Array.isArray(result)
|
|
106
|
+
? result
|
|
107
|
+
: (result as any).mandates ?? [];
|
|
108
|
+
const required = new Set(requiredScopes);
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
for (const m of list) {
|
|
111
|
+
if (m.principal_agent_id !== principalAgentId) continue;
|
|
112
|
+
if (m.delegate_agent_id !== delegateAgentId) continue;
|
|
113
|
+
if (m.revoked_at) continue;
|
|
114
|
+
if (m.expires_at && new Date(m.expires_at).getTime() <= now) continue;
|
|
115
|
+
if (![...required].every((s) => m.scope.includes(s))) continue;
|
|
116
|
+
return m;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async issueMandate(
|
|
122
|
+
principalAgentId: string,
|
|
123
|
+
delegateAgentId: string,
|
|
124
|
+
scopes: string[],
|
|
125
|
+
expiresAt?: string,
|
|
126
|
+
): Promise<MandateRecord> {
|
|
127
|
+
return apiFetch("/api/v1/mandates", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
principal_agent_id: principalAgentId,
|
|
131
|
+
delegate_agent_id: delegateAgentId,
|
|
132
|
+
scope: scopes,
|
|
133
|
+
...(expiresAt ? { expires_at: expiresAt } : {}),
|
|
134
|
+
}),
|
|
135
|
+
}) as Promise<MandateRecord>;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async startRun(sourceDebateId: string): Promise<{ run_id: string; commons_log_id: string }> {
|
|
139
|
+
return apiFetch("/api/v1/convergence/runs", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
body: JSON.stringify({ source_debate_id: sourceDebateId }),
|
|
142
|
+
}) as Promise<{ run_id: string; commons_log_id: string }>;
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async getRun(runId: string): Promise<{
|
|
146
|
+
run_id: string;
|
|
147
|
+
source_debate_id: string;
|
|
148
|
+
agent_a_id: string;
|
|
149
|
+
agent_b_id: string;
|
|
150
|
+
pa_agent_id: string;
|
|
151
|
+
status: string;
|
|
152
|
+
}> {
|
|
153
|
+
return apiFetch(`/api/v1/convergence/runs/${runId}`) as Promise<{
|
|
154
|
+
run_id: string;
|
|
155
|
+
source_debate_id: string;
|
|
156
|
+
agent_a_id: string;
|
|
157
|
+
agent_b_id: string;
|
|
158
|
+
pa_agent_id: string;
|
|
159
|
+
status: string;
|
|
160
|
+
}>;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async acceptOwnerB(runId: string): Promise<{ ok: boolean }> {
|
|
164
|
+
return apiFetch(`/api/v1/convergence/runs/${runId}/owner_b_accept`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
}) as Promise<{ ok: boolean }>;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async appendCommonsLog(
|
|
170
|
+
cid: string,
|
|
171
|
+
eventType: string,
|
|
172
|
+
payload: Record<string, unknown>,
|
|
173
|
+
): Promise<{ seq: number }> {
|
|
174
|
+
return apiFetch(`/api/v1/commons-logs/${cid}/append`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body: JSON.stringify({ event_type: eventType, payload }),
|
|
177
|
+
}) as Promise<{ seq: number }>;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async acceptCruxDecision(
|
|
181
|
+
runId: string,
|
|
182
|
+
cruxId: string,
|
|
183
|
+
body: CruxDecisionRequest,
|
|
184
|
+
): Promise<CruxDecisionResponse> {
|
|
185
|
+
return apiFetch(`/api/v1/convergence/runs/${runId}/cruxes/${cruxId}/accept`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
body: JSON.stringify(body),
|
|
188
|
+
}) as Promise<CruxDecisionResponse>;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async rejectCruxDecision(
|
|
192
|
+
runId: string,
|
|
193
|
+
cruxId: string,
|
|
194
|
+
body: CruxDecisionRequest,
|
|
195
|
+
): Promise<CruxDecisionResponse> {
|
|
196
|
+
return apiFetch(`/api/v1/convergence/runs/${runId}/cruxes/${cruxId}/reject`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: JSON.stringify(body),
|
|
199
|
+
}) as Promise<CruxDecisionResponse>;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async attestCruxDecision(
|
|
203
|
+
runId: string,
|
|
204
|
+
cruxId: string,
|
|
205
|
+
body: CruxDecisionRequest,
|
|
206
|
+
): Promise<CruxDecisionResponse> {
|
|
207
|
+
return apiFetch(`/api/v1/convergence/runs/${runId}/cruxes/${cruxId}/attest`, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
body: JSON.stringify(body),
|
|
210
|
+
}) as Promise<CruxDecisionResponse>;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Encode a string as a JSON string literal with non-ASCII chars escaped as \uXXXX
|
|
4
|
+
// to match Python json.dumps(ensure_ascii=True) output.
|
|
5
|
+
function encodeString(s: string): string {
|
|
6
|
+
let out = '"';
|
|
7
|
+
for (let i = 0; i < s.length; i++) {
|
|
8
|
+
const cp = s.charCodeAt(i);
|
|
9
|
+
if (cp === 0x22) out += '\\"';
|
|
10
|
+
else if (cp === 0x5c) out += "\\\\";
|
|
11
|
+
else if (cp === 0x08) out += "\\b";
|
|
12
|
+
else if (cp === 0x09) out += "\\t";
|
|
13
|
+
else if (cp === 0x0a) out += "\\n";
|
|
14
|
+
else if (cp === 0x0c) out += "\\f";
|
|
15
|
+
else if (cp === 0x0d) out += "\\r";
|
|
16
|
+
else if (cp < 0x20 || cp > 0x7e) out += `\\u${cp.toString(16).padStart(4, "0")}`;
|
|
17
|
+
else out += s[i];
|
|
18
|
+
}
|
|
19
|
+
return out + '"';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function canonicalize(value: unknown): string {
|
|
23
|
+
if (value === null) return "null";
|
|
24
|
+
if (typeof value === "string") return encodeString(value);
|
|
25
|
+
if (typeof value !== "object") return JSON.stringify(value);
|
|
26
|
+
if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
|
|
27
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
28
|
+
return "{" + keys.map((k) => encodeString(k) + ":" + canonicalize((value as any)[k])).join(",") + "}";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sha256OfCanonicalJson(value: unknown): string {
|
|
32
|
+
const h = createHash("sha256");
|
|
33
|
+
h.update(canonicalize(value));
|
|
34
|
+
return "sha256:" + h.digest("hex");
|
|
35
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { closeSync, openSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { LinkedClawError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
const LOCK_FILENAME = ".lock";
|
|
6
|
+
|
|
7
|
+
export function acquireLock(stagingDir: string): () => void {
|
|
8
|
+
const path = join(stagingDir, LOCK_FILENAME);
|
|
9
|
+
let fd: number;
|
|
10
|
+
try {
|
|
11
|
+
fd = openSync(path, "wx");
|
|
12
|
+
} catch (e: any) {
|
|
13
|
+
if (e.code === "EEXIST") {
|
|
14
|
+
throw new LinkedClawError(
|
|
15
|
+
"lock_held",
|
|
16
|
+
`Lock held at ${path}. If no other run/accept is in progress, delete ${path} to recover.`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
writeSync(fd, JSON.stringify({ pid: process.pid }));
|
|
22
|
+
closeSync(fd);
|
|
23
|
+
return () => {
|
|
24
|
+
try {
|
|
25
|
+
unlinkSync(path);
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { load as yamlLoad, dump as yamlDump } from "js-yaml";
|
|
5
|
+
|
|
6
|
+
export interface StagingFrontmatter {
|
|
7
|
+
debate_id: string;
|
|
8
|
+
run_id: string;
|
|
9
|
+
crux_id: string;
|
|
10
|
+
sub_debate_chain: string[];
|
|
11
|
+
latest_sub_debate_id: string | null;
|
|
12
|
+
source_crux_map_hash: string;
|
|
13
|
+
generation_id: string;
|
|
14
|
+
generated_at: string;
|
|
15
|
+
pa_body_hash: string;
|
|
16
|
+
outcome: "converged" | "partial_overlap" | "needs_input" | "irreconcilable" | "already_aligned";
|
|
17
|
+
bilateral_mandate_intact: boolean;
|
|
18
|
+
citations_a: Array<Record<string, unknown>>;
|
|
19
|
+
citations_b: Array<Record<string, unknown>>;
|
|
20
|
+
mod_progress_summary: Record<string, unknown>;
|
|
21
|
+
attested_by_user: boolean;
|
|
22
|
+
provenance?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StagingDoc {
|
|
26
|
+
frontmatter: StagingFrontmatter;
|
|
27
|
+
userResponse: string;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function stagingPathFor(stagingDir: string, cruxId: string): string {
|
|
32
|
+
return join(stagingDir, `${cruxId}.md`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listCruxFiles(stagingDir: string): string[] {
|
|
36
|
+
if (!existsSync(stagingDir)) return [];
|
|
37
|
+
return readdirSync(stagingDir).filter(
|
|
38
|
+
(f) => f.endsWith(".md") && !f.startsWith("."),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseStaging(text: string): StagingDoc {
|
|
43
|
+
if (!text.startsWith("---\n")) {
|
|
44
|
+
throw new Error("Missing YAML frontmatter: document must start with ---\\n");
|
|
45
|
+
}
|
|
46
|
+
const endIdx = text.indexOf("\n---\n", 4);
|
|
47
|
+
if (endIdx === -1) {
|
|
48
|
+
throw new Error("Malformed frontmatter: no closing ---");
|
|
49
|
+
}
|
|
50
|
+
const yamlText = text.slice(4, endIdx);
|
|
51
|
+
const body = text.slice(endIdx + 5);
|
|
52
|
+
const raw = yamlLoad(yamlText) as Record<string, unknown>;
|
|
53
|
+
if (!raw || typeof raw !== "object") {
|
|
54
|
+
throw new Error("Frontmatter parsed to non-object");
|
|
55
|
+
}
|
|
56
|
+
const userResponse = typeof raw._user_response === "string" ? raw._user_response : "";
|
|
57
|
+
delete raw._user_response;
|
|
58
|
+
return {
|
|
59
|
+
frontmatter: raw as unknown as StagingFrontmatter,
|
|
60
|
+
userResponse,
|
|
61
|
+
body,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function dumpStaging(doc: StagingDoc): string {
|
|
66
|
+
const fmRaw: Record<string, unknown> = { ...doc.frontmatter };
|
|
67
|
+
fmRaw._user_response = doc.userResponse ?? "";
|
|
68
|
+
const yamlText = yamlDump(fmRaw, { lineWidth: -1, sortKeys: false });
|
|
69
|
+
return `---\n${yamlText}---\n${doc.body}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readStaging(path: string): StagingDoc {
|
|
73
|
+
return parseStaging(readFileSync(path, "utf8"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writeStaging(path: string, doc: StagingDoc): void {
|
|
77
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
78
|
+
writeFileSync(path, dumpStaging(doc), "utf8");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function computePaBodyHash(body: string): string {
|
|
82
|
+
return "sha256:" + createHash("sha256").update(Buffer.from(body, "utf8")).digest("hex");
|
|
83
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface RunMeta {
|
|
2
|
+
run_id: string;
|
|
3
|
+
source_debate_id: string;
|
|
4
|
+
pa_agent_id: string;
|
|
5
|
+
target_corpus: string;
|
|
6
|
+
owner_role: "a" | "b";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RunWorkspace {
|
|
10
|
+
runId: string;
|
|
11
|
+
sourceDebateId: string;
|
|
12
|
+
paAgentId: string;
|
|
13
|
+
targetCorpus: string;
|
|
14
|
+
stagingDir: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DebateRecord {
|
|
18
|
+
debate_id: string;
|
|
19
|
+
agent_a_id: string;
|
|
20
|
+
agent_b_id: string;
|
|
21
|
+
commons_log_id: string;
|
|
22
|
+
counterparty_user_id: string | null;
|
|
23
|
+
status: string;
|
|
24
|
+
topic?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CommonsLogEvent {
|
|
28
|
+
seq: number;
|
|
29
|
+
event_type: string;
|
|
30
|
+
payload: Record<string, unknown>;
|
|
31
|
+
appended_at: string;
|
|
32
|
+
signed_by: string;
|
|
33
|
+
signature_hash?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AgentListing {
|
|
37
|
+
agent_id: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
capabilities: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MandateRecord {
|
|
43
|
+
mandate_id: string;
|
|
44
|
+
principal_agent_id: string;
|
|
45
|
+
delegate_agent_id: string;
|
|
46
|
+
scope: string[];
|
|
47
|
+
expires_at: string | null;
|
|
48
|
+
revoked_at?: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RunStateSummary {
|
|
52
|
+
run_id: string;
|
|
53
|
+
source_debate_id: string;
|
|
54
|
+
started_at: string | null;
|
|
55
|
+
owner_b_accepted: boolean;
|
|
56
|
+
cruxes: Array<{
|
|
57
|
+
crux_id: string;
|
|
58
|
+
latest_sub_debate_id: string | null;
|
|
59
|
+
sub_debate_chain: string[];
|
|
60
|
+
outcome: string | null;
|
|
61
|
+
bilateral_mandate_intact: boolean | null;
|
|
62
|
+
}>;
|
|
63
|
+
terminal_emitted: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ConvergenceDecisionAction = "accept" | "reject" | "attest";
|
|
67
|
+
|
|
68
|
+
export type ConvergenceAttestation =
|
|
69
|
+
| "bilateral_convergence"
|
|
70
|
+
| "user_attested_with_network_context"
|
|
71
|
+
| "user_attested_no_dialog";
|
|
72
|
+
|
|
73
|
+
export interface CruxDecisionRequest {
|
|
74
|
+
convergence_map_generation_id: string;
|
|
75
|
+
source_crux_map_hash: string;
|
|
76
|
+
latest_sub_debate_id: string | null;
|
|
77
|
+
terminal_outcome: "converged" | "partial_overlap" | "needs_input" | "irreconcilable" | "already_aligned";
|
|
78
|
+
bilateral_mandate_intact: boolean;
|
|
79
|
+
attestation: ConvergenceAttestation;
|
|
80
|
+
synthesis_edited: boolean;
|
|
81
|
+
pa_body_hash: string;
|
|
82
|
+
accepted_body_hash: string;
|
|
83
|
+
synthesis_text: string;
|
|
84
|
+
citations_a: Array<Record<string, unknown>>;
|
|
85
|
+
citations_b: Array<Record<string, unknown>>;
|
|
86
|
+
user_message?: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CruxDecisionResponse {
|
|
90
|
+
event_id: string;
|
|
91
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { load as yamlLoad, dump as yamlDump } from "js-yaml";
|
|
4
|
+
import { LinkedClawError } from "../errors.js";
|
|
5
|
+
import type { RunMeta, RunWorkspace } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface ResolveOpts {
|
|
8
|
+
runId?: string;
|
|
9
|
+
stagingDir?: string;
|
|
10
|
+
targetCorpus?: string;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const META_FILENAME = ".run-meta.yaml";
|
|
15
|
+
|
|
16
|
+
export function readRunMeta(stagingDir: string): RunMeta | null {
|
|
17
|
+
const metaPath = join(stagingDir, META_FILENAME);
|
|
18
|
+
if (!existsSync(metaPath)) return null;
|
|
19
|
+
return yamlLoad(readFileSync(metaPath, "utf8")) as RunMeta;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeRunMeta(stagingDir: string, meta: RunMeta): void {
|
|
23
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
24
|
+
writeFileSync(join(stagingDir, META_FILENAME), yamlDump(meta), "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function searchUpward(startDir: string, maxLevels = 5): string | null {
|
|
28
|
+
let dir = startDir;
|
|
29
|
+
for (let i = 0; i < maxLevels; i++) {
|
|
30
|
+
if (existsSync(join(dir, META_FILENAME))) return dir;
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir) break; // filesystem root
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolveWorkspace(opts: ResolveOpts): Promise<RunWorkspace> {
|
|
39
|
+
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
|
|
40
|
+
|
|
41
|
+
let stagingDir: string | undefined;
|
|
42
|
+
let meta: RunMeta | null = null;
|
|
43
|
+
|
|
44
|
+
if (opts.stagingDir) {
|
|
45
|
+
stagingDir = isAbsolute(opts.stagingDir) ? opts.stagingDir : resolve(cwd, opts.stagingDir);
|
|
46
|
+
meta = readRunMeta(stagingDir);
|
|
47
|
+
if (!meta) {
|
|
48
|
+
throw new LinkedClawError(
|
|
49
|
+
"meta_not_found",
|
|
50
|
+
`No ${META_FILENAME} found in --staging-dir: ${stagingDir}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
const found = searchUpward(cwd);
|
|
55
|
+
if (found) {
|
|
56
|
+
stagingDir = found;
|
|
57
|
+
meta = readRunMeta(found)!;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!meta) {
|
|
62
|
+
if (opts.runId) {
|
|
63
|
+
throw new LinkedClawError(
|
|
64
|
+
"meta_not_found",
|
|
65
|
+
`--run-id given but no ${META_FILENAME} found (searched upward from ${cwd}). Provide --staging-dir to locate the run workspace.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
throw new LinkedClawError(
|
|
69
|
+
"meta_not_found",
|
|
70
|
+
`No ${META_FILENAME} found (searched upward from ${cwd}). Run 'lc converge run <debate_id>' first.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (opts.runId && opts.runId !== meta.run_id) {
|
|
75
|
+
throw new LinkedClawError(
|
|
76
|
+
"run_id_mismatch",
|
|
77
|
+
`--run-id ${opts.runId} does not match run_id ${meta.run_id} in ${META_FILENAME}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const targetCorpus = isAbsolute(meta.target_corpus)
|
|
82
|
+
? meta.target_corpus
|
|
83
|
+
: resolve(stagingDir!, meta.target_corpus);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
runId: meta.run_id,
|
|
87
|
+
sourceDebateId: meta.source_debate_id,
|
|
88
|
+
paAgentId: meta.pa_agent_id,
|
|
89
|
+
targetCorpus,
|
|
90
|
+
stagingDir: stagingDir!,
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class LinkedClawError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(code: string, message: string) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "LinkedClawError";
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class NetworkError extends LinkedClawError {
|
|
11
|
+
constructor(message: string, cause?: unknown) {
|
|
12
|
+
super("network_error", message);
|
|
13
|
+
this.name = "NetworkError";
|
|
14
|
+
if (cause !== undefined) this.cause = cause;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ApiError extends LinkedClawError {
|
|
19
|
+
constructor(
|
|
20
|
+
readonly status: number,
|
|
21
|
+
readonly detail: string,
|
|
22
|
+
readonly path: string,
|
|
23
|
+
) {
|
|
24
|
+
super(`api_error_${status}`, `[${status}] ${path}: ${detail}`);
|
|
25
|
+
this.name = "ApiError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ConfigError extends LinkedClawError {
|
|
30
|
+
constructor(message: string) {
|
|
31
|
+
super("config_error", message);
|
|
32
|
+
this.name = "ConfigError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class HandlerError extends LinkedClawError {
|
|
37
|
+
constructor(code: string, message: string) {
|
|
38
|
+
super(code, message);
|
|
39
|
+
this.name = "HandlerError";
|
|
40
|
+
}
|
|
41
|
+
}
|