@vellumai/assistant 0.10.6-staging.1 → 0.10.6
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/openapi.yaml +43 -6
- package/package.json +1 -1
- package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
- package/src/__tests__/attachments-store.test.ts +37 -1
- package/src/__tests__/attachments.test.ts +53 -0
- package/src/__tests__/channel-reply-delivery.test.ts +214 -5
- package/src/__tests__/config-loader-backfill.test.ts +16 -36
- package/src/__tests__/image-source-path-reinject.test.ts +155 -14
- package/src/__tests__/managed-profile-guard.test.ts +2 -0
- package/src/__tests__/mtime-cache.test.ts +265 -80
- package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
- package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
- package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
- package/src/agent/attachments.ts +55 -13
- package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
- package/src/cli/commands/platform/index.ts +86 -4
- package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
- package/src/cli/lib/diff-plugin.ts +7 -3
- package/src/cli/lib/inspect-plugin.ts +28 -10
- package/src/cli/lib/install-from-github.ts +69 -24
- package/src/cli/lib/ipc-params.ts +3 -3
- package/src/cli/lib/list-installed-plugins.ts +11 -5
- package/src/cli/lib/merge-plugin-tree.ts +15 -9
- package/src/cli/lib/plugin-fingerprint.ts +0 -6
- package/src/cli/lib/publish-plugin.ts +1 -1
- package/src/cli/lib/unknown-command.ts +3 -1
- package/src/cli/lib/upgrade-plugin.ts +2 -5
- package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
- package/src/config/seed-inference-profiles.ts +5 -4
- package/src/daemon/conversation-lifecycle.ts +37 -15
- package/src/daemon/conversation-messaging.ts +56 -28
- package/src/daemon/conversation.ts +2 -2
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
- package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
- package/src/hooks/hook-loader.ts +57 -144
- package/src/hooks/registry.ts +13 -9
- package/src/persistence/attachments-store.ts +2 -1
- package/src/persistence/conversation-crud.ts +24 -0
- package/src/plugin-api/types.ts +4 -1
- package/src/plugins/mtime-cache.ts +347 -33
- package/src/plugins/pipeline.ts +3 -2
- package/src/providers/__tests__/inference.test.ts +42 -31
- package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
- package/src/providers/inference/auth.ts +1 -2
- package/src/providers/inference/backfill.ts +7 -5
- package/src/providers/inference/connections.ts +54 -30
- package/src/runtime/channel-reply-delivery.ts +83 -24
- package/src/runtime/channel-retry-sweep.ts +4 -0
- package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
- package/src/runtime/routes/conversation-query-routes.ts +18 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
- package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
- package/src/runtime/routes/platform-routes.ts +82 -1
- package/src/runtime/slack-reply-session.ts +1 -1
- package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { repointManagedConnectionsToVellumMigration } from "../workspace/migrations/125-repoint-managed-connections-to-vellum.js";
|
|
13
|
+
|
|
14
|
+
let workspaceDir: string;
|
|
15
|
+
|
|
16
|
+
function writeConfig(data: Record<string, unknown>): void {
|
|
17
|
+
writeFileSync(
|
|
18
|
+
join(workspaceDir, "config.json"),
|
|
19
|
+
JSON.stringify(data, null, 2) + "\n",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readConfig(): Record<string, unknown> {
|
|
24
|
+
return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readLlm(): Record<string, unknown> {
|
|
28
|
+
return readConfig().llm as Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
workspaceDir = join(
|
|
33
|
+
tmpdir(),
|
|
34
|
+
`vellum-migration-125-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
35
|
+
);
|
|
36
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (existsSync(workspaceDir)) {
|
|
41
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("125-repoint-managed-connections-to-vellum migration", () => {
|
|
46
|
+
test("no-op when config.json does not exist", () => {
|
|
47
|
+
repointManagedConnectionsToVellumMigration.run(workspaceDir);
|
|
48
|
+
expect(existsSync(join(workspaceDir, "config.json"))).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("gracefully handles invalid JSON", () => {
|
|
52
|
+
writeFileSync(join(workspaceDir, "config.json"), "not-valid-json");
|
|
53
|
+
repointManagedConnectionsToVellumMigration.run(workspaceDir);
|
|
54
|
+
expect(readFileSync(join(workspaceDir, "config.json"), "utf-8")).toBe(
|
|
55
|
+
"not-valid-json",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("repoints legacy managed connections across default, profiles, and callSites", () => {
|
|
60
|
+
writeConfig({
|
|
61
|
+
llm: {
|
|
62
|
+
default: {
|
|
63
|
+
provider: "anthropic",
|
|
64
|
+
provider_connection: "anthropic-managed",
|
|
65
|
+
},
|
|
66
|
+
profiles: {
|
|
67
|
+
balanced: {
|
|
68
|
+
source: "managed",
|
|
69
|
+
provider: "fireworks",
|
|
70
|
+
provider_connection: "fireworks-managed",
|
|
71
|
+
},
|
|
72
|
+
"quality-optimized": {
|
|
73
|
+
source: "managed",
|
|
74
|
+
provider: "anthropic",
|
|
75
|
+
provider_connection: "anthropic-managed",
|
|
76
|
+
},
|
|
77
|
+
"os-beta": {
|
|
78
|
+
source: "managed",
|
|
79
|
+
provider: "together",
|
|
80
|
+
provider_connection: "together-managed",
|
|
81
|
+
},
|
|
82
|
+
// User-owned copy referencing a managed connection is repointed too.
|
|
83
|
+
"my-quality": {
|
|
84
|
+
source: "user",
|
|
85
|
+
provider: "openai",
|
|
86
|
+
provider_connection: "openai-managed",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
callSites: {
|
|
90
|
+
replySuggestion: {
|
|
91
|
+
provider: "gemini",
|
|
92
|
+
provider_connection: "gemini-managed",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
repointManagedConnectionsToVellumMigration.run(workspaceDir);
|
|
99
|
+
|
|
100
|
+
const llm = readLlm();
|
|
101
|
+
const profiles = llm.profiles as Record<string, Record<string, unknown>>;
|
|
102
|
+
const callSites = llm.callSites as Record<string, Record<string, unknown>>;
|
|
103
|
+
|
|
104
|
+
expect((llm.default as Record<string, unknown>).provider_connection).toBe(
|
|
105
|
+
"vellum",
|
|
106
|
+
);
|
|
107
|
+
expect(profiles.balanced.provider_connection).toBe("vellum");
|
|
108
|
+
expect(profiles["quality-optimized"].provider_connection).toBe("vellum");
|
|
109
|
+
expect(profiles["os-beta"].provider_connection).toBe("vellum");
|
|
110
|
+
expect(profiles["my-quality"].provider_connection).toBe("vellum");
|
|
111
|
+
expect(callSites.replySuggestion.provider_connection).toBe("vellum");
|
|
112
|
+
|
|
113
|
+
// The provider field is left intact — that is how vellum recovers the
|
|
114
|
+
// upstream at dispatch time.
|
|
115
|
+
expect(profiles.balanced.provider).toBe("fireworks");
|
|
116
|
+
expect(profiles["quality-optimized"].provider).toBe("anthropic");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("leaves non-managed connections (personal, openrouter) untouched", () => {
|
|
120
|
+
const original = {
|
|
121
|
+
llm: {
|
|
122
|
+
profiles: {
|
|
123
|
+
"custom-balanced": {
|
|
124
|
+
source: "user",
|
|
125
|
+
provider: "anthropic",
|
|
126
|
+
provider_connection: "anthropic-personal",
|
|
127
|
+
},
|
|
128
|
+
"my-router": {
|
|
129
|
+
source: "user",
|
|
130
|
+
provider: "openrouter",
|
|
131
|
+
provider_connection: "openrouter-personal",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
writeConfig(original);
|
|
137
|
+
repointManagedConnectionsToVellumMigration.run(workspaceDir);
|
|
138
|
+
expect(readConfig()).toEqual(original);
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/agent/attachments.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface MessageAttachmentInput {
|
|
|
8
8
|
data: string;
|
|
9
9
|
extractedText?: string;
|
|
10
10
|
filePath?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Path of the canonical copy in the conversation's attachments/ directory,
|
|
13
|
+
* known once the attachment is linked to a message. Name collisions are
|
|
14
|
+
* resolved with a -2/-3 suffix, so this may differ from `filename` — and
|
|
15
|
+
* from `filePath`, which is where the upload originally came from.
|
|
16
|
+
*/
|
|
17
|
+
storedPath?: string;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
export function attachmentsToContentBlocks(
|
|
@@ -44,26 +51,61 @@ export function attachmentsToContentBlocks(
|
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* (
|
|
54
|
+
* Annotation line for where an attached image originally came from (e.g. a
|
|
55
|
+
* desktop client path). Shared with the history-reload reinjection so the
|
|
56
|
+
* reloaded content block stays byte-identical to the one built at persist
|
|
57
|
+
* time (prefix-cache parity).
|
|
58
|
+
*/
|
|
59
|
+
export function formatImageSourceAnnotation(filePath: string): string {
|
|
60
|
+
return `[Attached image source: ${filePath}]`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Annotation line binding an attachment's user-facing filename to its
|
|
65
|
+
* canonical on-disk copy. The stored path is the one the model must use to
|
|
66
|
+
* read the attachment — the plain filename in the attachments/ directory may
|
|
67
|
+
* belong to an older upload with the same name. Shared with the
|
|
68
|
+
* history-reload reinjection (prefix-cache parity).
|
|
69
|
+
*/
|
|
70
|
+
export function formatStoredPathAnnotation(
|
|
71
|
+
filename: string,
|
|
72
|
+
storedPath: string,
|
|
73
|
+
): string {
|
|
74
|
+
return `[Attachment "${filename}" is stored at: ${storedPath}]`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Return a copy of the message with text annotations for attachment paths:
|
|
79
|
+
* source paths for images (where the file came from) and stored paths for
|
|
80
|
+
* any attachment whose canonical copy in the conversation's attachments/
|
|
81
|
+
* directory is known. The annotations are appended as a text content block
|
|
82
|
+
* so the LLM knows where to find the files on disk. The caller should
|
|
83
|
+
* persist the ORIGINAL message (without annotations) so the UI stays clean.
|
|
51
84
|
*/
|
|
52
85
|
export function enrichMessageWithSourcePaths(
|
|
53
86
|
message: Message,
|
|
54
87
|
attachments: MessageAttachmentInput[],
|
|
55
88
|
): Message {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (const a of attachments) {
|
|
91
|
+
if (a.mimeType.toLowerCase().startsWith("image/") && a.filePath) {
|
|
92
|
+
lines.push(formatImageSourceAnnotation(a.filePath));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const a of attachments) {
|
|
96
|
+
if (a.storedPath) {
|
|
97
|
+
lines.push(formatStoredPathAnnotation(a.filename, a.storedPath));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (lines.length === 0) {
|
|
101
|
+
return message;
|
|
102
|
+
}
|
|
64
103
|
|
|
65
104
|
return {
|
|
66
105
|
...message,
|
|
67
|
-
content: [
|
|
106
|
+
content: [
|
|
107
|
+
...message.content,
|
|
108
|
+
{ type: "text" as const, text: lines.join("\n") },
|
|
109
|
+
],
|
|
68
110
|
};
|
|
69
111
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mock state
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
let mockCalls: Array<[string, Record<string, unknown>]> = [];
|
|
10
|
+
let mockResponse: unknown = {
|
|
11
|
+
ok: true,
|
|
12
|
+
result: {
|
|
13
|
+
remaining: 42.17,
|
|
14
|
+
settled: 50,
|
|
15
|
+
pending: 7.83,
|
|
16
|
+
unit: "USD",
|
|
17
|
+
stale: false,
|
|
18
|
+
as_of: "2026-07-06T00:00:00.000Z",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Mocks
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
mock.module("../../../../ipc/cli-client.js", () => ({
|
|
27
|
+
cliIpcCall: async (method: string, params: Record<string, unknown>) => {
|
|
28
|
+
mockCalls.push([method, params]);
|
|
29
|
+
return mockResponse;
|
|
30
|
+
},
|
|
31
|
+
exitFromIpcResult: (_r: unknown, _cmd: unknown) => {
|
|
32
|
+
throw new Error("exitFromIpcResult called");
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const { registerPlatformCommand } = await import("../index.js");
|
|
37
|
+
|
|
38
|
+
function buildProgram(): Command {
|
|
39
|
+
const program = new Command();
|
|
40
|
+
program.exitOverride();
|
|
41
|
+
registerPlatformCommand(program);
|
|
42
|
+
return program;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function captureStdout(fn: () => Promise<void>): Promise<string[]> {
|
|
46
|
+
const chunks: string[] = [];
|
|
47
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
48
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
49
|
+
chunks.push(typeof chunk === "string" ? chunk : String(chunk));
|
|
50
|
+
return true;
|
|
51
|
+
}) as typeof process.stdout.write;
|
|
52
|
+
return fn()
|
|
53
|
+
.then(() => chunks)
|
|
54
|
+
.finally(() => {
|
|
55
|
+
process.stdout.write = origWrite;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Tests
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
describe("assistant platform credits", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
mockCalls = [];
|
|
66
|
+
mockResponse = {
|
|
67
|
+
ok: true,
|
|
68
|
+
result: {
|
|
69
|
+
remaining: 42.17,
|
|
70
|
+
settled: 50,
|
|
71
|
+
pending: 7.83,
|
|
72
|
+
unit: "USD",
|
|
73
|
+
stale: false,
|
|
74
|
+
as_of: "2026-07-06T00:00:00.000Z",
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
process.exitCode = 0;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("calls platform_credits and emits balance JSON with --json", async () => {
|
|
81
|
+
const out = await captureStdout(async () => {
|
|
82
|
+
const program = buildProgram();
|
|
83
|
+
await program.parseAsync([
|
|
84
|
+
"node",
|
|
85
|
+
"assistant",
|
|
86
|
+
"platform",
|
|
87
|
+
"credits",
|
|
88
|
+
"--json",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(mockCalls[0][0]).toBe("platform_credits");
|
|
93
|
+
|
|
94
|
+
const parsed = JSON.parse(out.join(""));
|
|
95
|
+
expect(parsed.remaining).toBe(42.17);
|
|
96
|
+
expect(parsed.settled).toBe(50);
|
|
97
|
+
expect(parsed.pending).toBe(7.83);
|
|
98
|
+
expect(parsed.unit).toBe("USD");
|
|
99
|
+
expect(parsed.stale).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("plain text mode does not emit JSON to stdout", async () => {
|
|
103
|
+
const out = await captureStdout(async () => {
|
|
104
|
+
const program = buildProgram();
|
|
105
|
+
await program.parseAsync(["node", "assistant", "platform", "credits"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Plain-text mode logs via log.info — verify writeOutput (JSON) was NOT called
|
|
109
|
+
expect(() => JSON.parse(out.join("").trim())).toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -25,6 +25,15 @@ interface PlatformStatusResult {
|
|
|
25
25
|
velayTunnel: { connected: boolean; publicUrl: string | null } | null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
interface PlatformCreditsResult {
|
|
29
|
+
remaining: number;
|
|
30
|
+
settled: number;
|
|
31
|
+
pending: number;
|
|
32
|
+
unit: "USD";
|
|
33
|
+
stale: boolean;
|
|
34
|
+
as_of: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
export function registerPlatformCommand(program: Command): void {
|
|
29
38
|
registerCommand(program, {
|
|
30
39
|
name: "platform",
|
|
@@ -47,6 +56,7 @@ assistants.
|
|
|
47
56
|
|
|
48
57
|
Examples:
|
|
49
58
|
$ assistant platform status --json
|
|
59
|
+
$ assistant platform credits --json
|
|
50
60
|
$ assistant platform connect
|
|
51
61
|
$ assistant platform disconnect
|
|
52
62
|
$ assistant platform callback-routes register --path webhooks/telegram --type telegram --json`,
|
|
@@ -95,7 +105,11 @@ Examples:
|
|
|
95
105
|
"platform_status",
|
|
96
106
|
{},
|
|
97
107
|
);
|
|
98
|
-
if (!r.ok)
|
|
108
|
+
if (!r.ok)
|
|
109
|
+
return exitFromIpcResult(
|
|
110
|
+
{ ok: false, error: r.error, statusCode: r.statusCode },
|
|
111
|
+
cmd,
|
|
112
|
+
);
|
|
99
113
|
|
|
100
114
|
const result = r.result!;
|
|
101
115
|
|
|
@@ -114,7 +128,9 @@ Examples:
|
|
|
114
128
|
log.info(
|
|
115
129
|
`Callback registration available: ${result.available ? "yes" : "no"}`,
|
|
116
130
|
);
|
|
117
|
-
log.info(
|
|
131
|
+
log.info(
|
|
132
|
+
`Organization ID: ${result.organizationId || "(not set)"}`,
|
|
133
|
+
);
|
|
118
134
|
log.info(`User ID: ${result.userId || "(not set)"}`);
|
|
119
135
|
if (result.velayTunnel !== null) {
|
|
120
136
|
const tunnelState = result.velayTunnel.connected
|
|
@@ -127,6 +143,64 @@ Examples:
|
|
|
127
143
|
}
|
|
128
144
|
});
|
|
129
145
|
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
// credits
|
|
148
|
+
// -----------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
platform
|
|
151
|
+
.command("credits")
|
|
152
|
+
.description("Show the organization's remaining credit balance")
|
|
153
|
+
.addHelpText(
|
|
154
|
+
"after",
|
|
155
|
+
`
|
|
156
|
+
Fetches the org's credit balance from the platform billing summary.
|
|
157
|
+
|
|
158
|
+
Fields:
|
|
159
|
+
remaining Effective balance (settled minus pending charges) in USD
|
|
160
|
+
settled On-ledger balance in USD
|
|
161
|
+
pending Estimated pending compute charges not yet settled, in USD
|
|
162
|
+
unit Balance currency (USD)
|
|
163
|
+
stale True when pending-charge data may be stale or unavailable
|
|
164
|
+
as_of When this balance was read (response receipt time)
|
|
165
|
+
|
|
166
|
+
Combine with 'assistant usage daily' to compute runway (remaining divided
|
|
167
|
+
by rolling daily average) and warn before credits run out.
|
|
168
|
+
|
|
169
|
+
Requires platform credentials (run 'assistant platform connect' first or
|
|
170
|
+
ensure VELLUM_PLATFORM_URL is set and credentials are stored).
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
$ assistant platform credits
|
|
174
|
+
$ assistant platform credits --json`,
|
|
175
|
+
)
|
|
176
|
+
.action(async (_opts: Record<string, unknown>, cmd: Command) => {
|
|
177
|
+
const r = await cliIpcCall<PlatformCreditsResult>(
|
|
178
|
+
"platform_credits",
|
|
179
|
+
{},
|
|
180
|
+
);
|
|
181
|
+
if (!r.ok)
|
|
182
|
+
return exitFromIpcResult(
|
|
183
|
+
{ ok: false, error: r.error, statusCode: r.statusCode },
|
|
184
|
+
cmd,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const result = r.result!;
|
|
188
|
+
|
|
189
|
+
if (shouldOutputJson(cmd)) {
|
|
190
|
+
writeOutput(cmd, result);
|
|
191
|
+
} else {
|
|
192
|
+
const staleNote = result.stale
|
|
193
|
+
? " (pending data may be stale)"
|
|
194
|
+
: "";
|
|
195
|
+
log.info(
|
|
196
|
+
`Remaining: $${result.remaining.toFixed(2)} ${result.unit} (as of ${result.as_of})${staleNote}`,
|
|
197
|
+
);
|
|
198
|
+
log.info(
|
|
199
|
+
`Settled: $${result.settled.toFixed(2)} Pending: $${result.pending.toFixed(2)}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
130
204
|
// -----------------------------------------------------------------------
|
|
131
205
|
// disconnect
|
|
132
206
|
// -----------------------------------------------------------------------
|
|
@@ -209,7 +283,11 @@ Examples:
|
|
|
209
283
|
}>("platform_callback_routes_register", {
|
|
210
284
|
body: { path: opts.path, type: opts.type },
|
|
211
285
|
});
|
|
212
|
-
if (!r.ok)
|
|
286
|
+
if (!r.ok)
|
|
287
|
+
return exitFromIpcResult(
|
|
288
|
+
{ ok: false, error: r.error, statusCode: r.statusCode },
|
|
289
|
+
cmd,
|
|
290
|
+
);
|
|
213
291
|
|
|
214
292
|
writeOutput(cmd, { ok: true, ...r.result });
|
|
215
293
|
|
|
@@ -248,7 +326,11 @@ Examples:
|
|
|
248
326
|
callback_url: string;
|
|
249
327
|
}>;
|
|
250
328
|
}>("platform_callback_routes_list", {});
|
|
251
|
-
if (!r.ok)
|
|
329
|
+
if (!r.ok)
|
|
330
|
+
return exitFromIpcResult(
|
|
331
|
+
{ ok: false, error: r.error, statusCode: r.statusCode },
|
|
332
|
+
cmd,
|
|
333
|
+
);
|
|
252
334
|
|
|
253
335
|
const routes = r.result!.routes;
|
|
254
336
|
|
|
@@ -23,7 +23,7 @@ function buildStreams(): Captured {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
describe("confirmPrompt", () => {
|
|
26
|
-
test(
|
|
26
|
+
test('returns "non-interactive" without reading when isTTY=false', async () => {
|
|
27
27
|
const { stdin, stdout, stderr, outChunks, errChunks } = buildStreams();
|
|
28
28
|
const result = await confirmPrompt({
|
|
29
29
|
question: "Delete? [y/N] ",
|
|
@@ -38,7 +38,7 @@ describe("confirmPrompt", () => {
|
|
|
38
38
|
expect(outChunks.join("")).toBe("");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
test(
|
|
41
|
+
test('returns "confirmed" for "y\\n"', async () => {
|
|
42
42
|
const { stdin, stdout, stderr } = buildStreams();
|
|
43
43
|
const pending = confirmPrompt({
|
|
44
44
|
question: "Delete? [y/N] ",
|
|
@@ -52,7 +52,7 @@ describe("confirmPrompt", () => {
|
|
|
52
52
|
expect(await pending).toBe("confirmed");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test(
|
|
55
|
+
test('returns "confirmed" for "yes\\n" (case-insensitive, whitespace-tolerant)', async () => {
|
|
56
56
|
const { stdin, stdout, stderr } = buildStreams();
|
|
57
57
|
const pending = confirmPrompt({
|
|
58
58
|
question: "Delete? [y/N] ",
|
|
@@ -66,7 +66,7 @@ describe("confirmPrompt", () => {
|
|
|
66
66
|
expect(await pending).toBe("confirmed");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test(
|
|
69
|
+
test('returns "denied" for "n\\n"', async () => {
|
|
70
70
|
const { stdin, stdout, stderr } = buildStreams();
|
|
71
71
|
const pending = confirmPrompt({
|
|
72
72
|
question: "Delete? [y/N] ",
|
|
@@ -80,7 +80,7 @@ describe("confirmPrompt", () => {
|
|
|
80
80
|
expect(await pending).toBe("denied");
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test(
|
|
83
|
+
test('returns "denied" for empty input (just Enter)', async () => {
|
|
84
84
|
const { stdin, stdout, stderr } = buildStreams();
|
|
85
85
|
const pending = confirmPrompt({
|
|
86
86
|
question: "Delete? [y/N] ",
|
|
@@ -94,7 +94,7 @@ describe("confirmPrompt", () => {
|
|
|
94
94
|
expect(await pending).toBe("denied");
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
test(
|
|
97
|
+
test('returns "denied" on EOF without any data (regression: would have hung)', async () => {
|
|
98
98
|
const { stdin, stdout, stderr } = buildStreams();
|
|
99
99
|
const pending = confirmPrompt({
|
|
100
100
|
question: "Delete? [y/N] ",
|
|
@@ -108,7 +108,7 @@ describe("confirmPrompt", () => {
|
|
|
108
108
|
expect(await pending).toBe("denied");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
test(
|
|
111
|
+
test('returns "denied" on EOF after a partial line with no newline', async () => {
|
|
112
112
|
const { stdin, stdout, stderr } = buildStreams();
|
|
113
113
|
const pending = confirmPrompt({
|
|
114
114
|
question: "Delete? [y/N] ",
|
|
@@ -131,7 +131,7 @@ describe("confirmPrompt", () => {
|
|
|
131
131
|
test("writes the question to stdout when interactive", async () => {
|
|
132
132
|
const { stdin, stdout, stderr, outChunks } = buildStreams();
|
|
133
133
|
const pending = confirmPrompt({
|
|
134
|
-
question:
|
|
134
|
+
question: 'Delete plugin "foo"? [y/N] ',
|
|
135
135
|
isTTY: true,
|
|
136
136
|
refuseNonInteractiveMessage: "n/a",
|
|
137
137
|
stdin,
|
|
@@ -140,7 +140,7 @@ describe("confirmPrompt", () => {
|
|
|
140
140
|
});
|
|
141
141
|
stdin.write("n\n");
|
|
142
142
|
await pending;
|
|
143
|
-
expect(outChunks.join("")).toContain(
|
|
143
|
+
expect(outChunks.join("")).toContain('Delete plugin "foo"? [y/N] ');
|
|
144
144
|
});
|
|
145
145
|
|
|
146
146
|
test("treats stray garbage as denial, never confirmation", async () => {
|
|
@@ -38,6 +38,7 @@ import { join } from "node:path";
|
|
|
38
38
|
|
|
39
39
|
import { createTwoFilesPatch } from "diff";
|
|
40
40
|
|
|
41
|
+
import { PRESERVED_ENTRIES } from "../../plugins/plugin-tree-walk.js";
|
|
41
42
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
42
43
|
import {
|
|
43
44
|
DEFAULT_PLUGIN_REF,
|
|
@@ -55,7 +56,6 @@ import {
|
|
|
55
56
|
compareFingerprint,
|
|
56
57
|
computeFingerprint,
|
|
57
58
|
type Fingerprint,
|
|
58
|
-
PRESERVED_ENTRIES,
|
|
59
59
|
} from "./plugin-fingerprint.js";
|
|
60
60
|
import { PluginNotInstalledError } from "./uninstall-plugin.js";
|
|
61
61
|
|
|
@@ -141,7 +141,9 @@ export interface DiffPluginDeps {
|
|
|
141
141
|
function isBinary(buf: Buffer): boolean {
|
|
142
142
|
const len = Math.min(buf.length, 8000);
|
|
143
143
|
for (let i = 0; i < len; i++) {
|
|
144
|
-
if (buf[i] === 0)
|
|
144
|
+
if (buf[i] === 0) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
145
147
|
}
|
|
146
148
|
return false;
|
|
147
149
|
}
|
|
@@ -214,7 +216,9 @@ function baselineContent(
|
|
|
214
216
|
recorded: Fingerprint,
|
|
215
217
|
materialized: Fingerprint,
|
|
216
218
|
): FileContent | null {
|
|
217
|
-
if (materialized.files[path] !== recorded.files[path])
|
|
219
|
+
if (materialized.files[path] !== recorded.files[path]) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
218
222
|
return readContent(join(baselineRoot, path));
|
|
219
223
|
}
|
|
220
224
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* is a thin wrapper that supplies production deps and formats the result.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { PRESERVED_ENTRIES } from "../../plugins/plugin-tree-walk.js";
|
|
19
20
|
import {
|
|
20
21
|
DEFAULT_PLUGIN_REF,
|
|
21
22
|
type FetchLike,
|
|
@@ -30,7 +31,6 @@ import {
|
|
|
30
31
|
import {
|
|
31
32
|
compareFingerprint,
|
|
32
33
|
type FingerprintComparison,
|
|
33
|
-
PRESERVED_ENTRIES,
|
|
34
34
|
} from "./plugin-fingerprint.js";
|
|
35
35
|
import {
|
|
36
36
|
fetchMarketplaceEntries,
|
|
@@ -238,15 +238,25 @@ async function fetchCommitDate(
|
|
|
238
238
|
"User-Agent": "vellum-assistant-cli",
|
|
239
239
|
},
|
|
240
240
|
});
|
|
241
|
-
if (!res.ok)
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
242
244
|
const json: unknown = JSON.parse(await res.text());
|
|
243
|
-
if (typeof json !== "object" || json === null)
|
|
245
|
+
if (typeof json !== "object" || json === null) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
244
248
|
const commit = (json as Record<string, unknown>).commit;
|
|
245
|
-
if (typeof commit !== "object" || commit === null)
|
|
249
|
+
if (typeof commit !== "object" || commit === null) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
246
252
|
const committer = (commit as Record<string, unknown>).committer;
|
|
247
|
-
if (typeof committer !== "object" || committer === null)
|
|
253
|
+
if (typeof committer !== "object" || committer === null) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
248
256
|
const date = (committer as Record<string, unknown>).date;
|
|
249
|
-
if (typeof date !== "string")
|
|
257
|
+
if (typeof date !== "string") {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
250
260
|
const ms = Date.parse(date);
|
|
251
261
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
252
262
|
} catch {
|
|
@@ -312,10 +322,18 @@ function classify(
|
|
|
312
322
|
remote: PluginRemoteInfo | null,
|
|
313
323
|
remoteError: string | null,
|
|
314
324
|
): PluginUpdateStatus {
|
|
315
|
-
if (!installed)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (!
|
|
325
|
+
if (!installed) {
|
|
326
|
+
return "not-installed";
|
|
327
|
+
}
|
|
328
|
+
if (remoteError && !remote) {
|
|
329
|
+
return "remote-unavailable";
|
|
330
|
+
}
|
|
331
|
+
if (!remote) {
|
|
332
|
+
return "not-in-marketplace";
|
|
333
|
+
}
|
|
334
|
+
if (!local?.commit) {
|
|
335
|
+
return "unknown-provenance";
|
|
336
|
+
}
|
|
319
337
|
return local.commit.toLowerCase() === remote.commit.toLowerCase()
|
|
320
338
|
? "up-to-date"
|
|
321
339
|
: "update-available";
|