@figs-so/cli 1.3.0 → 1.4.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/SPEC.md +8 -1
- package/figs.mjs +82 -20
- package/package.json +1 -1
package/SPEC.md
CHANGED
|
@@ -272,7 +272,14 @@ minimum CLI version requires Bearer.)
|
|
|
272
272
|
"confirmRename": true // optional — `figs push --rename`: confirm a real name change
|
|
273
273
|
}
|
|
274
274
|
```
|
|
275
|
-
2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded,
|
|
275
|
+
2. **Each attached file the server lacks** → `POST {endpoint}/api/artifacts/upload`, base64-encoded,
|
|
276
|
+
hash-verified. The `/api/ingest` response returns the agent's **artifact manifest**
|
|
277
|
+
(`{ "<name>": "sha256:<hex>" }` for every stored file); the CLI re-hashes its local files and uploads
|
|
278
|
+
only those whose hash differs or are absent — **content-addressed on the sha256 of the raw file
|
|
279
|
+
bytes** (hashed before base64), so unchanged bytes never cross the wire. A response without the
|
|
280
|
+
manifest field triggers a full upload (older readers omit it; older CLIs ignore it). Attachments stay
|
|
281
|
+
immutable (§7): same name + different bytes is refused `409`. `figs push --reupload` forces a full
|
|
282
|
+
re-send (recovery if a stored object ever drifts from its row).
|
|
276
283
|
|
|
277
284
|
The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it
|
|
278
285
|
**rebuilds each run's timeline from `runEvents`, one entry per fold, deduped by `eventId`** (a run with no
|
package/figs.mjs
CHANGED
|
@@ -211,19 +211,21 @@ const COMMANDS = {
|
|
|
211
211
|
answer: {
|
|
212
212
|
args: "<ask-id> --by <who> (--chosen <option> | --text <reply> | --approve | --request-changes | --reject)",
|
|
213
213
|
flags: [
|
|
214
|
-
"--by", "--chosen", "--text", "--approve", "--request-changes", "--reject", "--no-push",
|
|
214
|
+
"--by", "--chosen", "--text", "--approve", "--request-changes", "--reject", "--no-push", "--force",
|
|
215
215
|
],
|
|
216
|
-
desc: "
|
|
216
|
+
desc: "transcribe a human's out-of-band reply (chat/console, not the app), verbatim (you run this, not them)",
|
|
217
217
|
more: [
|
|
218
|
-
"
|
|
219
|
-
"
|
|
218
|
+
"For a reply your user gave you in chat/console rather than the app — fine even when",
|
|
219
|
+
"linked (it records as a 'relayed' reply, the honest lower-trust grade). Humans don't",
|
|
220
|
+
"type commands; you transcribe what they said. --by names the HUMAN who said it, not you.",
|
|
220
221
|
"question → --chosen '<option verbatim>' (checked against the ask's options) or",
|
|
221
222
|
"--text '<what they said>'. sign-off → --approve | --request-changes | --reject",
|
|
222
223
|
"(a qualified verdict may also carry --chosen). Transcribe verbatim — never summarize,",
|
|
223
224
|
"never author the reply yourself.",
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"
|
|
225
|
+
"Just don't RE-transcribe a reply that already came through the app: it's attested and",
|
|
226
|
+
"syncs down on its own (figs inbox), and a re-typed copy would downgrade it — so answer",
|
|
227
|
+
"REFUSES if the ask already has an app reply (--force only for a separate out-of-band one).",
|
|
228
|
+
"When inbox already shows the reply, skip straight to `figs close <ask-id>`.",
|
|
227
229
|
],
|
|
228
230
|
eg: "figs answer acme-bridge --chosen 'Strip the alpha prefix' --by 'Sarah (accounting)'",
|
|
229
231
|
},
|
|
@@ -280,16 +282,20 @@ const COMMANDS = {
|
|
|
280
282
|
},
|
|
281
283
|
push: {
|
|
282
284
|
args: "",
|
|
283
|
-
flags: ["--rename"],
|
|
285
|
+
flags: ["--rename", "--reupload"],
|
|
284
286
|
desc: "publish .figs/ — spine to /api/ingest, artifacts to /api/artifacts",
|
|
285
287
|
more: [
|
|
286
288
|
"Idempotent (records fold by id). Exits non-zero if an artifact upload is rejected.",
|
|
287
289
|
"The writing verbs (report/ask/resolve) call this automatically — you only need it",
|
|
288
290
|
"after hand-editing files, after --no-push, or to retry a failed auto-push.",
|
|
291
|
+
"Artifacts already on the server (same content) are skipped — their bytes aren't",
|
|
292
|
+
"re-sent; only new or changed files upload.",
|
|
289
293
|
"--rename: confirm a genuine name change on an already-registered agent (one",
|
|
290
294
|
"time). The server refuses a name that doesn't match the registered one — it's",
|
|
291
295
|
"the fingerprint of a copied folder; if that's what happened, rotate identity",
|
|
292
296
|
"instead with `rm -rf .figs && figs init`, don't --rename.",
|
|
297
|
+
"--reupload: re-send every artifact even if the server already has it (bypasses",
|
|
298
|
+
"the content-hash skip) — recovery if a stored file ever drifts from its record.",
|
|
293
299
|
],
|
|
294
300
|
eg: "figs push",
|
|
295
301
|
},
|
|
@@ -366,7 +372,7 @@ function positional() {
|
|
|
366
372
|
return undefined
|
|
367
373
|
}
|
|
368
374
|
const BOOLEAN_FLAGS = new Set([
|
|
369
|
-
"--no-push", "--stdin", "--withdrawn", "--rejected", "--json", "-h", "--help",
|
|
375
|
+
"--no-push", "--stdin", "--withdrawn", "--rejected", "--json", "--force", "--reupload", "-h", "--help",
|
|
370
376
|
])
|
|
371
377
|
|
|
372
378
|
/** ISO-8601 with the machine's real UTC offset (never the agent's guess). */
|
|
@@ -399,6 +405,13 @@ function genEventId() {
|
|
|
399
405
|
return `evt-${randomUUID()}`
|
|
400
406
|
}
|
|
401
407
|
|
|
408
|
+
/** Human-readable byte size — for the push line ("X not re-sent"). */
|
|
409
|
+
function fmtBytes(n) {
|
|
410
|
+
if (n < 1024) return `${n} B`
|
|
411
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
412
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
413
|
+
}
|
|
414
|
+
|
|
402
415
|
// ---------- local validation (the spec's common mistakes, caught on write) ----
|
|
403
416
|
// The server's schema stays the source of truth; these catch what hand-authors
|
|
404
417
|
// and flag typos get wrong, with errors that teach the fix.
|
|
@@ -1674,8 +1687,24 @@ async function answerCmd() {
|
|
|
1674
1687
|
}
|
|
1675
1688
|
}
|
|
1676
1689
|
|
|
1677
|
-
//
|
|
1678
|
-
//
|
|
1690
|
+
// Attested-reply guard (the trust-grade protector). A reply made in the app is
|
|
1691
|
+
// attested (source:"app") and syncs down on its own via `figs inbox`. Re-transcribing
|
|
1692
|
+
// it here mints a weaker source:"chat" duplicate — and since `close` cites the NEWEST
|
|
1693
|
+
// reply, the self-report would supersede the attested one, silently downgrading the
|
|
1694
|
+
// resolution from ✓VERIFIED to "relayed by agent". So refuse if the ask already has an
|
|
1695
|
+
// app-minted reply (type-agnostic: verdicts and answers alike); --force records a
|
|
1696
|
+
// genuinely-out-of-band chat reply anyway. Local check — the app reply is already in
|
|
1697
|
+
// messages.jsonl from a prior inbox sync (no network on this hot path; contract rule 6).
|
|
1698
|
+
const appReply = readJsonl("messages.jsonl").find((m) => m.ask === askId && m.source === "app")
|
|
1699
|
+
if (appReply && !hasFlag("--force")) {
|
|
1700
|
+
die(
|
|
1701
|
+
`ask "${askId}" already has an attested reply from the app (synced here via \`figs inbox\`).\n` +
|
|
1702
|
+
` Don't transcribe app replies — the app is the answer channel. → just \`figs close ${askId}\`.\n` +
|
|
1703
|
+
` (\`figs answer\` is only for a reply that exists nowhere but your chat. --force to record one anyway.)`,
|
|
1704
|
+
)
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Softer guard for the no-app case (e.g. re-typing a chat reply): a content-match dupe.
|
|
1679
1708
|
const priorSame = readJsonl("messages.jsonl").some(
|
|
1680
1709
|
(m) =>
|
|
1681
1710
|
m.ask === askId &&
|
|
@@ -2243,8 +2272,18 @@ async function doPush() {
|
|
|
2243
2272
|
// The wow-moment link — relay this to your human so they can see the agent.
|
|
2244
2273
|
console.log(` view at ${base}/w/${config.workspaceId}`)
|
|
2245
2274
|
|
|
2275
|
+
// The ingest response carries the agent's artifact manifest ({ name: "sha256:…" }) —
|
|
2276
|
+
// pushArtifacts re-hashes locally and skips re-sending bytes the server already
|
|
2277
|
+
// has. Absent (older server / non-JSON body) → it uploads everything, as before.
|
|
2278
|
+
let artifactManifest
|
|
2279
|
+
try {
|
|
2280
|
+
artifactManifest = JSON.parse(text)?.artifactManifest
|
|
2281
|
+
} catch {
|
|
2282
|
+
// non-JSON success body — fall back to upload-all
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2246
2285
|
// The spine landed; an artifact-stage failure is transient/oversize — retry.
|
|
2247
|
-
return (await pushArtifacts(base, token, config))
|
|
2286
|
+
return (await pushArtifacts(base, token, config, artifactManifest))
|
|
2248
2287
|
? { ok: true }
|
|
2249
2288
|
: { ok: false, retryable: true }
|
|
2250
2289
|
}
|
|
@@ -2258,7 +2297,7 @@ async function doPush() {
|
|
|
2258
2297
|
* survives. A **server rejection** (auth/size) is fatal; a **missing local
|
|
2259
2298
|
* file** is only a warning (the agent referenced something it didn't produce).
|
|
2260
2299
|
*/
|
|
2261
|
-
async function pushArtifacts(base, token, config) {
|
|
2300
|
+
async function pushArtifacts(base, token, config, manifest) {
|
|
2262
2301
|
const names = [
|
|
2263
2302
|
...new Set(
|
|
2264
2303
|
[...readJsonl("runs.jsonl"), ...readJsonl("asks.jsonl")].flatMap(attachmentsOf),
|
|
@@ -2266,10 +2305,18 @@ async function pushArtifacts(base, token, config) {
|
|
|
2266
2305
|
]
|
|
2267
2306
|
if (names.length === 0) return true
|
|
2268
2307
|
|
|
2308
|
+
// Content-addressed skip: ingest returns the server's artifacts as
|
|
2309
|
+
// { name: "sha256:<hex>" }; we re-hash each local file and only POST its bytes
|
|
2310
|
+
// when they differ (or the server lacks it). Without a manifest (older server)
|
|
2311
|
+
// we upload everything, as before. `--reupload` forces a full re-send — the
|
|
2312
|
+
// manual recovery if a stored object ever drifts from its row.
|
|
2313
|
+
const reupload = hasFlag("--reupload")
|
|
2269
2314
|
let uploaded = 0
|
|
2315
|
+
let skipped = 0
|
|
2270
2316
|
let unchanged = 0
|
|
2271
2317
|
let missing = 0
|
|
2272
2318
|
let failed = 0
|
|
2319
|
+
let savedBytes = 0
|
|
2273
2320
|
for (const name of names) {
|
|
2274
2321
|
const p = join(repoDir, "artifacts", name)
|
|
2275
2322
|
if (!existsSync(p)) {
|
|
@@ -2277,7 +2324,15 @@ async function pushArtifacts(base, token, config) {
|
|
|
2277
2324
|
missing++
|
|
2278
2325
|
continue
|
|
2279
2326
|
}
|
|
2280
|
-
const
|
|
2327
|
+
const bytes = readFileSync(p)
|
|
2328
|
+
// sha256 of the RAW bytes — must match the server's hash of the decoded
|
|
2329
|
+
// upload (SPEC §8). Hash before base64, never the base64 text.
|
|
2330
|
+
const localHash = `sha256:${createHash("sha256").update(bytes).digest("hex")}`
|
|
2331
|
+
if (!reupload && manifest && manifest[name] === localHash) {
|
|
2332
|
+
skipped++
|
|
2333
|
+
savedBytes += bytes.length
|
|
2334
|
+
continue
|
|
2335
|
+
}
|
|
2281
2336
|
let res
|
|
2282
2337
|
try {
|
|
2283
2338
|
res = await fetchT(`${base}/api/artifacts/upload`, {
|
|
@@ -2287,7 +2342,7 @@ async function pushArtifacts(base, token, config) {
|
|
|
2287
2342
|
workspaceId: config.workspaceId,
|
|
2288
2343
|
agentId: config.agentId,
|
|
2289
2344
|
name,
|
|
2290
|
-
content,
|
|
2345
|
+
content: bytes.toString("base64"),
|
|
2291
2346
|
}),
|
|
2292
2347
|
})
|
|
2293
2348
|
} catch (e) {
|
|
@@ -2305,15 +2360,22 @@ async function pushArtifacts(base, token, config) {
|
|
|
2305
2360
|
failed++
|
|
2306
2361
|
continue
|
|
2307
2362
|
}
|
|
2363
|
+
// We sent it; the server may still report it unchanged on the fallback path
|
|
2364
|
+
// (no manifest to skip with — the bytes crossed the wire, storage was spared).
|
|
2308
2365
|
const body = await res.json().catch(() => ({}))
|
|
2309
2366
|
if (body.unchanged) unchanged++
|
|
2310
2367
|
else uploaded++
|
|
2311
2368
|
}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
(
|
|
2316
|
-
|
|
2369
|
+
const parts = [`${uploaded} uploaded`]
|
|
2370
|
+
if (skipped) {
|
|
2371
|
+
parts.push(
|
|
2372
|
+
`${skipped} skipped (already synced${savedBytes ? ` — ${fmtBytes(savedBytes)} not re-sent` : ""})`,
|
|
2373
|
+
)
|
|
2374
|
+
}
|
|
2375
|
+
if (unchanged) parts.push(`${unchanged} unchanged`)
|
|
2376
|
+
if (missing) parts.push(`${missing} missing`)
|
|
2377
|
+
if (failed) parts.push(`${failed} failed`)
|
|
2378
|
+
console.log(`figs: ${failed ? "✗" : "✓"} artifacts — ${parts.join(", ")}`)
|
|
2317
2379
|
// The spine already landed; a false return lets the caller exit non-zero so
|
|
2318
2380
|
// an agent's run loop can catch that an artifact the manager needs to read
|
|
2319
2381
|
// did not publish.
|