ada-agent 0.4.0 → 0.6.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/docs/integrations.md +7 -1
- package/package.json +1 -1
- package/src/client/agent-server.ts +8 -0
- package/src/client/agent.ts +16 -15
- package/src/client/cli.ts +145 -13
- package/src/client/embed-index.ts +198 -0
- package/src/client/skill-router.ts +19 -7
- package/src/client/tools.ts +25 -0
- package/src/sdk/index.ts +33 -6
- package/src/selfcheck.ts +51 -0
- package/src/server/config.ts +5 -3
- package/src/server/index.ts +23 -0
- package/src/server/providers/copilot-token.ts +35 -0
- package/src/server/providers/openai-compat.ts +27 -7
package/docs/integrations.md
CHANGED
|
@@ -24,8 +24,14 @@ language, over plain HTTP + Server-Sent Events:
|
|
|
24
24
|
```
|
|
25
25
|
GET /v1/sessions → { sessions: [{ file, title, mtime, parent? }, …] }
|
|
26
26
|
POST /v1/sessions {"resume"?: "latest"|"<file>"} → { sessionId, model, file, resumed }
|
|
27
|
-
POST /v1/sessions/:id/prompt {"text"
|
|
27
|
+
POST /v1/sessions/:id/prompt {"text":…, "images"?: [dataURL|https…]}
|
|
28
|
+
→ SSE stream of events (see below), until "done"
|
|
29
|
+
(409 if a turn is already running on this session)
|
|
28
30
|
POST /v1/sessions/:id/approve {"id":…, "decision":"yes"|"all"|"no"}
|
|
31
|
+
POST /v1/sessions/:id/abort → cancel the running turn ("stop generating"); also
|
|
32
|
+
denies any approval it was parked on
|
|
33
|
+
POST /v1/sessions/:id/steer {"text":…} → queue a mid-turn user message (409 when idle)
|
|
34
|
+
PATCH /v1/sessions/:id {"mode":"ask"|"plan"|"auto"} → switch the permission mode live
|
|
29
35
|
DELETE /v1/sessions/:id → free the session (does not delete the transcript)
|
|
30
36
|
```
|
|
31
37
|
|
package/package.json
CHANGED
|
@@ -39,6 +39,14 @@ export class ApprovalRegistry {
|
|
|
39
39
|
return true;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Deny every pending approval — an aborted turn must not stay parked on an unanswered prompt. */
|
|
43
|
+
abortAll(): number {
|
|
44
|
+
const n = this.pending.size;
|
|
45
|
+
for (const resolve of this.pending.values()) resolve("no");
|
|
46
|
+
this.pending.clear();
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
get size(): number {
|
|
43
51
|
return this.pending.size;
|
|
44
52
|
}
|
package/src/client/agent.ts
CHANGED
|
@@ -19,8 +19,8 @@ type Msg = OpenAI.Chat.Completions.ChatCompletionMessageParam;
|
|
|
19
19
|
* When `onEvent` is set on SendCtrl, `send()` emits these instead of writing to stdout. */
|
|
20
20
|
export type AgentEvent =
|
|
21
21
|
| { type: "text"; delta: string }
|
|
22
|
-
| { type: "tool_call"; name: string; detail: string }
|
|
23
|
-
| { type: "tool_result"; name: string; output: string; isError: boolean }
|
|
22
|
+
| { type: "tool_call"; callId: string; name: string; detail: string }
|
|
23
|
+
| { type: "tool_result"; callId: string; name: string; output: string; isError: boolean }
|
|
24
24
|
| { type: "done"; text: string; usage: string };
|
|
25
25
|
type SendCtrl = { signal?: AbortSignal; steer?: string[]; quiet?: boolean; images?: string[]; onReplyStart?: () => void; onEvent?: (e: AgentEvent) => void };
|
|
26
26
|
type ToolCall = { id: string; name: string; args: string };
|
|
@@ -50,7 +50,7 @@ function systemPrompt(includeProject: boolean): string {
|
|
|
50
50
|
"You are ada, a minimal coding agent running in a terminal, in the spirit of pi, Codex, and Cursor.",
|
|
51
51
|
`Working directory: ${process.cwd()}`,
|
|
52
52
|
`Platform: ${process.platform}`,
|
|
53
|
-
"Tools: read_file, write_file, edit_file, bash, ls, grep, glob, web_fetch, web_search, lsp_diagnostics. Use grep/glob/ls to explore the codebase; read a file before editing it; prefer edit_file for changes to existing files; web_fetch to read a URL, web_search to find one; lsp_diagnostics to check a file for errors after editing; apply_patch for multi-file changes; ask_user only when genuinely blocked.",
|
|
53
|
+
"Tools: read_file, write_file, edit_file, bash, ls, grep, glob, codebase_search, web_fetch, web_search, lsp_diagnostics. Use grep/glob/ls to explore the codebase — or codebase_search when you're looking for code by MEANING rather than an exact string; read a file before editing it; prefer edit_file for changes to existing files; web_fetch to read a URL, web_search to find one; lsp_diagnostics to check a file for errors after editing; apply_patch for multi-file changes; ask_user only when genuinely blocked.",
|
|
54
54
|
"Specialized skills are available: call list_skills to browse them (by category or filter), then use_skill to load one before a specialized task.",
|
|
55
55
|
"Be concise. Don't narrate routine actions or pad with preamble. When you have enough information to act, act. Ask only when genuinely blocked or before destructive, irreversible actions.",
|
|
56
56
|
].join("\n") + (includeProject ? projectContext() : "")
|
|
@@ -654,6 +654,7 @@ export class Agent {
|
|
|
654
654
|
calls[tc.index] = entry;
|
|
655
655
|
}
|
|
656
656
|
if (tc.id) entry.id = tc.id;
|
|
657
|
+
else if (!entry.id) entry.id = `call_${tc.index}`; // some backends omit streamed ids — consumers key events on callId
|
|
657
658
|
if (tc.function?.name) entry.name += tc.function.name;
|
|
658
659
|
if (tc.function?.arguments) entry.args += tc.function.arguments;
|
|
659
660
|
}
|
|
@@ -699,14 +700,14 @@ export class Agent {
|
|
|
699
700
|
* append one tool message per call. */
|
|
700
701
|
private async execTools(toolCalls: ToolCall[], ctrl: SendCtrl | undefined, say: (s: string) => void): Promise<void> {
|
|
701
702
|
const signal = ctrl?.signal;
|
|
702
|
-
const printCall = (name: string, args: Record<string, unknown>): void => {
|
|
703
|
+
const printCall = (callId: string, name: string, args: Record<string, unknown>): void => {
|
|
703
704
|
const d = describeCall(name, args);
|
|
704
705
|
const detail = d.detail ? ` ${d.detail.length > 100 ? `${d.detail.slice(0, 99)}…` : d.detail}` : "";
|
|
705
|
-
ctrl?.onEvent?.({ type: "tool_call", name, detail: d.detail });
|
|
706
|
+
ctrl?.onEvent?.({ type: "tool_call", callId, name, detail: d.detail });
|
|
706
707
|
say(`\n\x1b[2m• ${name}${detail}\x1b[0m\n`);
|
|
707
708
|
};
|
|
708
|
-
const printResult = (name: string, r: ToolResult): void => {
|
|
709
|
-
ctrl?.onEvent?.({ type: "tool_result", name, output: r.output, isError: !!r.isError });
|
|
709
|
+
const printResult = (callId: string, name: string, r: ToolResult): void => {
|
|
710
|
+
ctrl?.onEvent?.({ type: "tool_result", callId, name, output: r.output, isError: !!r.isError });
|
|
710
711
|
if (r.display) say(`${r.display}\n`);
|
|
711
712
|
else if (r.isError) say(`\x1b[31m ${r.output.split("\n")[0]}\x1b[0m\n`);
|
|
712
713
|
};
|
|
@@ -734,15 +735,15 @@ export class Agent {
|
|
|
734
735
|
continue;
|
|
735
736
|
}
|
|
736
737
|
if (!tool) {
|
|
737
|
-
printCall(c.name, args);
|
|
738
|
+
printCall(c.id, c.name, args);
|
|
738
739
|
results[i] = { output: `Unknown tool: ${c.name}`, isError: true };
|
|
739
740
|
continue;
|
|
740
741
|
}
|
|
741
742
|
const perm = permissionFor(c.name, summarize(args)); // configured allow/ask/deny rule, if any
|
|
742
743
|
if (perm === "deny") {
|
|
743
|
-
printCall(c.name, args);
|
|
744
|
+
printCall(c.id, c.name, args);
|
|
744
745
|
results[i] = { output: "Denied by permission policy.", isError: true };
|
|
745
|
-
printResult(c.name, results[i]!);
|
|
746
|
+
printResult(c.id, c.name, results[i]!);
|
|
746
747
|
continue;
|
|
747
748
|
}
|
|
748
749
|
if (!tool.needsApproval && perm !== "ask") {
|
|
@@ -750,10 +751,10 @@ export class Agent {
|
|
|
750
751
|
continue;
|
|
751
752
|
}
|
|
752
753
|
// gated tool (or a rule forces "ask") → sequential (so prompts and same-file writes don't race)
|
|
753
|
-
printCall(c.name, args);
|
|
754
|
+
printCall(c.id, c.name, args);
|
|
754
755
|
if (this.planMode && tool.needsApproval) {
|
|
755
756
|
results[i] = { output: "Plan mode: not executing — finish the plan; the user approves with /run." };
|
|
756
|
-
printResult(c.name, results[i]!);
|
|
757
|
+
printResult(c.id, c.name, results[i]!);
|
|
757
758
|
continue;
|
|
758
759
|
}
|
|
759
760
|
const forceConfirm = c.name === "bash" && isDestructive(String(args.command ?? ""));
|
|
@@ -771,15 +772,15 @@ export class Agent {
|
|
|
771
772
|
results[i] = await runTool(tool, c.name, args);
|
|
772
773
|
}
|
|
773
774
|
}
|
|
774
|
-
printResult(c.name, results[i]!);
|
|
775
|
+
printResult(c.id, c.name, results[i]!);
|
|
775
776
|
}
|
|
776
777
|
await Promise.all(
|
|
777
778
|
parallel.map(async (i) => {
|
|
778
779
|
const c = toolCalls[i]!;
|
|
779
780
|
const args = argsOf(c.args);
|
|
780
|
-
printCall(c.name, args);
|
|
781
|
+
printCall(c.id, c.name, args);
|
|
781
782
|
results[i] = await runTool(toolByName.get(c.name)!, c.name, args);
|
|
782
|
-
printResult(c.name, results[i]!);
|
|
783
|
+
printResult(c.id, c.name, results[i]!);
|
|
783
784
|
}),
|
|
784
785
|
);
|
|
785
786
|
for (let i = 0; i < toolCalls.length; i++) {
|
package/src/client/cli.ts
CHANGED
|
@@ -502,6 +502,11 @@ const NO_BACKEND = new Set(["mcp", "skill", "worktree", "wt", "catalog", "share"
|
|
|
502
502
|
|
|
503
503
|
async function main(): Promise<void> {
|
|
504
504
|
const sub = process.argv[2];
|
|
505
|
+
if (sub === "--version" || sub === "-v" || sub === "version") {
|
|
506
|
+
// Before anything else — must not auto-start a backend just to print a version.
|
|
507
|
+
console.log(`ada ${adaVersion()}`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
505
510
|
if (sub === "login" || sub === "logout") {
|
|
506
511
|
await authCommand(sub, process.argv[3]);
|
|
507
512
|
return;
|
|
@@ -643,9 +648,10 @@ async function main(): Promise<void> {
|
|
|
643
648
|
return;
|
|
644
649
|
}
|
|
645
650
|
if (sub === "acp") {
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
651
|
+
// Agent Client Protocol bridge over stdio (JSON-RPC 2.0, newline-delimited). Handles
|
|
652
|
+
// initialize / session/new / session/prompt, and streams session/update notifications
|
|
653
|
+
// (agent_message_chunk + tool_call/tool_call_update) while a turn runs — the shape ACP editors
|
|
654
|
+
// like Zed render live. Still experimental until exercised against a real ACP client.
|
|
649
655
|
const trusted = isTrusted(process.cwd());
|
|
650
656
|
const settings = loadSettings(trusted);
|
|
651
657
|
await loadExtensions(trusted);
|
|
@@ -662,6 +668,9 @@ async function main(): Promise<void> {
|
|
|
662
668
|
}
|
|
663
669
|
const agent = new Agent({ client, model, session: Session.create(), onApprove: async (): Promise<ApprovalDecision> => "yes", autoApprove: true, project: trusted, compactAt: settings.compactAt });
|
|
664
670
|
const send = (msg: object): void => void stdout.write(`${JSON.stringify(msg)}\n`);
|
|
671
|
+
const ACP_SESSION = newId("acp");
|
|
672
|
+
const update = (update: object): void => send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: ACP_SESSION, update } });
|
|
673
|
+
let acpCtrl: AbortController | null = null; // the in-flight prompt's abort handle (session/cancel)
|
|
665
674
|
let buf = "";
|
|
666
675
|
stdin.on("data", async (d) => {
|
|
667
676
|
buf += d.toString("utf8");
|
|
@@ -677,16 +686,29 @@ async function main(): Promise<void> {
|
|
|
677
686
|
continue;
|
|
678
687
|
}
|
|
679
688
|
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1, agentCapabilities: { promptCapabilities: {} } } });
|
|
680
|
-
else if (msg.method === "session/new" || msg.method === "newSession") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId:
|
|
681
|
-
else if (msg.method === "session/
|
|
689
|
+
else if (msg.method === "session/new" || msg.method === "newSession") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: ACP_SESSION } });
|
|
690
|
+
else if (msg.method === "session/cancel" || msg.method === "cancel") {
|
|
691
|
+
acpCtrl?.abort();
|
|
692
|
+
if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
693
|
+
} else if (msg.method === "session/prompt" || msg.method === "prompt") {
|
|
682
694
|
const p = msg.params ?? {};
|
|
683
695
|
const blocks = (p.prompt ?? p.text) as unknown;
|
|
684
696
|
const text = Array.isArray(blocks) ? blocks.map((b) => (b as { text?: string }).text ?? "").join("") : String(blocks ?? "");
|
|
697
|
+
acpCtrl = new AbortController();
|
|
685
698
|
try {
|
|
686
|
-
|
|
687
|
-
|
|
699
|
+
await agent.send(text, {
|
|
700
|
+
signal: acpCtrl.signal,
|
|
701
|
+
onEvent: (e: AgentEvent) => {
|
|
702
|
+
if (e.type === "text") update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: e.delta } });
|
|
703
|
+
else if (e.type === "tool_call") update({ sessionUpdate: "tool_call", toolCallId: e.callId, title: `${e.name} ${e.detail}`.trim(), status: "in_progress" });
|
|
704
|
+
else if (e.type === "tool_result") update({ sessionUpdate: "tool_call_update", toolCallId: e.callId, status: e.isError ? "failed" : "completed" });
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: acpCtrl.signal.aborted ? "cancelled" : "end_turn" } });
|
|
688
708
|
} catch (e) {
|
|
689
709
|
send({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: e instanceof Error ? e.message : String(e) } });
|
|
710
|
+
} finally {
|
|
711
|
+
acpCtrl = null;
|
|
690
712
|
}
|
|
691
713
|
} else if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
692
714
|
}
|
|
@@ -736,6 +758,9 @@ async function main(): Promise<void> {
|
|
|
736
758
|
registry: ApprovalRegistry;
|
|
737
759
|
emit: ((frame: string) => void) | null; // set only while a /prompt request's SSE stream is open
|
|
738
760
|
file: string; // the on-disk transcript — survives an `ada serve` restart; resume with it
|
|
761
|
+
ctrl: AbortController | null; // set while a turn runs — doubles as the busy flag
|
|
762
|
+
steer: string[]; // queued mid-turn user messages, drained by the agent between steps
|
|
763
|
+
mode: "ask" | "plan" | "auto";
|
|
739
764
|
}
|
|
740
765
|
const sessions = new Map<string, AgentSession>();
|
|
741
766
|
// `resumeFile` reattaches to an existing on-disk transcript (e.g. after `ada serve` restarted) —
|
|
@@ -743,7 +768,7 @@ async function main(): Promise<void> {
|
|
|
743
768
|
const makeSession = (m: string, resumeFile?: string): { id: string; rec: AgentSession } => {
|
|
744
769
|
const session = resumeFile ? Session.open(resumeFile) : Session.create();
|
|
745
770
|
const history = resumeFile ? (session.load() as unknown as Msg[]) : undefined;
|
|
746
|
-
const rec: AgentSession = { agent: undefined as unknown as Agent, registry: new ApprovalRegistry(), emit: null, file: session.file };
|
|
771
|
+
const rec: AgentSession = { agent: undefined as unknown as Agent, registry: new ApprovalRegistry(), emit: null, file: session.file, ctrl: null, steer: [], mode: "ask" };
|
|
747
772
|
rec.agent = new Agent({
|
|
748
773
|
client,
|
|
749
774
|
model: m,
|
|
@@ -810,6 +835,16 @@ async function main(): Promise<void> {
|
|
|
810
835
|
} catch {
|
|
811
836
|
/* ignore, use default model + no resume */
|
|
812
837
|
}
|
|
838
|
+
if (resume) {
|
|
839
|
+
// A live in-memory session may still be appending to that transcript (e.g. the IDE lost
|
|
840
|
+
// its SSE stream and *assumed* a restart) — two Agents on one JSONL interleave twin
|
|
841
|
+
// conversations. Point the caller at the live session instead of forking the file.
|
|
842
|
+
const live = [...sessions.entries()].find(([, r]) => r.file === resume);
|
|
843
|
+
if (live) {
|
|
844
|
+
res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ error: "that transcript belongs to a live session — reuse it (or DELETE it first)", sessionId: live[0], busy: !!live[1].ctrl }));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
813
848
|
const { id, rec } = makeSession(m, resume);
|
|
814
849
|
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ sessionId: id, model: m, file: rec.file, resumed: !!resume }));
|
|
815
850
|
});
|
|
@@ -822,28 +857,121 @@ async function main(): Promise<void> {
|
|
|
822
857
|
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
823
858
|
return;
|
|
824
859
|
}
|
|
860
|
+
if (rec.ctrl) {
|
|
861
|
+
// One turn at a time per session — two interleaved prompts would corrupt one conversation.
|
|
862
|
+
res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ error: "a turn is already running on this session — abort it or wait for done" }));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
rec.ctrl = new AbortController(); // claim the session before any await, so a racing second prompt sees busy
|
|
866
|
+
// If the client dies MID-BODY (e.g. a dropped multi-MB image upload), 'end' never fires and
|
|
867
|
+
// the claim above would brick the session with a permanent 409 — release it on 'close'.
|
|
868
|
+
req.on("close", () => {
|
|
869
|
+
if (!req.complete) {
|
|
870
|
+
rec.ctrl = null;
|
|
871
|
+
rec.steer.length = 0;
|
|
872
|
+
}
|
|
873
|
+
});
|
|
825
874
|
let body = "";
|
|
826
875
|
req.on("data", (c) => (body += c));
|
|
827
876
|
req.on("end", async () => {
|
|
828
877
|
let text = "";
|
|
878
|
+
let images: string[] | undefined;
|
|
829
879
|
try {
|
|
830
|
-
|
|
880
|
+
const j = JSON.parse(body || "{}") as { text?: string; images?: string[] };
|
|
881
|
+
text = String(j.text ?? "");
|
|
882
|
+
if (Array.isArray(j.images) && j.images.length) images = j.images.map(String);
|
|
831
883
|
} catch {
|
|
832
884
|
/* empty prompt */
|
|
833
885
|
}
|
|
834
886
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
887
|
+
// If the client drops the SSE stream mid-turn (IDE reload/crash), abort the turn — else it
|
|
888
|
+
// runs headless, and in ask mode parks forever on an approval no one can see or answer.
|
|
889
|
+
res.on("close", () => {
|
|
890
|
+
if (!res.writableEnded) {
|
|
891
|
+
rec.ctrl?.abort();
|
|
892
|
+
rec.registry.abortAll();
|
|
893
|
+
}
|
|
894
|
+
});
|
|
835
895
|
rec.emit = (frame) => res.write(frame);
|
|
836
896
|
try {
|
|
837
|
-
await rec.agent.send(text, { onEvent: (e: AgentEvent) => res.write(sseFrame(e)) });
|
|
897
|
+
await rec.agent.send(text, { signal: rec.ctrl!.signal, steer: rec.steer, images, onEvent: (e: AgentEvent) => res.write(sseFrame(e)) });
|
|
838
898
|
} catch (e) {
|
|
839
899
|
res.write(sseFrame({ type: "error", message: e instanceof Error ? e.message : String(e) }));
|
|
840
900
|
} finally {
|
|
841
901
|
rec.emit = null;
|
|
902
|
+
rec.ctrl = null;
|
|
903
|
+
rec.steer.length = 0;
|
|
842
904
|
res.end();
|
|
843
905
|
}
|
|
844
906
|
});
|
|
845
907
|
return;
|
|
846
908
|
}
|
|
909
|
+
const abortMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/abort$/);
|
|
910
|
+
if (abortMatch) {
|
|
911
|
+
const rec = sessions.get(abortMatch[1]!);
|
|
912
|
+
if (!rec) {
|
|
913
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const wasRunning = !!rec.ctrl;
|
|
917
|
+
rec.ctrl?.abort();
|
|
918
|
+
rec.registry.abortAll(); // a turn parked on an unanswered approval must not stay stuck
|
|
919
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, wasRunning }));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const steerMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/steer$/);
|
|
923
|
+
if (steerMatch) {
|
|
924
|
+
const rec = sessions.get(steerMatch[1]!);
|
|
925
|
+
if (!rec) {
|
|
926
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
let body = "";
|
|
930
|
+
req.on("data", (c) => (body += c));
|
|
931
|
+
req.on("end", () => {
|
|
932
|
+
let text = "";
|
|
933
|
+
try {
|
|
934
|
+
text = String((JSON.parse(body || "{}") as { text?: string }).text ?? "");
|
|
935
|
+
} catch {
|
|
936
|
+
/* stays empty */
|
|
937
|
+
}
|
|
938
|
+
if (!text || !rec.ctrl) {
|
|
939
|
+
// steering only makes sense mid-turn; when idle, just send the next prompt instead
|
|
940
|
+
res.writeHead(409, { "content-type": "application/json" }).end(JSON.stringify({ ok: false, error: rec.ctrl ? "empty text" : "no turn running — send a prompt instead" }));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
rec.steer.push(text);
|
|
944
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
|
|
945
|
+
});
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const modeMatch = req.method === "PATCH" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
949
|
+
if (modeMatch) {
|
|
950
|
+
const rec = sessions.get(modeMatch[1]!);
|
|
951
|
+
if (!rec) {
|
|
952
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "unknown session" }));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
let body = "";
|
|
956
|
+
req.on("data", (c) => (body += c));
|
|
957
|
+
req.on("end", () => {
|
|
958
|
+
let mode: string | undefined;
|
|
959
|
+
try {
|
|
960
|
+
mode = (JSON.parse(body || "{}") as { mode?: string }).mode;
|
|
961
|
+
} catch {
|
|
962
|
+
/* stays undefined */
|
|
963
|
+
}
|
|
964
|
+
if (mode !== "ask" && mode !== "plan" && mode !== "auto") {
|
|
965
|
+
res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: 'mode must be "ask" | "plan" | "auto"' }));
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
rec.mode = mode;
|
|
969
|
+
rec.agent.setPlanMode(mode === "plan");
|
|
970
|
+
rec.agent.setAutoApprove(mode === "auto");
|
|
971
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, mode }));
|
|
972
|
+
});
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
847
975
|
const approveMatch = req.method === "POST" && url.pathname.match(/^\/v1\/sessions\/([^/]+)\/approve$/);
|
|
848
976
|
if (approveMatch) {
|
|
849
977
|
const rec = sessions.get(approveMatch[1]!);
|
|
@@ -867,6 +995,9 @@ async function main(): Promise<void> {
|
|
|
867
995
|
}
|
|
868
996
|
const delMatch = req.method === "DELETE" && url.pathname.match(/^\/v1\/sessions\/([^/]+)$/);
|
|
869
997
|
if (delMatch) {
|
|
998
|
+
const rec = sessions.get(delMatch[1]!);
|
|
999
|
+
rec?.ctrl?.abort(); // don't orphan a running turn
|
|
1000
|
+
rec?.registry.abortAll();
|
|
870
1001
|
const existed = sessions.delete(delMatch[1]!);
|
|
871
1002
|
res.writeHead(existed ? 200 : 404, { "content-type": "application/json" }).end(JSON.stringify({ ok: existed }));
|
|
872
1003
|
return;
|
|
@@ -876,9 +1007,10 @@ async function main(): Promise<void> {
|
|
|
876
1007
|
console.log(
|
|
877
1008
|
`ada HTTP API on http://localhost:${port} · model ${model || "(none — set one)"}\n` +
|
|
878
1009
|
` one-shot: POST /v1/prompt {"text":"…"}\n` +
|
|
879
|
-
` interactive: POST /v1/sessions → {sessionId}\n` +
|
|
880
|
-
` POST /v1/sessions/:id/prompt {"text":"…"} (SSE: text/tool_call/tool_result/approval_request/done)\n` +
|
|
881
|
-
` POST /v1/sessions/:id/approve {"id":"…","decision":"yes"|"all"|"no"}
|
|
1010
|
+
` interactive: POST /v1/sessions → {sessionId} (GET lists resumable transcripts)\n` +
|
|
1011
|
+
` POST /v1/sessions/:id/prompt {"text":"…","images"?:[…]} (SSE: text/tool_call/tool_result/approval_request/done)\n` +
|
|
1012
|
+
` POST /v1/sessions/:id/approve {"id":"…","decision":"yes"|"all"|"no"}\n` +
|
|
1013
|
+
` POST /v1/sessions/:id/abort · /steer {"text":"…"} · PATCH /v1/sessions/:id {"mode":"ask"|"plan"|"auto"}`,
|
|
882
1014
|
),
|
|
883
1015
|
);
|
|
884
1016
|
await new Promise(() => {}); // keep the process alive for the server
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @codebase semantic search. Chunks the working tree, embeds chunks through the backend's
|
|
2
|
+
// /v1/embeddings (which forwards to Ollama — `ollama pull nomic-embed-text`, or set
|
|
3
|
+
// ADA_EMBED_MODEL), caches vectors in .ada/index.json keyed by content hash, and answers queries
|
|
4
|
+
// by cosine similarity. Exposed to the model as the read-only `codebase_search` tool.
|
|
5
|
+
//
|
|
6
|
+
// ponytail: brute-force cosine over a JSON cache — fine to ~50k chunks; an ANN index and a binary
|
|
7
|
+
// vector format are the upgrade path if repos outgrow it.
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join, relative, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
const EMBED_MODEL = process.env.ADA_EMBED_MODEL ?? "nomic-embed-text";
|
|
14
|
+
const BACKEND = process.env.ADA_BACKEND_URL ?? "http://localhost:8787/v1";
|
|
15
|
+
const SKIP = new Set(["node_modules", ".git", "dist", ".ada", ".next", "build", "coverage"]);
|
|
16
|
+
const TEXT_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|c|h|cpp|hpp|md|txt|json|yaml|yml|toml|css|scss|html|sql|sh|svelte|vue)$/i;
|
|
17
|
+
const CHUNK_LINES = 80;
|
|
18
|
+
const MAX_FILE_BYTES = 200_000;
|
|
19
|
+
|
|
20
|
+
export interface Chunk {
|
|
21
|
+
start: number; // 1-based first line
|
|
22
|
+
end: number;
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
interface IndexedFile {
|
|
26
|
+
hash: string;
|
|
27
|
+
chunks: Array<{ start: number; end: number; vec: number[] }>;
|
|
28
|
+
}
|
|
29
|
+
interface Index {
|
|
30
|
+
model: string;
|
|
31
|
+
files: Record<string, IndexedFile>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Split file text into fixed-size line windows, char-capped so minified/long-line files can't
|
|
35
|
+
* blow the embedding model's context window. */
|
|
36
|
+
export function chunkText(text: string, lines = CHUNK_LINES): Chunk[] {
|
|
37
|
+
const all = text.split("\n");
|
|
38
|
+
const out: Chunk[] = [];
|
|
39
|
+
for (let i = 0; i < all.length; i += lines) {
|
|
40
|
+
const slice = all.slice(i, i + lines).join("\n");
|
|
41
|
+
if (slice.trim()) out.push({ start: i + 1, end: Math.min(i + lines, all.length), text: slice.slice(0, 6000) });
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cosine(a: number[], b: number[]): number {
|
|
47
|
+
let dot = 0;
|
|
48
|
+
let na = 0;
|
|
49
|
+
let nb = 0;
|
|
50
|
+
for (let i = 0; i < a.length; i++) {
|
|
51
|
+
dot += a[i]! * b[i]!;
|
|
52
|
+
na += a[i]! * a[i]!;
|
|
53
|
+
nb += b[i]! * b[i]!;
|
|
54
|
+
}
|
|
55
|
+
const d = Math.sqrt(na) * Math.sqrt(nb);
|
|
56
|
+
return d ? dot / d : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sha1(s: string): string {
|
|
60
|
+
return createHash("sha1").update(s).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Indexable text files under root (relative paths), matching the tool suite's skip list. */
|
|
64
|
+
export function walkFiles(root: string, dir = root, out: string[] = []): string[] {
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
68
|
+
} catch {
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
if (e.name.startsWith(".") && e.name !== ".github") continue;
|
|
73
|
+
if (SKIP.has(e.name)) continue;
|
|
74
|
+
const p = join(dir, e.name);
|
|
75
|
+
if (e.isDirectory()) walkFiles(root, p, out);
|
|
76
|
+
else if (TEXT_EXT.test(e.name)) {
|
|
77
|
+
try {
|
|
78
|
+
if (statSync(p).size <= MAX_FILE_BYTES) out.push(relative(root, p).replace(/\\/g, "/"));
|
|
79
|
+
} catch {
|
|
80
|
+
/* unreadable — skip */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function embed(texts: string[], kind: "document" | "query" = "document"): Promise<number[][]> {
|
|
88
|
+
// nomic-embed models are trained asymmetric: prefixing queries/documents differently measurably
|
|
89
|
+
// improves retrieval (code stops losing to prose). Other models get the raw text.
|
|
90
|
+
const input = EMBED_MODEL.includes("nomic") ? texts.map((t) => `search_${kind}: ${t}`) : texts;
|
|
91
|
+
const res = await fetch(`${BACKEND}/embeddings`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${process.env.ADA_CLIENT_KEY ?? "dev"}` },
|
|
94
|
+
body: JSON.stringify({ model: EMBED_MODEL, input }),
|
|
95
|
+
signal: AbortSignal.timeout(60_000),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) throw new Error(`embeddings HTTP ${res.status}: ${(await res.text().catch(() => "")).slice(0, 200)} — is the backend up, and is "${EMBED_MODEL}" pulled in Ollama? (ollama pull nomic-embed-text, or set ADA_EMBED_MODEL)`);
|
|
98
|
+
const j = (await res.json()) as { data?: Array<{ index: number; embedding: number[] }> };
|
|
99
|
+
if (!j.data?.length) throw new Error("embeddings response had no data");
|
|
100
|
+
return [...j.data].sort((a, b) => a.index - b.index).map((d) => d.embedding);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function indexPath(root: string): string {
|
|
104
|
+
return resolve(root, ".ada", "index.json");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cache key includes an embedding-scheme tag: changing the model OR how text is prefixed makes old
|
|
108
|
+
// vectors incomparable, and both must force a rebuild.
|
|
109
|
+
const SCHEME = EMBED_MODEL.includes("nomic") ? `${EMBED_MODEL}#affix1` : EMBED_MODEL;
|
|
110
|
+
|
|
111
|
+
function loadIndex(root: string): Index {
|
|
112
|
+
try {
|
|
113
|
+
const idx = JSON.parse(readFileSync(indexPath(root), "utf8")) as Index;
|
|
114
|
+
if (idx.model === SCHEME) return idx; // scheme changed → vectors incomparable, rebuild
|
|
115
|
+
} catch {
|
|
116
|
+
/* no cache yet */
|
|
117
|
+
}
|
|
118
|
+
return { model: SCHEME, files: {} };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function saveIndex(root: string, idx: Index): void {
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(resolve(root, ".ada"), { recursive: true });
|
|
124
|
+
writeFileSync(indexPath(root), JSON.stringify(idx));
|
|
125
|
+
} catch {
|
|
126
|
+
/* cache is best-effort */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Bring the index up to date (embed new/changed files, drop deleted ones). Returns chunk count. */
|
|
131
|
+
export async function refreshIndex(root = process.cwd(), onProgress?: (msg: string) => void): Promise<number> {
|
|
132
|
+
const idx = loadIndex(root);
|
|
133
|
+
const files = walkFiles(root);
|
|
134
|
+
const live = new Set(files);
|
|
135
|
+
for (const known of Object.keys(idx.files)) if (!live.has(known)) delete idx.files[known];
|
|
136
|
+
|
|
137
|
+
const stale: Array<{ rel: string; hash: string; chunks: Chunk[] }> = [];
|
|
138
|
+
for (const rel of files) {
|
|
139
|
+
let text: string;
|
|
140
|
+
try {
|
|
141
|
+
text = readFileSync(resolve(root, rel), "utf8");
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const hash = sha1(text);
|
|
146
|
+
if (idx.files[rel]?.hash === hash) continue;
|
|
147
|
+
stale.push({ rel, hash, chunks: chunkText(text) });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let done = 0;
|
|
151
|
+
for (const f of stale) {
|
|
152
|
+
const vecs: number[][] = [];
|
|
153
|
+
for (let i = 0; i < f.chunks.length; i += 32) {
|
|
154
|
+
const batch = f.chunks.slice(i, i + 32);
|
|
155
|
+
vecs.push(...(await embed(batch.map((c) => c.text))));
|
|
156
|
+
}
|
|
157
|
+
idx.files[f.rel] = { hash: f.hash, chunks: f.chunks.map((c, i) => ({ start: c.start, end: c.end, vec: vecs[i]! })) };
|
|
158
|
+
done++;
|
|
159
|
+
if (onProgress && done % 20 === 0) onProgress(`indexed ${done}/${stale.length} changed files…`);
|
|
160
|
+
}
|
|
161
|
+
if (stale.length) saveIndex(root, idx);
|
|
162
|
+
return Object.values(idx.files).reduce((n, f) => n + f.chunks.length, 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface Hit {
|
|
166
|
+
file: string;
|
|
167
|
+
start: number;
|
|
168
|
+
end: number;
|
|
169
|
+
score: number;
|
|
170
|
+
snippet: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Top-k chunks most similar to the query. Refreshes the index first (incremental). */
|
|
174
|
+
export async function searchCodebase(query: string, k = 6, root = process.cwd()): Promise<Hit[]> {
|
|
175
|
+
await refreshIndex(root);
|
|
176
|
+
const idx = loadIndex(root);
|
|
177
|
+
const [qvec] = await embed([query], "query");
|
|
178
|
+
const hits: Hit[] = [];
|
|
179
|
+
for (const [rel, f] of Object.entries(idx.files)) {
|
|
180
|
+
for (const c of f.chunks) {
|
|
181
|
+
hits.push({ file: rel, start: c.start, end: c.end, score: cosine(qvec!, c.vec), snippet: "" });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
hits.sort((a, b) => b.score - a.score);
|
|
185
|
+
const top = hits.slice(0, k);
|
|
186
|
+
for (const h of top) {
|
|
187
|
+
try {
|
|
188
|
+
h.snippet = readFileSync(resolve(root, h.file), "utf8")
|
|
189
|
+
.split("\n")
|
|
190
|
+
.slice(h.start - 1, h.end)
|
|
191
|
+
.join("\n")
|
|
192
|
+
.slice(0, 1200);
|
|
193
|
+
} catch {
|
|
194
|
+
h.snippet = "(file changed since indexing)";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return top;
|
|
198
|
+
}
|
|
@@ -64,16 +64,28 @@ export function rankSkills(query: string, items: RankItem[], n = 5): { name: str
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* The single clearly-dominant skill for a query, or null when the match is weak/ambiguous.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
67
|
+
* Four gates, all required:
|
|
68
|
+
* 1. a score floor;
|
|
69
|
+
* 2. dominance over the runner-up;
|
|
70
|
+
* 3. an EXACT whole-token overlap with the skill NAME — the guard against prefix false positives
|
|
71
|
+
* ("make a powerpoint" prefix-matches "low-power" and even dominates, but "powerpoint" never
|
|
72
|
+
* equals the name token "power", so it's rejected);
|
|
73
|
+
* 4. query COVERAGE — strictly more than a third of the query's content tokens must EXACTLY match
|
|
74
|
+
* the skill's tokens. A conversational sentence that merely *contains* one skill-y keyword
|
|
75
|
+
* ("remember this: the secret word is X" → secret-scan, observed live) is about something else;
|
|
76
|
+
* a short task-like command ("describe the project" → project-overview) matches nearly all its
|
|
77
|
+
* tokens. Exact equality here on purpose — matches()'s 4-char prefixing is right for recall in
|
|
78
|
+
* rankSkills but inflates coverage ("remember" prefix-matches "remediate"), re-opening the leak.
|
|
71
79
|
*/
|
|
72
80
|
export function confidentSkill(query: string, items: RankItem[]): string | null {
|
|
73
81
|
const ranked = rankSkills(query, items, 2);
|
|
74
82
|
const top = ranked[0];
|
|
75
83
|
if (!top || top.score < 4) return null;
|
|
76
|
-
if (ranked[1] && top.score < ranked[1].score * 1.3) return null; // reject ties/near-ties
|
|
77
|
-
const q = new Set(tokenize(query));
|
|
78
|
-
|
|
84
|
+
if (ranked[1] && top.score < ranked[1].score * 1.3) return null; // reject ties/near-ties
|
|
85
|
+
const q = [...new Set(tokenize(query))];
|
|
86
|
+
if (!tokenize(top.name).some((t) => q.includes(t))) return null;
|
|
87
|
+
const item = items.find((it) => it.name === top.name);
|
|
88
|
+
const doc = new Set(tokenize(`${top.name} ${item?.description ?? ""} ${item?.category ?? ""}`));
|
|
89
|
+
const covered = q.filter((qt) => doc.has(qt)).length;
|
|
90
|
+
return covered / q.length > 1 / 3 ? top.name : null;
|
|
79
91
|
}
|
package/src/client/tools.ts
CHANGED
|
@@ -533,6 +533,31 @@ export const tools: Tool[] = [
|
|
|
533
533
|
return { output: (matches.join("\n") || "(no matches)") + more };
|
|
534
534
|
},
|
|
535
535
|
},
|
|
536
|
+
{
|
|
537
|
+
name: "codebase_search",
|
|
538
|
+
description:
|
|
539
|
+
"Semantic (meaning-based) search over the codebase — finds code by what it DOES, not by exact strings. Use when grep's literal matching won't work (\"where do we handle auth?\", \"how are sessions persisted?\"). First call indexes the repo (needs an Ollama embedding model, e.g. nomic-embed-text); later calls are incremental.",
|
|
540
|
+
parameters: {
|
|
541
|
+
type: "object",
|
|
542
|
+
properties: {
|
|
543
|
+
query: { type: "string", description: "What you're looking for, in plain words." },
|
|
544
|
+
k: { type: "number", description: "How many results (default 6)." },
|
|
545
|
+
},
|
|
546
|
+
required: ["query"],
|
|
547
|
+
additionalProperties: false,
|
|
548
|
+
},
|
|
549
|
+
needsApproval: false,
|
|
550
|
+
async run(args) {
|
|
551
|
+
try {
|
|
552
|
+
const { searchCodebase } = await import("./embed-index.ts"); // lazy — only pay for it when used
|
|
553
|
+
const hits = await searchCodebase(String(args.query), Math.min(Number(args.k) || 6, 20));
|
|
554
|
+
if (!hits.length) return { output: "No indexed content matched. Is the repo empty, or all files skipped?" };
|
|
555
|
+
return { output: hits.map((h) => `${h.file}:${h.start}-${h.end} (score ${h.score.toFixed(3)})\n${h.snippet}`).join("\n\n---\n\n") };
|
|
556
|
+
} catch (e) {
|
|
557
|
+
return { output: String(e instanceof Error ? e.message : e), isError: true };
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
},
|
|
536
561
|
{
|
|
537
562
|
name: "web_fetch",
|
|
538
563
|
description: "Fetch an http(s) URL and return its content as readable text (HTML is stripped to text). Use to read docs, articles, changelogs, or JSON APIs.",
|
package/src/sdk/index.ts
CHANGED
|
@@ -23,8 +23,8 @@ export interface PromptResult {
|
|
|
23
23
|
/** One event from an interactive session's prompt stream. */
|
|
24
24
|
export type SessionEvent =
|
|
25
25
|
| { type: "text"; delta: string }
|
|
26
|
-
| { type: "tool_call"; name: string; detail: string }
|
|
27
|
-
| { type: "tool_result"; name: string; output: string; isError: boolean }
|
|
26
|
+
| { type: "tool_call"; callId: string; name: string; detail: string }
|
|
27
|
+
| { type: "tool_result"; callId: string; name: string; output: string; isError: boolean }
|
|
28
28
|
| { type: "approval_request"; id: string; name: string; summary: string }
|
|
29
29
|
| { type: "done"; text: string; usage: string }
|
|
30
30
|
| { type: "error"; message: string };
|
|
@@ -36,10 +36,17 @@ export interface AdaSession {
|
|
|
36
36
|
readonly file: string;
|
|
37
37
|
/** True if this session's history was seeded from an existing transcript. */
|
|
38
38
|
readonly resumed: boolean;
|
|
39
|
-
/** Send a prompt; `onEvent` fires for every event as the turn streams. Resolves once it's done.
|
|
40
|
-
|
|
39
|
+
/** Send a prompt; `onEvent` fires for every event as the turn streams. Resolves once it's done.
|
|
40
|
+
* `images` are data: or https: URLs attached to the message. 409s if a turn is already running. */
|
|
41
|
+
prompt(text: string, onEvent: (e: SessionEvent) => void, opts?: { images?: string[] }): Promise<void>;
|
|
41
42
|
/** Answer a pending `approval_request` event by its id. */
|
|
42
43
|
approve(id: string, decision: "yes" | "all" | "no"): Promise<void>;
|
|
44
|
+
/** Cancel the currently-running turn (the "stop generating" button). Safe when idle. */
|
|
45
|
+
abort(): Promise<void>;
|
|
46
|
+
/** Queue a mid-turn user message — the agent folds it in between steps (the "steer" box). */
|
|
47
|
+
steer(text: string): Promise<void>;
|
|
48
|
+
/** Switch the session's permission mode: ask (gate every edit), plan (read-only), auto (run freely). */
|
|
49
|
+
setMode(mode: "ask" | "plan" | "auto"): Promise<void>;
|
|
43
50
|
/** Free the session's resources server-side. (Does not delete the on-disk transcript.) */
|
|
44
51
|
close(): Promise<void>;
|
|
45
52
|
}
|
|
@@ -110,11 +117,11 @@ export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
|
|
|
110
117
|
id: sessionId,
|
|
111
118
|
file,
|
|
112
119
|
resumed,
|
|
113
|
-
async prompt(text, onEvent) {
|
|
120
|
+
async prompt(text, onEvent, opts) {
|
|
114
121
|
const r = await fetch(`${url}/v1/sessions/${sessionId}/prompt`, {
|
|
115
122
|
method: "POST",
|
|
116
123
|
headers: { "content-type": "application/json" },
|
|
117
|
-
body: JSON.stringify({ text }),
|
|
124
|
+
body: JSON.stringify({ text, images: opts?.images }),
|
|
118
125
|
});
|
|
119
126
|
await streamSse(r, onEvent);
|
|
120
127
|
},
|
|
@@ -126,6 +133,26 @@ export function createClient(baseUrl = "http://localhost:8788"): AdaClient {
|
|
|
126
133
|
});
|
|
127
134
|
if (!r.ok) throw new Error(`ada ${r.status}: could not settle approval ${id}`);
|
|
128
135
|
},
|
|
136
|
+
async abort() {
|
|
137
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/abort`, { method: "POST" });
|
|
138
|
+
if (!r.ok) throw new Error(`ada ${r.status}: abort failed`);
|
|
139
|
+
},
|
|
140
|
+
async steer(text) {
|
|
141
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}/steer`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
144
|
+
body: JSON.stringify({ text }),
|
|
145
|
+
});
|
|
146
|
+
if (!r.ok) throw new Error(`ada ${r.status}: steer failed (is a turn running?)`);
|
|
147
|
+
},
|
|
148
|
+
async setMode(mode) {
|
|
149
|
+
const r = await fetch(`${url}/v1/sessions/${sessionId}`, {
|
|
150
|
+
method: "PATCH",
|
|
151
|
+
headers: { "content-type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ mode }),
|
|
153
|
+
});
|
|
154
|
+
if (!r.ok) throw new Error(`ada ${r.status}: could not set mode`);
|
|
155
|
+
},
|
|
129
156
|
async close() {
|
|
130
157
|
await fetch(`${url}/v1/sessions/${sessionId}`, { method: "DELETE" });
|
|
131
158
|
},
|
package/src/selfcheck.ts
CHANGED
|
@@ -293,6 +293,37 @@ async function main(): Promise<void> {
|
|
|
293
293
|
assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
// --- @codebase semantic search: pure parts (no network / no embedding model needed) ---
|
|
297
|
+
{
|
|
298
|
+
const { chunkText, cosine, walkFiles } = await import("./client/embed-index.ts");
|
|
299
|
+
const chunks = chunkText(Array.from({ length: 200 }, (_, i) => `line ${i + 1}`).join("\n"));
|
|
300
|
+
assert.equal(chunks.length, 3, "200 lines → 3 chunks of 80");
|
|
301
|
+
assert.equal(chunks[0]!.start, 1);
|
|
302
|
+
assert.equal(chunks[1]!.start, 81);
|
|
303
|
+
assert.equal(chunks[2]!.end, 200, "last chunk ends at the last line");
|
|
304
|
+
assert.equal(chunkText(" \n \n").length, 0, "whitespace-only text → no chunks");
|
|
305
|
+
assert.ok(chunkText(`x${"y".repeat(50_000)}`)[0]!.text.length <= 6000, "long-line chunks are char-capped");
|
|
306
|
+
assert.ok(Math.abs(cosine([1, 0], [1, 0]) - 1) < 1e-9, "cosine identical = 1");
|
|
307
|
+
assert.equal(cosine([1, 0], [0, 1]), 0, "cosine orthogonal = 0");
|
|
308
|
+
assert.equal(cosine([0, 0], [1, 1]), 0, "zero vector → 0, not NaN");
|
|
309
|
+
const walked = walkFiles(process.cwd());
|
|
310
|
+
assert.ok(walked.includes("src/selfcheck.ts"), "walkFiles finds source files");
|
|
311
|
+
assert.ok(!walked.some((f) => f.includes("node_modules")), "walkFiles skips node_modules");
|
|
312
|
+
// Offline: the tool must fail with a clear message, not hang or throw
|
|
313
|
+
const r = await toolByName.get("codebase_search")!.run({ query: "x" });
|
|
314
|
+
assert.ok(typeof r.output === "string", "codebase_search returns cleanly even when embeddings are unavailable");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// --- `ada --version` prints the version and exits WITHOUT auto-starting a backend ---
|
|
318
|
+
{
|
|
319
|
+
const { spawnSync } = await import("node:child_process");
|
|
320
|
+
const { fileURLToPath } = await import("node:url");
|
|
321
|
+
const bin = fileURLToPath(new URL("../bin/ada.mjs", import.meta.url));
|
|
322
|
+
const r = spawnSync(process.execPath, [bin, "--version"], { encoding: "utf8", timeout: 30_000 });
|
|
323
|
+
assert.match(r.stdout, /^ada \d+\.\d+\.\d+/, `--version prints the version (got: ${JSON.stringify(r.stdout)} / ${JSON.stringify(r.stderr?.slice(0, 120))})`);
|
|
324
|
+
assert.ok(!/starting ada-server/.test(r.stderr ?? ""), "--version must not auto-start the backend");
|
|
325
|
+
}
|
|
326
|
+
|
|
296
327
|
// --- autostart helpers: URL classification + /health derivation ---
|
|
297
328
|
{
|
|
298
329
|
const { isLocalBackend, healthUrl } = await import("./client/autostart.ts");
|
|
@@ -327,6 +358,14 @@ async function main(): Promise<void> {
|
|
|
327
358
|
assert.equal(await promise, "yes", "the waiting promise resolves with the decision");
|
|
328
359
|
assert.equal(registry.size, 0, "settle() clears the pending entry");
|
|
329
360
|
assert.equal(registry.settle("nope", "no"), false, "settle() on an unknown id returns false");
|
|
361
|
+
|
|
362
|
+
// abortAll: an aborted turn must not stay parked on unanswered approvals
|
|
363
|
+
const a1 = registry.wait();
|
|
364
|
+
const a2 = registry.wait();
|
|
365
|
+
assert.equal(registry.abortAll(), 2, "abortAll reports how many were pending");
|
|
366
|
+
assert.equal(await a1.promise, "no", "aborted approvals resolve to 'no'");
|
|
367
|
+
assert.equal(await a2.promise, "no", "all of them");
|
|
368
|
+
assert.equal(registry.size, 0, "abortAll clears the registry");
|
|
330
369
|
}
|
|
331
370
|
assert.equal((await toolByName.get("web_fetch")!.run({ url: "http://127.0.0.1/x" })).isError, true, "web_fetch blocks loopback (SSRF guard)");
|
|
332
371
|
|
|
@@ -381,6 +420,18 @@ async function main(): Promise<void> {
|
|
|
381
420
|
assert.equal(confidentSkill("draw an architecture diagram of this project", allSkills), "architecture-diagram", "confident: → architecture-diagram");
|
|
382
421
|
assert.equal(confidentSkill("make a powerpoint about Q3 results", allSkills), null, "precision guard: 'powerpoint' must NOT auto-apply 'low-power'");
|
|
383
422
|
assert.equal(confidentSkill("what is 2 + 2", allSkills), null, "ambiguous query → no auto-apply");
|
|
423
|
+
// Coverage gate — a long sentence merely CONTAINING a skill-y keyword must not auto-apply
|
|
424
|
+
// (observed live: this exact prompt pulled in secret-scan and derailed a small model).
|
|
425
|
+
assert.equal(
|
|
426
|
+
confidentSkill("Remember this fact for later: the secret word is PINEAPPLE97. Just confirm you will remember it, do not do anything else.", allSkills),
|
|
427
|
+
null,
|
|
428
|
+
"coverage gate: incidental 'secret' must NOT auto-apply secret-scan",
|
|
429
|
+
);
|
|
430
|
+
assert.equal(confidentSkill("I was talking to my friend about docker yesterday and she mentioned kubernetes", allSkills), null, "coverage gate: conversational mention of docker");
|
|
431
|
+
// Short rephrasings of the same incident — prefix-matching must not inflate coverage
|
|
432
|
+
// ("remember" prefix-matches "remediate"), and 1/3 exactly must not pass the strict gate.
|
|
433
|
+
assert.equal(confidentSkill("remember this: the secret word is X", allSkills), null, "coverage gate: short secret-word phrasing");
|
|
434
|
+
assert.equal(confidentSkill("remember the secret word", allSkills), null, "coverage gate: shortest secret-word phrasing");
|
|
384
435
|
// LOADED was set by registerSkillTool(allSkills) above, so routeConfident/skillBody resolve a body.
|
|
385
436
|
const applied = routeConfident("describe the project");
|
|
386
437
|
assert.ok(applied?.name === "project-overview" && /purpose/i.test(applied.body), "routeConfident returns the skill body to inject");
|
package/src/server/config.ts
CHANGED
|
@@ -23,9 +23,9 @@ export const PROVIDERS: Record<ProviderName, ProviderDef> = {
|
|
|
23
23
|
baseURL: process.env.DASHSCOPE_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
|
24
24
|
keyEnv: "DASHSCOPE_API_KEY",
|
|
25
25
|
},
|
|
26
|
-
// GitHub Copilot — OpenAI-compatible chat endpoint. COPILOT_API_KEY
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// GitHub Copilot — OpenAI-compatible chat endpoint. Set COPILOT_API_KEY (a Copilot bearer you
|
|
27
|
+
// already have) OR COPILOT_GITHUB_TOKEN (a GitHub token with Copilot access — the adapter runs
|
|
28
|
+
// the /copilot_internal/v2/token exchange and caches/refreshes the bearer; see copilot-token.ts).
|
|
29
29
|
copilot: { baseURL: process.env.COPILOT_BASE_URL ?? "https://api.githubcopilot.com", keyEnv: "COPILOT_API_KEY" },
|
|
30
30
|
// Cloudflare Workers AI / AI Gateway — OpenAI-compatible. Workers AI: set CLOUDFLARE_ACCOUNT_ID +
|
|
31
31
|
// CLOUDFLARE_API_TOKEN (default URL). AI Gateway: point CLOUDFLARE_BASE_URL at the gateway URL.
|
|
@@ -57,6 +57,8 @@ export function providerKey(p: ProviderName): string | undefined {
|
|
|
57
57
|
|
|
58
58
|
/** A provider is usable if it's keyless, its key env var is set, or a credential is stored. */
|
|
59
59
|
export function isConfigured(p: ProviderName): boolean {
|
|
60
|
+
// Copilot has a second way in: a GitHub token the adapter exchanges for a bearer (copilot-token.ts).
|
|
61
|
+
if (p === "copilot" && process.env.COPILOT_GITHUB_TOKEN) return true;
|
|
60
62
|
return PROVIDERS[p].keyEnv === "" || !!process.env[PROVIDERS[p].keyEnv] || !!getCredential(p);
|
|
61
63
|
}
|
|
62
64
|
|
package/src/server/index.ts
CHANGED
|
@@ -72,6 +72,25 @@ async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<vo
|
|
|
72
72
|
await adapterFor(provider).chat({ provider, model, body, res });
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Embeddings for @codebase semantic search — forwarded to the ollama provider's
|
|
76
|
+
* OpenAI-compatible endpoint (embedding models only live there for now). */
|
|
77
|
+
async function handleEmbeddings(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
78
|
+
const raw = await readBody(req);
|
|
79
|
+
try {
|
|
80
|
+
JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
return json(res, 400, { error: { message: "invalid JSON body" } });
|
|
83
|
+
}
|
|
84
|
+
const upstream = await fetch(`${PROVIDERS.ollama.baseURL}/embeddings`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
body: raw,
|
|
88
|
+
});
|
|
89
|
+
const text = await upstream.text();
|
|
90
|
+
res.writeHead(upstream.status, { "content-type": "application/json" });
|
|
91
|
+
res.end(text);
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
const server = createServer(async (req, res) => {
|
|
76
95
|
try {
|
|
77
96
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -91,6 +110,10 @@ const server = createServer(async (req, res) => {
|
|
|
91
110
|
if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
|
|
92
111
|
return await handleChat(req, res);
|
|
93
112
|
}
|
|
113
|
+
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
|
|
114
|
+
if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
|
|
115
|
+
return await handleEmbeddings(req, res);
|
|
116
|
+
}
|
|
94
117
|
return json(res, 404, { error: { message: "not found" } });
|
|
95
118
|
} catch (err) {
|
|
96
119
|
if (!res.headersSent) json(res, 500, { error: { message: err instanceof Error ? err.message : String(err) } });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// GitHub Copilot bearer-token exchange. Copilot's endpoint doesn't take a GitHub token directly —
|
|
2
|
+
// you exchange one at /copilot_internal/v2/token for a short-lived bearer. Ways in, in order:
|
|
3
|
+
// COPILOT_API_KEY — you already have a bearer (pasted from another tool); used as-is.
|
|
4
|
+
// COPILOT_GITHUB_TOKEN — a GitHub OAuth token with Copilot access; exchanged + cached here,
|
|
5
|
+
// refreshed automatically before expiry.
|
|
6
|
+
// stored credential — whatever `ada login`-style credential storage holds for copilot.
|
|
7
|
+
// Untested against a live subscription (needs one) — the exchange shape matches the documented
|
|
8
|
+
// flow used by editor integrations; failures surface as a normal upstream error to the client.
|
|
9
|
+
|
|
10
|
+
import { providerKey } from "../config.ts";
|
|
11
|
+
|
|
12
|
+
let cached: { token: string; expiresAt: number } | null = null;
|
|
13
|
+
|
|
14
|
+
/** Drop the cached bearer (e.g. after an upstream 401 — revoked token or clock skew). */
|
|
15
|
+
export function invalidateCopilotBearer(): void {
|
|
16
|
+
cached = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The bearer to send to api.githubcopilot.com, or "" if no Copilot credentials are configured. */
|
|
20
|
+
export async function copilotBearer(): Promise<string> {
|
|
21
|
+
const direct = process.env.COPILOT_API_KEY;
|
|
22
|
+
if (direct) return direct;
|
|
23
|
+
const gh = process.env.COPILOT_GITHUB_TOKEN;
|
|
24
|
+
if (!gh) return providerKey("copilot") ?? ""; // stored credential, or unconfigured
|
|
25
|
+
if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
|
|
26
|
+
const res = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
27
|
+
headers: { authorization: `token ${gh}`, "user-agent": "ada" },
|
|
28
|
+
signal: AbortSignal.timeout(10_000),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) throw new Error(`Copilot token exchange failed: HTTP ${res.status} — is COPILOT_GITHUB_TOKEN a GitHub token on an account with a Copilot subscription?`);
|
|
31
|
+
const j = (await res.json()) as { token?: string; expires_at?: number };
|
|
32
|
+
if (!j.token) throw new Error("Copilot token exchange returned no token");
|
|
33
|
+
cached = { token: j.token, expiresAt: (j.expires_at ?? Math.floor(Date.now() / 1000) + 600) * 1000 };
|
|
34
|
+
return cached.token;
|
|
35
|
+
}
|
|
@@ -4,17 +4,35 @@
|
|
|
4
4
|
// that format, this adapter just swaps in the upstream base URL + key and streams the
|
|
5
5
|
// response straight back — no translation needed.
|
|
6
6
|
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
7
8
|
import type { ProviderName } from "../../shared/types.ts";
|
|
8
9
|
import { PROVIDERS, providerKey } from "../config.ts";
|
|
9
10
|
import { SSE_HEADERS } from "../sse.ts";
|
|
10
11
|
import type { Adapter, ChatRequest } from "./adapter.ts";
|
|
12
|
+
import { copilotBearer, invalidateCopilotBearer } from "./copilot-token.ts";
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
const ADA_VERSION = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return (JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")) as { version?: string }).version ?? "0.0.0";
|
|
17
|
+
} catch {
|
|
18
|
+
return "0.0.0";
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
async function authHeaders(provider: ProviderName): Promise<Record<string, string>> {
|
|
23
|
+
// GitHub Copilot: bearer comes from the token exchange (or COPILOT_API_KEY), plus the
|
|
24
|
+
// editor-identification headers its endpoint requires.
|
|
25
|
+
if (provider === "copilot") {
|
|
26
|
+
const bearer = await copilotBearer();
|
|
27
|
+
return {
|
|
28
|
+
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
|
|
29
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
30
|
+
"Editor-Version": `ada/${ADA_VERSION}`,
|
|
31
|
+
"Editor-Plugin-Version": `ada/${ADA_VERSION}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
13
34
|
const key = providerKey(provider);
|
|
14
|
-
|
|
15
|
-
// GitHub Copilot's endpoint requires these editor-identification headers.
|
|
16
|
-
if (provider === "copilot") return { ...base, "Copilot-Integration-Id": "vscode-chat", "Editor-Version": "ada/0.0.1", "Editor-Plugin-Version": "ada/0.0.1" };
|
|
17
|
-
return base;
|
|
35
|
+
return key ? { authorization: `Bearer ${key}` } : {};
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
export const openAICompatAdapter: Adapter = {
|
|
@@ -28,7 +46,7 @@ export const openAICompatAdapter: Adapter = {
|
|
|
28
46
|
try {
|
|
29
47
|
upstream = await fetch(`${def.baseURL}/chat/completions`, {
|
|
30
48
|
method: "POST",
|
|
31
|
-
headers: { "content-type": "application/json", ...authHeaders(provider) },
|
|
49
|
+
headers: { "content-type": "application/json", ...(await authHeaders(provider)) },
|
|
32
50
|
body: JSON.stringify(outBody),
|
|
33
51
|
});
|
|
34
52
|
} catch (e) {
|
|
@@ -42,6 +60,8 @@ export const openAICompatAdapter: Adapter = {
|
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
if (!upstream.ok || !upstream.body) {
|
|
63
|
+
// A dead exchanged bearer (revoked / clock skew) would otherwise be reused until local expiry.
|
|
64
|
+
if (provider === "copilot" && upstream.status === 401) invalidateCopilotBearer();
|
|
45
65
|
const text = await upstream.text().catch(() => "");
|
|
46
66
|
res.writeHead(upstream.status || 502, { "content-type": "application/json" });
|
|
47
67
|
res.end(text || JSON.stringify({ error: { message: `upstream error ${upstream.status}` } }));
|
|
@@ -67,7 +87,7 @@ export const openAICompatAdapter: Adapter = {
|
|
|
67
87
|
async listModels(provider: ProviderName): Promise<string[]> {
|
|
68
88
|
const def = PROVIDERS[provider];
|
|
69
89
|
try {
|
|
70
|
-
const r = await fetch(`${def.baseURL}/models`, { headers: authHeaders(provider) });
|
|
90
|
+
const r = await fetch(`${def.baseURL}/models`, { headers: await authHeaders(provider) });
|
|
71
91
|
if (!r.ok) return [];
|
|
72
92
|
const j = (await r.json()) as { data?: Array<{ id?: unknown }> };
|
|
73
93
|
return (j.data ?? []).map((m) => m.id).filter((x): x is string => typeof x === "string");
|