@desplega.ai/agent-swarm 1.98.0 → 1.98.1
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.json +1 -1
- package/package.json +3 -3
- package/src/be/modelsdev-cache.ts +5 -0
- package/src/be/pricing-refresh.ts +189 -0
- package/src/be/seed-pricing.ts +5 -3
- package/src/commands/profile-sync.ts +83 -17
- package/src/commands/runner.ts +23 -1
- package/src/hooks/hook.ts +21 -5
- package/src/http/index.ts +2 -0
- package/src/providers/pricing-sources.md +27 -9
- package/src/server.ts +2 -0
- package/src/slack/blocks.ts +58 -12
- package/src/slack/responses.ts +35 -12
- package/src/slack/watcher.ts +28 -7
- package/src/tests/pricing-refresh.test.ts +156 -0
- package/src/tests/profile-sync.test.ts +186 -0
- package/src/tests/slack-blocks.test.ts +48 -1
- package/src/types.ts +2 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.98.
|
|
5
|
+
"version": "1.98.1",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "1.98.
|
|
3
|
+
"version": "1.98.1",
|
|
4
4
|
"description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "desplega.sh <contact@desplega.sh>",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"devDependencies": {
|
|
96
96
|
"@biomejs/biome": "^2.3.10",
|
|
97
97
|
"@faker-js/faker": "^10.4.0",
|
|
98
|
-
"@opencode-ai/plugin": "1.
|
|
98
|
+
"@opencode-ai/plugin": "1.17.4",
|
|
99
99
|
"@types/bun": "latest"
|
|
100
100
|
},
|
|
101
101
|
"peerDependencies": {
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"@linear/sdk": "^77.0.0",
|
|
119
119
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
120
120
|
"@openai/codex-sdk": "^0.139.0",
|
|
121
|
-
"@opencode-ai/sdk": "^1.
|
|
121
|
+
"@opencode-ai/sdk": "^1.17.4",
|
|
122
122
|
"@openfort/openfort-node": "^0.9.1",
|
|
123
123
|
"@opentelemetry/api": "^1.9.1",
|
|
124
124
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
|
@@ -24,6 +24,11 @@ export const MODELSDEV_CACHE_PATH = path.join("src", "be", "modelsdev-cache.json
|
|
|
24
24
|
/**
|
|
25
25
|
* Resolve the vendored models.dev cache from source checkouts and compiled
|
|
26
26
|
* Docker images. The API image copies the snapshot to `/app/src/be/...`.
|
|
27
|
+
*
|
|
28
|
+
* This file is now fallback-only for pricing freshness: boot seeding uses it
|
|
29
|
+
* when the DB is empty or models.dev is unavailable, while
|
|
30
|
+
* `src/be/pricing-refresh.ts` owns live price updates. The UI model picker
|
|
31
|
+
* still imports the same snapshot for names, labels, and context windows.
|
|
27
32
|
*/
|
|
28
33
|
export function loadModelsDevCache(): ModelsDevCache | null {
|
|
29
34
|
const explicitPath = process.env.MODELSDEV_CACHE_PATH;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
2
|
+
import {
|
|
3
|
+
createLogEntry,
|
|
4
|
+
getActivePricingRow,
|
|
5
|
+
getDb,
|
|
6
|
+
type InsertPricingRowInput,
|
|
7
|
+
insertPricingRow,
|
|
8
|
+
} from "./db";
|
|
9
|
+
import type { ModelsDevCache } from "./modelsdev-cache";
|
|
10
|
+
import { buildModelsDevSeedRows, type PricingSeedRow } from "./seed-pricing";
|
|
11
|
+
|
|
12
|
+
const MODELSDEV_API_URL = "https://models.dev/api.json";
|
|
13
|
+
export const PRICING_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
let lastETag: string | null = null;
|
|
16
|
+
let refreshLoopStarted = false;
|
|
17
|
+
|
|
18
|
+
interface RefreshPricingOptions {
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
now?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PricingRefreshResult {
|
|
24
|
+
status: "refreshed" | "not_modified";
|
|
25
|
+
candidateRows: number;
|
|
26
|
+
inserted: number;
|
|
27
|
+
unchanged: number;
|
|
28
|
+
pruned: number;
|
|
29
|
+
etag?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function logPricingRefresh(message: string): void {
|
|
33
|
+
console.log(scrubSecrets(`[pricing-refresh] ${message}`));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function logPricingRefreshError(message: string, err: unknown): void {
|
|
37
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
38
|
+
console.warn(scrubSecrets(`[pricing-refresh] ${message}: ${detail}`));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function insertChangedPricingRows(
|
|
42
|
+
rows: PricingSeedRow[],
|
|
43
|
+
now: number,
|
|
44
|
+
): {
|
|
45
|
+
inserted: number;
|
|
46
|
+
unchanged: number;
|
|
47
|
+
} {
|
|
48
|
+
let inserted = 0;
|
|
49
|
+
let unchanged = 0;
|
|
50
|
+
|
|
51
|
+
const tx = getDb().transaction((seedRows: PricingSeedRow[]) => {
|
|
52
|
+
for (const row of seedRows) {
|
|
53
|
+
const existing = getActivePricingRow(row.provider, row.model, row.tokenClass, now);
|
|
54
|
+
if (existing?.pricePerMillionUsd === row.pricePerMillionUsd) {
|
|
55
|
+
unchanged += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const input: InsertPricingRowInput = {
|
|
60
|
+
...row,
|
|
61
|
+
effectiveFrom: now,
|
|
62
|
+
};
|
|
63
|
+
insertPricingRow(input);
|
|
64
|
+
inserted += 1;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
tx(rows);
|
|
69
|
+
return { inserted, unchanged };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function prunePricingHistory(keepLatest = 2): number {
|
|
73
|
+
const result = getDb()
|
|
74
|
+
.prepare(
|
|
75
|
+
`DELETE FROM pricing
|
|
76
|
+
WHERE rowid IN (
|
|
77
|
+
SELECT rowid
|
|
78
|
+
FROM (
|
|
79
|
+
SELECT
|
|
80
|
+
rowid,
|
|
81
|
+
ROW_NUMBER() OVER (
|
|
82
|
+
PARTITION BY provider, model, token_class
|
|
83
|
+
ORDER BY effective_from DESC
|
|
84
|
+
) AS rn
|
|
85
|
+
FROM pricing
|
|
86
|
+
)
|
|
87
|
+
WHERE rn > ?
|
|
88
|
+
)`,
|
|
89
|
+
)
|
|
90
|
+
.run(keepLatest);
|
|
91
|
+
return result.changes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function auditPricingRefresh(result: PricingRefreshResult): void {
|
|
95
|
+
try {
|
|
96
|
+
createLogEntry({
|
|
97
|
+
eventType: "pricing.refresh",
|
|
98
|
+
newValue: `${result.status}: inserted=${result.inserted}; unchanged=${result.unchanged}; pruned=${result.pruned}`,
|
|
99
|
+
metadata: {
|
|
100
|
+
status: result.status,
|
|
101
|
+
candidateRows: result.candidateRows,
|
|
102
|
+
inserted: result.inserted,
|
|
103
|
+
unchanged: result.unchanged,
|
|
104
|
+
pruned: result.pruned,
|
|
105
|
+
etag: result.etag,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logPricingRefreshError("audit log write failed", err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function auditPricingRefreshFailure(err: unknown): void {
|
|
114
|
+
try {
|
|
115
|
+
createLogEntry({
|
|
116
|
+
eventType: "pricing.refresh.failed",
|
|
117
|
+
newValue: scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
118
|
+
});
|
|
119
|
+
} catch (auditErr) {
|
|
120
|
+
logPricingRefreshError("failure audit log write failed", auditErr);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function refreshPricingFromModelsDev(
|
|
125
|
+
opts: RefreshPricingOptions = {},
|
|
126
|
+
): Promise<PricingRefreshResult> {
|
|
127
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
128
|
+
const now = opts.now ?? Date.now();
|
|
129
|
+
const headers: Record<string, string> = lastETag ? { "If-None-Match": lastETag } : {};
|
|
130
|
+
|
|
131
|
+
const response = await fetchImpl(MODELSDEV_API_URL, { headers });
|
|
132
|
+
if (response.status === 304) {
|
|
133
|
+
const result: PricingRefreshResult = {
|
|
134
|
+
status: "not_modified",
|
|
135
|
+
candidateRows: 0,
|
|
136
|
+
inserted: 0,
|
|
137
|
+
unchanged: 0,
|
|
138
|
+
pruned: 0,
|
|
139
|
+
etag: lastETag ?? undefined,
|
|
140
|
+
};
|
|
141
|
+
auditPricingRefresh(result);
|
|
142
|
+
logPricingRefresh("models.dev returned 304; pricing rows unchanged");
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
throw new Error(`models.dev returned HTTP ${response.status}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const cache = (await response.json()) as ModelsDevCache;
|
|
150
|
+
const etag = response.headers.get("etag");
|
|
151
|
+
const rows = buildModelsDevSeedRows(cache);
|
|
152
|
+
const { inserted, unchanged } = insertChangedPricingRows(rows, now);
|
|
153
|
+
const pruned = prunePricingHistory(2);
|
|
154
|
+
lastETag = etag;
|
|
155
|
+
|
|
156
|
+
const result: PricingRefreshResult = {
|
|
157
|
+
status: "refreshed",
|
|
158
|
+
candidateRows: rows.length,
|
|
159
|
+
inserted,
|
|
160
|
+
unchanged,
|
|
161
|
+
pruned,
|
|
162
|
+
etag: lastETag ?? undefined,
|
|
163
|
+
};
|
|
164
|
+
auditPricingRefresh(result);
|
|
165
|
+
logPricingRefresh(
|
|
166
|
+
`refreshed ${rows.length} candidate row(s); inserted=${inserted}; unchanged=${unchanged}; pruned=${pruned}`,
|
|
167
|
+
);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function runPricingRefreshSafely(): Promise<void> {
|
|
172
|
+
try {
|
|
173
|
+
await refreshPricingFromModelsDev();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
logPricingRefreshError("refresh failed", err);
|
|
176
|
+
auditPricingRefreshFailure(err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function startPricingRefreshLoop(): void {
|
|
181
|
+
if (refreshLoopStarted) return;
|
|
182
|
+
refreshLoopStarted = true;
|
|
183
|
+
|
|
184
|
+
void runPricingRefreshSafely();
|
|
185
|
+
const interval = setInterval(() => {
|
|
186
|
+
void runPricingRefreshSafely();
|
|
187
|
+
}, PRICING_REFRESH_INTERVAL_MS);
|
|
188
|
+
interval.unref?.();
|
|
189
|
+
}
|
package/src/be/seed-pricing.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Phase 2 of the cost-tracking plan — seed the `pricing` table at server boot.
|
|
3
3
|
*
|
|
4
4
|
* The vendored models.dev snapshot at `src/be/modelsdev-cache.json` is the
|
|
5
|
-
*
|
|
5
|
+
* cold-start fallback for per-token rates. Runtime freshness is owned by
|
|
6
|
+
* `src/be/pricing-refresh.ts`, which fetches models.dev after boot and inserts
|
|
7
|
+
* newer effective rows when prices change. We project both sources into rows keyed by
|
|
6
8
|
* `(provider, model, token_class)` so the recompute path in
|
|
7
9
|
* `src/http/session-data.ts` can rebuild USD from tokens regardless of which
|
|
8
10
|
* adapter wrote the row.
|
|
@@ -74,7 +76,7 @@ const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
|
|
|
74
76
|
haiku: "claude-haiku-4-5",
|
|
75
77
|
};
|
|
76
78
|
|
|
77
|
-
interface PricingSeedRow {
|
|
79
|
+
export interface PricingSeedRow {
|
|
78
80
|
provider: PricingProvider;
|
|
79
81
|
model: string;
|
|
80
82
|
tokenClass: PricingTokenClass;
|
|
@@ -127,7 +129,7 @@ function projectCostBlock(
|
|
|
127
129
|
* "what the adapter writes for `model`" and "what models.dev keys by" is
|
|
128
130
|
* explicit and auditable.
|
|
129
131
|
*/
|
|
130
|
-
function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[] {
|
|
132
|
+
export function buildModelsDevSeedRows(cache: ModelsDevCache): PricingSeedRow[] {
|
|
131
133
|
const rows: PricingSeedRow[] = [];
|
|
132
134
|
|
|
133
135
|
// ---- Anthropic / claude family ----------------------------------------
|
|
@@ -34,6 +34,42 @@ export const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
|
|
|
34
34
|
export const TOOLS_MD_PATH = "/workspace/TOOLS.md";
|
|
35
35
|
export const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
|
|
36
36
|
export const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
|
|
37
|
+
|
|
38
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Identity-file baseline hashes — prevents session-end sync from clobbering
|
|
40
|
+
// DB-side edits made by Lead (via update-profile) during a running session.
|
|
41
|
+
//
|
|
42
|
+
// Flow:
|
|
43
|
+
// 1. Runner writes DB content → /workspace/*.md at session start.
|
|
44
|
+
// 2. Runner records SHA-256 hashes of the written content (the "baselines").
|
|
45
|
+
// 3. At session end, sync compares current file hash against its baseline.
|
|
46
|
+
// - Hash matches → file untouched by the agent → skip sync (preserves
|
|
47
|
+
// any DB-side edits Lead made during the session).
|
|
48
|
+
// - Hash differs → agent modified the file → sync it back to DB.
|
|
49
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
50
|
+
export const IDENTITY_BASELINES_PATH = "/tmp/identity-baselines.json";
|
|
51
|
+
|
|
52
|
+
export type IdentityBaselines = Record<string, string>;
|
|
53
|
+
|
|
54
|
+
export function contentSha256(content: string): string {
|
|
55
|
+
return new Bun.CryptoHasher("sha256").update(content).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function writeIdentityBaselines(baselines: IdentityBaselines): Promise<void> {
|
|
59
|
+
await Bun.write(IDENTITY_BASELINES_PATH, JSON.stringify(baselines));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readIdentityBaselines(
|
|
63
|
+
readFile: FileReader = readFileIfExists,
|
|
64
|
+
): Promise<IdentityBaselines | null> {
|
|
65
|
+
try {
|
|
66
|
+
const raw = await readFile(IDENTITY_BASELINES_PATH);
|
|
67
|
+
if (!raw) return null;
|
|
68
|
+
return JSON.parse(raw) as IdentityBaselines;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
37
73
|
/**
|
|
38
74
|
* Claude Code's personal-file CLAUDE.md path. This is what the Claude plugin
|
|
39
75
|
* Stop hook reads and owns — the runner only uses it as a backstop for an
|
|
@@ -135,18 +171,27 @@ export function extractSetupScriptContent(raw: string): string | null {
|
|
|
135
171
|
* the trim / max-length guards and the SOUL/IDENTITY min-length guard. Returns
|
|
136
172
|
* an empty object when nothing is syncable (callers should skip the POST).
|
|
137
173
|
* `undefined` inputs mean the file was absent.
|
|
174
|
+
*
|
|
175
|
+
* When `baselines` is provided, skips any field whose content hash matches the
|
|
176
|
+
* baseline (i.e. the file was not modified during the session). This prevents
|
|
177
|
+
* session-end sync from clobbering DB-side edits made by Lead.
|
|
138
178
|
*/
|
|
139
|
-
export function buildIdentityPayload(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
179
|
+
export function buildIdentityPayload(
|
|
180
|
+
files: {
|
|
181
|
+
soulMd?: string;
|
|
182
|
+
identityMd?: string;
|
|
183
|
+
toolsMd?: string;
|
|
184
|
+
heartbeatMd?: string;
|
|
185
|
+
},
|
|
186
|
+
baselines?: IdentityBaselines | null,
|
|
187
|
+
): Record<string, string> {
|
|
145
188
|
const updates: Record<string, string> = {};
|
|
146
189
|
|
|
147
190
|
if (files.soulMd !== undefined) {
|
|
148
191
|
const content = files.soulMd;
|
|
149
|
-
if (
|
|
192
|
+
if (baselines?.soulMd && contentSha256(content) === baselines.soulMd) {
|
|
193
|
+
// File unchanged during session — skip to preserve Lead's DB edits
|
|
194
|
+
} else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
150
195
|
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
151
196
|
console.error(
|
|
152
197
|
`[profile-sync] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
@@ -159,7 +204,9 @@ export function buildIdentityPayload(files: {
|
|
|
159
204
|
|
|
160
205
|
if (files.identityMd !== undefined) {
|
|
161
206
|
const content = files.identityMd;
|
|
162
|
-
if (
|
|
207
|
+
if (baselines?.identityMd && contentSha256(content) === baselines.identityMd) {
|
|
208
|
+
// File unchanged during session — skip to preserve Lead's DB edits
|
|
209
|
+
} else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
163
210
|
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
164
211
|
console.error(
|
|
165
212
|
`[profile-sync] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
@@ -172,14 +219,18 @@ export function buildIdentityPayload(files: {
|
|
|
172
219
|
|
|
173
220
|
if (files.toolsMd !== undefined) {
|
|
174
221
|
const content = files.toolsMd;
|
|
175
|
-
if (
|
|
222
|
+
if (baselines?.toolsMd && contentSha256(content) === baselines.toolsMd) {
|
|
223
|
+
// File unchanged during session — skip
|
|
224
|
+
} else if (content.trim() && content.length <= MAX_FILE_LENGTH) {
|
|
176
225
|
updates.toolsMd = content;
|
|
177
226
|
}
|
|
178
227
|
}
|
|
179
228
|
|
|
180
229
|
if (files.heartbeatMd !== undefined) {
|
|
181
230
|
const content = files.heartbeatMd;
|
|
182
|
-
if (content
|
|
231
|
+
if (baselines?.heartbeatMd && contentSha256(content) === baselines.heartbeatMd) {
|
|
232
|
+
// File unchanged during session — skip
|
|
233
|
+
} else if (content.length <= MAX_FILE_LENGTH) {
|
|
183
234
|
updates.heartbeatMd = content;
|
|
184
235
|
}
|
|
185
236
|
}
|
|
@@ -205,6 +256,12 @@ async function readFileIfExists(path: string): Promise<string | undefined> {
|
|
|
205
256
|
* Collect the profile-update POST bodies to send. Each entry is one POST.
|
|
206
257
|
* `fields` selects which groups to include. The file reader is injectable so
|
|
207
258
|
* the field-selection / guard logic can be unit-tested without touching the FS.
|
|
259
|
+
*
|
|
260
|
+
* When `changeSource` is `"session_sync"`, loads baseline hashes written at
|
|
261
|
+
* session start and skips identity fields whose content hasn't changed — this
|
|
262
|
+
* prevents blind-overwriting DB-side edits made by Lead during the session.
|
|
263
|
+
* On-edit syncs (`"self_edit"`) bypass baselines entirely since the agent
|
|
264
|
+
* explicitly changed the file and the new content should propagate.
|
|
208
265
|
*/
|
|
209
266
|
export async function collectProfilePayloads(
|
|
210
267
|
fields: ProfileSyncField[],
|
|
@@ -214,13 +271,18 @@ export async function collectProfilePayloads(
|
|
|
214
271
|
): Promise<ProfilePayload[]> {
|
|
215
272
|
const payloads: ProfilePayload[] = [];
|
|
216
273
|
|
|
274
|
+
const baselines = changeSource === "session_sync" ? await readIdentityBaselines(readFile) : null;
|
|
275
|
+
|
|
217
276
|
if (fields.includes("identity")) {
|
|
218
|
-
const updates = buildIdentityPayload(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
277
|
+
const updates = buildIdentityPayload(
|
|
278
|
+
{
|
|
279
|
+
soulMd: await readFile(SOUL_MD_PATH),
|
|
280
|
+
identityMd: await readFile(IDENTITY_MD_PATH),
|
|
281
|
+
toolsMd: await readFile(TOOLS_MD_PATH),
|
|
282
|
+
heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
|
|
283
|
+
},
|
|
284
|
+
baselines,
|
|
285
|
+
);
|
|
224
286
|
if (Object.keys(updates).length > 0) {
|
|
225
287
|
payloads.push({ label: "identity", body: { ...updates, changeSource } });
|
|
226
288
|
}
|
|
@@ -229,7 +291,11 @@ export async function collectProfilePayloads(
|
|
|
229
291
|
if (fields.includes("claude")) {
|
|
230
292
|
const raw = await readFile(claudeMdPath);
|
|
231
293
|
if (raw?.trim() && raw.length <= MAX_FILE_LENGTH) {
|
|
232
|
-
|
|
294
|
+
if (baselines?.claudeMd && contentSha256(raw) === baselines.claudeMd) {
|
|
295
|
+
// CLAUDE.md unchanged during session — skip to preserve Lead's DB edits
|
|
296
|
+
} else {
|
|
297
|
+
payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
|
|
298
|
+
}
|
|
233
299
|
}
|
|
234
300
|
}
|
|
235
301
|
|
package/src/commands/runner.ts
CHANGED
|
@@ -57,7 +57,12 @@ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
|
|
|
57
57
|
import { interpolate } from "../workflows/template.ts";
|
|
58
58
|
import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
|
|
59
59
|
import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
|
|
60
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
contentSha256,
|
|
62
|
+
resolveClaudeMdPath,
|
|
63
|
+
syncProfileFilesToServer,
|
|
64
|
+
writeIdentityBaselines,
|
|
65
|
+
} from "./profile-sync.ts";
|
|
61
66
|
import {
|
|
62
67
|
buildCredStatusReport,
|
|
63
68
|
buildLatestModelReport,
|
|
@@ -4307,6 +4312,23 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
4307
4312
|
}
|
|
4308
4313
|
}
|
|
4309
4314
|
|
|
4315
|
+
// Record baseline hashes of identity files as written from DB. Session-end
|
|
4316
|
+
// sync compares current file content against these baselines: unchanged files
|
|
4317
|
+
// are skipped, which prevents clobbering DB-side edits made by Lead via
|
|
4318
|
+
// update-profile during the running session.
|
|
4319
|
+
try {
|
|
4320
|
+
const baselines: Record<string, string> = {};
|
|
4321
|
+
if (agentSoulMd) baselines.soulMd = contentSha256(agentSoulMd);
|
|
4322
|
+
if (agentIdentityMd) baselines.identityMd = contentSha256(agentIdentityMd);
|
|
4323
|
+
if (agentToolsMd) baselines.toolsMd = contentSha256(agentToolsMd);
|
|
4324
|
+
if (agentHeartbeatMd) baselines.heartbeatMd = contentSha256(agentHeartbeatMd);
|
|
4325
|
+
if (agentClaudeMd) baselines.claudeMd = contentSha256(agentClaudeMd);
|
|
4326
|
+
await writeIdentityBaselines(baselines);
|
|
4327
|
+
console.log(`[${role}] Recorded identity file baselines for session-end sync`);
|
|
4328
|
+
} catch {
|
|
4329
|
+
// Non-fatal — worst case, session-end sync proceeds as before (blind overwrite)
|
|
4330
|
+
}
|
|
4331
|
+
|
|
4310
4332
|
// ========== Boot-time skill load (signature-gated, replaces the standalone
|
|
4311
4333
|
// skill-fetch + FS sync blocks). The polling loop below calls the same
|
|
4312
4334
|
// helper per task to hot-reload skills mid-flight. Skipped for
|
package/src/hooks/hook.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
postRatings,
|
|
10
10
|
type RetrievalRow,
|
|
11
11
|
} from "../be/memory/raters/llm";
|
|
12
|
+
import { contentSha256, readIdentityBaselines } from "../commands/profile-sync";
|
|
12
13
|
import type { Agent } from "../types";
|
|
13
14
|
import { getApiKey } from "../utils/api-key";
|
|
14
15
|
import { getMcpBaseUrl } from "../utils/constants";
|
|
@@ -581,7 +582,12 @@ export async function handleHook(): Promise<void> {
|
|
|
581
582
|
const IDENTITY_FILE_MIN_LENGTH = 500;
|
|
582
583
|
|
|
583
584
|
/**
|
|
584
|
-
* Sync SOUL.md and IDENTITY.md content back to the server
|
|
585
|
+
* Sync SOUL.md and IDENTITY.md content back to the server.
|
|
586
|
+
*
|
|
587
|
+
* When `changeSource` is `"session_sync"` (the Stop-hook default), loads
|
|
588
|
+
* baseline hashes written at session start and skips any file whose content
|
|
589
|
+
* hasn't changed. This prevents the session-end sync from clobbering DB-side
|
|
590
|
+
* edits that Lead made via `update-profile` during the running session.
|
|
585
591
|
*/
|
|
586
592
|
const syncIdentityFilesToServer = async (
|
|
587
593
|
agentId: string,
|
|
@@ -589,12 +595,16 @@ export async function handleHook(): Promise<void> {
|
|
|
589
595
|
): Promise<void> => {
|
|
590
596
|
if (!mcpConfig) return;
|
|
591
597
|
|
|
598
|
+
const baselines = changeSource === "session_sync" ? await readIdentityBaselines() : null;
|
|
599
|
+
|
|
592
600
|
const updates: Record<string, string> = {};
|
|
593
601
|
|
|
594
602
|
const soulFile = Bun.file(SOUL_MD_PATH);
|
|
595
603
|
if (await soulFile.exists()) {
|
|
596
604
|
const content = await soulFile.text();
|
|
597
|
-
if (
|
|
605
|
+
if (baselines?.soulMd && contentSha256(content) === baselines.soulMd) {
|
|
606
|
+
// Unchanged during session — skip to preserve Lead's DB edits
|
|
607
|
+
} else if (content.trim() && content.length <= 65536) {
|
|
598
608
|
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
599
609
|
console.error(
|
|
600
610
|
`[hook] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
@@ -608,7 +618,9 @@ export async function handleHook(): Promise<void> {
|
|
|
608
618
|
const identityFile = Bun.file(IDENTITY_MD_PATH);
|
|
609
619
|
if (await identityFile.exists()) {
|
|
610
620
|
const content = await identityFile.text();
|
|
611
|
-
if (
|
|
621
|
+
if (baselines?.identityMd && contentSha256(content) === baselines.identityMd) {
|
|
622
|
+
// Unchanged during session — skip
|
|
623
|
+
} else if (content.trim() && content.length <= 65536) {
|
|
612
624
|
if (content.length < IDENTITY_FILE_MIN_LENGTH) {
|
|
613
625
|
console.error(
|
|
614
626
|
`[hook] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
|
|
@@ -622,7 +634,9 @@ export async function handleHook(): Promise<void> {
|
|
|
622
634
|
const toolsMdFile = Bun.file(TOOLS_MD_PATH);
|
|
623
635
|
if (await toolsMdFile.exists()) {
|
|
624
636
|
const content = await toolsMdFile.text();
|
|
625
|
-
if (
|
|
637
|
+
if (baselines?.toolsMd && contentSha256(content) === baselines.toolsMd) {
|
|
638
|
+
// Unchanged during session — skip
|
|
639
|
+
} else if (content.trim() && content.length <= 65536) {
|
|
626
640
|
updates.toolsMd = content;
|
|
627
641
|
}
|
|
628
642
|
}
|
|
@@ -630,7 +644,9 @@ export async function handleHook(): Promise<void> {
|
|
|
630
644
|
const heartbeatFile = Bun.file(HEARTBEAT_MD_PATH);
|
|
631
645
|
if (await heartbeatFile.exists()) {
|
|
632
646
|
const content = await heartbeatFile.text();
|
|
633
|
-
if (content
|
|
647
|
+
if (baselines?.heartbeatMd && contentSha256(content) === baselines.heartbeatMd) {
|
|
648
|
+
// Unchanged during session — skip
|
|
649
|
+
} else if (content.length <= 65536) {
|
|
634
650
|
updates.heartbeatMd = content;
|
|
635
651
|
}
|
|
636
652
|
}
|
package/src/http/index.ts
CHANGED
|
@@ -451,6 +451,8 @@ try {
|
|
|
451
451
|
try {
|
|
452
452
|
const { seedPricingFromModelsDev } = await import("../be/seed-pricing");
|
|
453
453
|
seedPricingFromModelsDev();
|
|
454
|
+
const { startPricingRefreshLoop } = await import("../be/pricing-refresh");
|
|
455
|
+
startPricingRefreshLoop();
|
|
454
456
|
} catch (err) {
|
|
455
457
|
console.error("[startup] Failed to seed pricing rows:", err);
|
|
456
458
|
}
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
# Pricing sources
|
|
2
2
|
|
|
3
|
-
This page lists the sources that feed the `pricing` table
|
|
4
|
-
|
|
3
|
+
This page lists the sources that feed the `pricing` table. Operators bumping a
|
|
4
|
+
rate by hand should also update this file.
|
|
5
5
|
|
|
6
|
-
## Primary:
|
|
6
|
+
## Primary pricing freshness: runtime models.dev refresh
|
|
7
7
|
|
|
8
|
-
- **
|
|
8
|
+
- **Runtime module**: `src/be/pricing-refresh.ts`
|
|
9
|
+
- **Upstream**: `https://models.dev/api.json`, fetched with `If-None-Match`.
|
|
10
|
+
- **Boot wiring**: after `seedPricingFromModelsDev()`, the API server starts one
|
|
11
|
+
non-blocking refresh and then repeats every 12 hours with `setInterval`.
|
|
12
|
+
- **Update rule**: project upstream through `buildModelsDevSeedRows()` and insert
|
|
13
|
+
a new `effective_from=Date.now()` row only when the model/token class is new
|
|
14
|
+
or the active price changed. Identical prices are no-ops.
|
|
15
|
+
- **Growth bound**: after each refresh, keep only the latest two rows per
|
|
16
|
+
`(provider, model, token_class)` triple.
|
|
17
|
+
- **Pinned local entries**: safe by construction. The runtime refresh only adds
|
|
18
|
+
pricing rows; it does not rewrite or delete the committed snapshot.
|
|
19
|
+
|
|
20
|
+
## Fallback/UI catalog: vendored models.dev snapshot
|
|
21
|
+
|
|
22
|
+
- **Fallback path**: `src/be/modelsdev-cache.json`
|
|
9
23
|
- **UI compatibility path**: `ui/src/lib/modelsdev-cache.json` symlinks to the
|
|
10
24
|
backend snapshot so existing UI imports keep working.
|
|
11
25
|
- **Loaded by**: `src/be/modelsdev-cache.ts` → `src/be/seed-pricing.ts` →
|
|
12
26
|
`seedPricingFromModelsDev()`,
|
|
13
27
|
called from `src/server.ts` after `initDb`.
|
|
28
|
+
- **Role**: cold-start fallback seed for pricing when models.dev is unavailable,
|
|
29
|
+
plus the UI model-picker source for names, labels, and context windows.
|
|
14
30
|
- **Projection rules** (see the same module for code-level detail):
|
|
15
31
|
- Anthropic models → rows under `provider='claude'` AND `provider='claude-managed'`.
|
|
16
32
|
Shortnames (`opus`, `sonnet`, `haiku`) ALSO get rows keyed by the current
|
|
@@ -22,12 +38,13 @@ Operators bumping a rate by hand should also update this file.
|
|
|
22
38
|
stripped name and the full `google/...` id) so internal-ai callers find
|
|
23
39
|
a hit either way.
|
|
24
40
|
|
|
25
|
-
- **
|
|
41
|
+
- **Snapshot refresh procedure**:
|
|
26
42
|
- Run `bun run scripts/refresh-modelsdev-pricing.ts` (Phase 2 — adds the
|
|
27
43
|
script). It fetches the latest snapshot from models.dev, diffs against
|
|
28
44
|
the vendored copy, prints a summary, and writes the new file.
|
|
29
45
|
- Commit the regenerated `src/be/modelsdev-cache.json` together with a bump
|
|
30
|
-
note in the PR description.
|
|
46
|
+
note in the PR description. This is no longer the pricing freshness path;
|
|
47
|
+
use it when the fallback/UI catalog needs new labels or context-window data.
|
|
31
48
|
|
|
32
49
|
## Manual overrides
|
|
33
50
|
|
|
@@ -50,6 +67,7 @@ no input/output pricing rows at the lookup time, the row is persisted with
|
|
|
50
67
|
`costSource='unpriced'` (rather than 'harness'). The UI surfaces this as a
|
|
51
68
|
yellow badge.
|
|
52
69
|
|
|
53
|
-
To fix:
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
To fix: first check whether the runtime refresh is failing. If the model must
|
|
71
|
+
also appear in the UI picker or cold-start fallback, add it to
|
|
72
|
+
`src/be/modelsdev-cache.json`; otherwise add a manual override row via the
|
|
73
|
+
existing admin route `POST /api/pricing`.
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import pkg from "../package.json";
|
|
3
3
|
import { initDb } from "./be/db";
|
|
4
|
+
import { startPricingRefreshLoop } from "./be/pricing-refresh";
|
|
4
5
|
import { seedPricingFromModelsDev } from "./be/seed-pricing";
|
|
5
6
|
import { registerCancelTaskTool } from "./tools/cancel-task";
|
|
6
7
|
import { registerContextDiffTool } from "./tools/context-diff";
|
|
@@ -172,6 +173,7 @@ export function createServer() {
|
|
|
172
173
|
// call on every boot. See src/be/seed-pricing.ts for the projection logic
|
|
173
174
|
// and the manual-override constants for runtime-fee / ACU pricing.
|
|
174
175
|
seedPricingFromModelsDev();
|
|
176
|
+
startPricingRefreshLoop();
|
|
175
177
|
|
|
176
178
|
const server = new McpServer(
|
|
177
179
|
{
|