flowviant 0.92.1 → 0.94.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/bin/lib/agentPlan.mjs +53 -1
- package/bin/lib/artifacts.mjs +298 -0
- package/bin/lib/claude.mjs +9 -1
- package/bin/lib/fleet.mjs +34 -0
- package/bin/lib/knowledge.mjs +531 -0
- package/bin/lib/prompts.mjs +98 -1
- package/bin/lib/runtimes.mjs +6 -1
- package/bin/lib/work.mjs +136 -4
- package/package.json +1 -1
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -157,6 +157,35 @@ export function parseProposal(text) {
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The running account, off whichever shape the turn ended with — `{ progress }`
|
|
162
|
+
* or nothing at all.
|
|
163
|
+
*
|
|
164
|
+
* A SPREAD RATHER THAN A FIELD, so an absent account produces no key on the
|
|
165
|
+
* result and therefore no key on the settle body. That is the whole contract
|
|
166
|
+
* with the server: absence means KEEP the last account, and an empty string
|
|
167
|
+
* sent instead would be indistinguishable from an agent saying the branch now
|
|
168
|
+
* amounts to nothing.
|
|
169
|
+
*
|
|
170
|
+
* AN ABSURDITY BOUND HERE, NOT THE DISPLAY CAP (corrected 2026-09-22, in
|
|
171
|
+
* review). This sliced to the wire's own 1000 first, which quietly INVERTED the
|
|
172
|
+
* order the caller's own comment claims to keep: `envScrub` replaces EXACT
|
|
173
|
+
* values, so handing it a paragraph already cut at 1000 hands it a credential
|
|
174
|
+
* cut in half — it matches nothing and the surviving prefix ships. The scrub is
|
|
175
|
+
* the only thing standing between a model that pasted a secret into its own
|
|
176
|
+
* summary and the wire, and a cap must never run ahead of it. So this is the
|
|
177
|
+
* same absurdity bound `summary` takes one line up (8000): enough that nothing
|
|
178
|
+
* a model writes reaches the caller unbounded, far enough above the 1000 the
|
|
179
|
+
* wire cuts to that the scrub always sees whole values. `work.mjs` scrubs and
|
|
180
|
+
* THEN cuts to 1000, and the server clamps again because the wire never trusts
|
|
181
|
+
* a machine to have clamped.
|
|
182
|
+
*/
|
|
183
|
+
const MAX_PROGRESS = 8000;
|
|
184
|
+
const progressOf = (v) => {
|
|
185
|
+
const t = typeof v.progress === 'string' ? v.progress.trim() : '';
|
|
186
|
+
return t ? { progress: t.slice(0, MAX_PROGRESS) } : {};
|
|
187
|
+
};
|
|
188
|
+
|
|
160
189
|
/**
|
|
161
190
|
* READING AN AGENT'S ANSWER at the end of a turn.
|
|
162
191
|
*
|
|
@@ -170,6 +199,28 @@ export function parseProposal(text) {
|
|
|
170
199
|
* `nothing`, which sends the agent to Stuck. Optimistic status from a machine
|
|
171
200
|
* that quit is the one lie this board cannot afford, so anything ambiguous ends
|
|
172
201
|
* up here rather than being read as success.
|
|
202
|
+
*
|
|
203
|
+
* ── AND IT CARRIES THE AGENT'S RUNNING ACCOUNT OF ITSELF (2026-09-22) ─────
|
|
204
|
+
*
|
|
205
|
+
* The owner: "we should add a brief summary of what the agent has done overall
|
|
206
|
+
* at the top that updates." `progress` is one more key on the SAME object —
|
|
207
|
+
* two or three cumulative sentences about the whole branch, rewritten fresh
|
|
208
|
+
* every turn — and it is read here because this is where everything the agent
|
|
209
|
+
* SAYS is already read. Nothing derives it: this file parses, it does not
|
|
210
|
+
* summarise, and a daemon that read the turn log and wrote its own paragraph
|
|
211
|
+
* would be the second brain the product forbids.
|
|
212
|
+
*
|
|
213
|
+
* ON BOTH SHAPES, because a turn that stopped to ask has still done work — an
|
|
214
|
+
* agent that built three quarters of a feature and then needed a decision is
|
|
215
|
+
* exactly the run whose account a person wants. It is NOT read off an
|
|
216
|
+
* unparseable turn, and that is not an omission: there is no object to read it
|
|
217
|
+
* from, and a `nothing` outcome means the CLI never got as far as saying
|
|
218
|
+
* anything about itself.
|
|
219
|
+
*
|
|
220
|
+
* MISSING IS MISSING. An older prompt, a resumed conversation that never saw
|
|
221
|
+
* the key, a model that dropped it — all of them leave it absent, and the
|
|
222
|
+
* caller omits the field rather than sending an empty string, so the server
|
|
223
|
+
* keeps the last account that was true instead of blanking the head.
|
|
173
224
|
*/
|
|
174
225
|
export function parseTurnResult(text) {
|
|
175
226
|
for (const v of candidateObjects(String(text ?? ''))) {
|
|
@@ -179,12 +230,13 @@ export function parseTurnResult(text) {
|
|
|
179
230
|
// parks an agent with nothing to reply to. Treated as `nothing`, which
|
|
180
231
|
// at least says truthfully that the machine went quiet.
|
|
181
232
|
if (!question) continue;
|
|
182
|
-
return { outcome: 'question', answer: question.slice(0, 8000) };
|
|
233
|
+
return { outcome: 'question', answer: question.slice(0, 8000), ...progressOf(v) };
|
|
183
234
|
}
|
|
184
235
|
if (v.status === 'delivered') {
|
|
185
236
|
return {
|
|
186
237
|
outcome: 'delivered',
|
|
187
238
|
answer: (typeof v.summary === 'string' ? v.summary : '').slice(0, 8000),
|
|
239
|
+
...progressOf(v),
|
|
188
240
|
raised: Array.isArray(v.raised)
|
|
189
241
|
? v.raised
|
|
190
242
|
.filter((r) => r && typeof r.title === 'string' && r.title.trim())
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARTIFACTS, daemon side (2026-09-22, 0.94.0) — the files a turn wrote to SHOW
|
|
3
|
+
* the person, found after the turn and relayed.
|
|
4
|
+
*
|
|
5
|
+
* The owner asked whether Flowviant could "have it generate artifacts and
|
|
6
|
+
* display artifacts there". The turn contracts gain one paragraph
|
|
7
|
+
* (`ARTIFACTS_PARAGRAPH`, prompts.mjs): to show a page, a document, a chart or
|
|
8
|
+
* an image, write it under `.flowviant/artifacts/` in the directory you stand
|
|
9
|
+
* in. After each session turn and each agent turn this module lists that
|
|
10
|
+
* directory and uploads what the turn changed to `POST /fleet/artifact`; the
|
|
11
|
+
* Workbench draws it beside the thread. NOTHING HERE MAKES AN ARTIFACT — it
|
|
12
|
+
* reads what the CLI wrote and relays it, scrubbed.
|
|
13
|
+
*
|
|
14
|
+
* ── WHAT THE TURN CHANGED, NOT WHAT IS THERE ──
|
|
15
|
+
*
|
|
16
|
+
* The directory is snapshotted (name → size:mtime) BEFORE the CLI spawns and
|
|
17
|
+
* compared after it exits. The spec's words were "changed since the last
|
|
18
|
+
* report", and a per-turn snapshot is the strict form of that, chosen for two
|
|
19
|
+
* reasons: (1) every tab one person owns shares ONE place (the checkout, or
|
|
20
|
+
* their own copy), so a file one tab wrote is on disk when a sibling tab's turn
|
|
21
|
+
* ends — diffing against "last reported" per session would hand tab B every
|
|
22
|
+
* artifact tab A ever drew; and (2) a snapshot needs no memory across a daemon
|
|
23
|
+
* restart, which a "last reported" map would lose on every auto-update and then
|
|
24
|
+
* re-upload the whole directory. The residual, stated: two tabs in one place
|
|
25
|
+
* running turns AT THE SAME TIME (the place lock admits concurrent readers) can
|
|
26
|
+
* each see the other's write land inside their window. The server's upsert by
|
|
27
|
+
* (owner, name) and its bound keep that a duplicate, never a wrong answer.
|
|
28
|
+
*
|
|
29
|
+
* ── THE BOUNDS ──
|
|
30
|
+
*
|
|
31
|
+
* Regular files only, DEPTH ONE, `lstat` and never followed: a symlink an
|
|
32
|
+
* agent planted at `.flowviant/artifacts/x.txt -> ~/.ssh/id_ed25519` is
|
|
33
|
+
* reported as nothing, and the read itself opens with `O_NOFOLLOW` so a swap
|
|
34
|
+
* between the lstat and the open cannot turn one into a follow. The directory
|
|
35
|
+
* itself must be a real directory for the same reason. At most 40 files,
|
|
36
|
+
* newest first. Types off the allowlist (the server's list, repeated here —
|
|
37
|
+
* this is somebody's disk and one place checking is one deploy from zero) are
|
|
38
|
+
* reported BY NAME and never read. Over `ARTIFACT_MAX_BYTES` likewise.
|
|
39
|
+
*
|
|
40
|
+
* ── TEXT IS SCRUBBED ──
|
|
41
|
+
*
|
|
42
|
+
* html, md, svg, json, csv and txt pass through `envScrub` before upload — the
|
|
43
|
+
* turn trace's own scrub, for the trace's own reason: a page the agent wrote
|
|
44
|
+
* can quote a value out of the checkout's `.env`, and an artifact is served to
|
|
45
|
+
* a browser. Binary images are not scrubbed (there is no text to match), and
|
|
46
|
+
* the SERVER computes the sha256 of what it stored; the one sent here is for
|
|
47
|
+
* the log.
|
|
48
|
+
*
|
|
49
|
+
* ── DELIVERY ──
|
|
50
|
+
*
|
|
51
|
+
* A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
|
|
52
|
+
* older SERVER 404s the whole route, and holding bodies it will refuse forever
|
|
53
|
+
* would be the trace relay's wedge wearing a retry's clothes; a refusal of one
|
|
54
|
+
* file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
|
|
55
|
+
* network error keeps the STORED BODY — the fields and the bytes as they were
|
|
56
|
+
* scrubbed — and retries it on the next report beat, the settle's shape, at
|
|
57
|
+
* most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
|
|
58
|
+
*
|
|
59
|
+
* NO VERSION FLOOR: a daemon→server report on a new endpoint. The prompt
|
|
60
|
+
* paragraph is gated instead, on the roster's `artifactsAccepted` — a server
|
|
61
|
+
* that cannot show an artifact must not have the CLI told it will.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
|
|
65
|
+
import { createHash } from 'node:crypto';
|
|
66
|
+
import { join } from 'node:path';
|
|
67
|
+
|
|
68
|
+
/** Relative to the directory the turn stands in. Under `.flowviant/`, which
|
|
69
|
+
* the exclude file already hides from git (`excludeInWorktree`). */
|
|
70
|
+
export const ARTIFACT_DIR = '.flowviant/artifacts';
|
|
71
|
+
/** The server's per-file ceiling, re-checked here. */
|
|
72
|
+
export const ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
|
|
73
|
+
/** Listed per scan, newest first — the server keeps as many per owner. */
|
|
74
|
+
export const ARTIFACT_MAX_FILES = 40;
|
|
75
|
+
/** A held upload is tried this many times in all, then dropped with a line. */
|
|
76
|
+
const MAX_TRIES = 4;
|
|
77
|
+
/** Held bodies across every owner — each can be two megabytes. */
|
|
78
|
+
const MAX_PENDING = 40;
|
|
79
|
+
|
|
80
|
+
/** The allowlist, by extension. The server's `ARTIFACT_TYPES`, repeated. */
|
|
81
|
+
export const ARTIFACT_TYPES = {
|
|
82
|
+
html: { mime: 'text/html', text: true },
|
|
83
|
+
htm: { mime: 'text/html', text: true },
|
|
84
|
+
md: { mime: 'text/markdown', text: true },
|
|
85
|
+
svg: { mime: 'image/svg+xml', text: true },
|
|
86
|
+
png: { mime: 'image/png', text: false },
|
|
87
|
+
jpg: { mime: 'image/jpeg', text: false },
|
|
88
|
+
jpeg: { mime: 'image/jpeg', text: false },
|
|
89
|
+
gif: { mime: 'image/gif', text: false },
|
|
90
|
+
webp: { mime: 'image/webp', text: false },
|
|
91
|
+
json: { mime: 'application/json', text: true },
|
|
92
|
+
csv: { mime: 'text/csv', text: true },
|
|
93
|
+
txt: { mime: 'text/plain', text: true },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function artifactTypeFor(name) {
|
|
97
|
+
const dot = String(name).lastIndexOf('.');
|
|
98
|
+
if (dot <= 0) return null;
|
|
99
|
+
return ARTIFACT_TYPES[String(name).slice(dot + 1).toLowerCase()] ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The directory's regular files, newest first, at most `ARTIFACT_MAX_FILES`.
|
|
104
|
+
* `[]` for a directory that does not exist, is not a directory, or is a
|
|
105
|
+
* symlink — nothing to report is the only honest reading of any of those.
|
|
106
|
+
*/
|
|
107
|
+
export function scanArtifacts(placeDir) {
|
|
108
|
+
const dir = join(placeDir, ARTIFACT_DIR);
|
|
109
|
+
/**
|
|
110
|
+
* BOTH components are checked, not only the last (2026-09-23). `lstat` on
|
|
111
|
+
* `.flowviant/artifacts` refuses a symlink AT that name, but it resolves
|
|
112
|
+
* THROUGH a symlinked `.flowviant` — and git commits symlinks, so a cloned
|
|
113
|
+
* repo carrying `.flowviant -> /some/dir` would have had this scan listing,
|
|
114
|
+
* reading and uploading `/some/dir/artifacts/*`, a directory outside the
|
|
115
|
+
* place the turn stood in. The knowledge sync already refuses a `.flowviant`
|
|
116
|
+
* that is not a real directory (`flowviantDirOk`); this is the same check on
|
|
117
|
+
* the read side.
|
|
118
|
+
*/
|
|
119
|
+
try {
|
|
120
|
+
const parent = lstatSync(join(placeDir, '.flowviant'));
|
|
121
|
+
if (parent.isSymbolicLink() || !parent.isDirectory()) return [];
|
|
122
|
+
if (!lstatSync(dir).isDirectory()) return [];
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
let names;
|
|
127
|
+
try {
|
|
128
|
+
names = readdirSync(dir);
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const name of names) {
|
|
134
|
+
if (name.startsWith('.')) continue; // editor swap files, `.DS_Store`
|
|
135
|
+
try {
|
|
136
|
+
const st = lstatSync(join(dir, name));
|
|
137
|
+
if (!st.isFile()) continue; // symlinks, directories, sockets: nothing
|
|
138
|
+
out.push({ name, path: join(dir, name), size: st.size, mtimeMs: st.mtimeMs });
|
|
139
|
+
} catch {
|
|
140
|
+
/* vanished between the list and the stat */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
144
|
+
return out.slice(0, ARTIFACT_MAX_FILES);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sigOf = (e) => `${e.size}:${e.mtimeMs}`;
|
|
148
|
+
|
|
149
|
+
/** name → size:mtime, taken before the CLI spawns. */
|
|
150
|
+
export function snapshotArtifacts(placeDir) {
|
|
151
|
+
return new Map(scanArtifacts(placeDir).map((e) => [e.name, sigOf(e)]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What the turn wrote: every entry whose (name, size, mtime) is not what the
|
|
155
|
+
* snapshot held. A deleted file is not reported — the server's copy is
|
|
156
|
+
* scrollback and goes with the session or the agent. */
|
|
157
|
+
export function changedArtifacts(before, entries) {
|
|
158
|
+
return entries.filter((e) => before?.get(e.name) !== sigOf(e));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read a regular file WITHOUT following a symlink planted after the lstat. */
|
|
162
|
+
function readNoFollow(path, limit) {
|
|
163
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
164
|
+
try {
|
|
165
|
+
const st = fstatSync(fd);
|
|
166
|
+
if (!st.isFile() || st.size > limit) return null;
|
|
167
|
+
const buf = Buffer.alloc(st.size);
|
|
168
|
+
let off = 0;
|
|
169
|
+
while (off < st.size) {
|
|
170
|
+
const n = readSync(fd, buf, off, st.size - off, off);
|
|
171
|
+
if (n <= 0) break;
|
|
172
|
+
off += n;
|
|
173
|
+
}
|
|
174
|
+
return buf.subarray(0, off);
|
|
175
|
+
} finally {
|
|
176
|
+
closeSync(fd);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* ONE UPLOAD BODY, as data — fields plus optional bytes — so a held body is
|
|
182
|
+
* re-sent exactly as it was first built (scrubbed once, never re-read from a
|
|
183
|
+
* disk the agent may have changed since).
|
|
184
|
+
*/
|
|
185
|
+
export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s) {
|
|
186
|
+
const type = artifactTypeFor(entry.name);
|
|
187
|
+
const fields = {
|
|
188
|
+
...(sessionId ? { sessionId } : { agentId }),
|
|
189
|
+
...(turnId ? { turnId } : {}),
|
|
190
|
+
name: entry.name,
|
|
191
|
+
bytes: String(entry.size),
|
|
192
|
+
};
|
|
193
|
+
if (!type) return { fields, bytes: null }; // reported by name only
|
|
194
|
+
if (entry.size > ARTIFACT_MAX_BYTES) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
|
|
195
|
+
let bytes;
|
|
196
|
+
try {
|
|
197
|
+
bytes = readNoFollow(entry.path, ARTIFACT_MAX_BYTES);
|
|
198
|
+
} catch {
|
|
199
|
+
return null; // vanished, or a symlink swapped in: nothing to say
|
|
200
|
+
}
|
|
201
|
+
if (!bytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
|
|
202
|
+
if (type.text) {
|
|
203
|
+
bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
|
|
204
|
+
// A redaction marker can be longer than the value it replaced.
|
|
205
|
+
if (bytes.byteLength > ARTIFACT_MAX_BYTES) {
|
|
206
|
+
return { fields: { ...fields, bytes: String(bytes.byteLength), tooLarge: '1' }, bytes: null };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
fields: {
|
|
211
|
+
...fields,
|
|
212
|
+
bytes: String(bytes.byteLength),
|
|
213
|
+
mime: type.mime,
|
|
214
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
215
|
+
},
|
|
216
|
+
bytes,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The reporter a work manager holds for its life. `report` is called once per
|
|
222
|
+
* finished turn and never awaited by the turn (a slow uplink must not hold the
|
|
223
|
+
* next turn behind a readout); `retryPending` rides the settle retry beat.
|
|
224
|
+
*/
|
|
225
|
+
export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetchImpl, log = () => {} }) {
|
|
226
|
+
const url = String(fleetUrl).replace(/\/agents\/?$/, '/artifact');
|
|
227
|
+
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
228
|
+
const pending = new Map(); // `${owner}:${name}` -> { body, tries }
|
|
229
|
+
|
|
230
|
+
/** true = settled (delivered or permanently refused); false = hold it. */
|
|
231
|
+
const post = async (body) => {
|
|
232
|
+
const form = new FormData();
|
|
233
|
+
for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
|
|
234
|
+
if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
|
|
235
|
+
try {
|
|
236
|
+
const res = await doFetch(url, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
239
|
+
signal: AbortSignal.timeout(30_000),
|
|
240
|
+
body: form,
|
|
241
|
+
});
|
|
242
|
+
return (
|
|
243
|
+
res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
244
|
+
);
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const hold = (key, body, tries) => {
|
|
251
|
+
if (tries >= MAX_TRIES) {
|
|
252
|
+
pending.delete(key);
|
|
253
|
+
log(`artifact ${body.fields.name}: not delivered after ${tries} tries — dropped`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
|
|
257
|
+
pending.set(key, { body, tries });
|
|
258
|
+
while (pending.size > MAX_PENDING) pending.delete(pending.keys().next().value);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const send = async (key, body, tries) => {
|
|
262
|
+
if (await post(body)) pending.delete(key);
|
|
263
|
+
else hold(key, body, tries + 1);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let retrying = false;
|
|
267
|
+
const retryPending = async () => {
|
|
268
|
+
if (retrying || pending.size === 0) return;
|
|
269
|
+
retrying = true;
|
|
270
|
+
try {
|
|
271
|
+
for (const [key, held] of [...pending]) {
|
|
272
|
+
if (pending.get(key) !== held) continue; // superseded meanwhile
|
|
273
|
+
await send(key, held.body, held.tries);
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
retrying = false;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** Upload what this turn changed in `placeDir`. Resolves to the number of
|
|
281
|
+
* bodies SENT this call (delivered or held), for the tests and the log. */
|
|
282
|
+
const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
|
|
283
|
+
if (!placeDir || (!sessionId && !agentId)) return 0;
|
|
284
|
+
const changed = changedArtifacts(before, scanArtifacts(placeDir));
|
|
285
|
+
let n = 0;
|
|
286
|
+
for (const entry of changed) {
|
|
287
|
+
const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub);
|
|
288
|
+
if (!body) continue;
|
|
289
|
+
const key = `${sessionId ?? agentId}:${entry.name}`;
|
|
290
|
+
pending.delete(key); // this copy supersedes a held older one
|
|
291
|
+
await send(key, body, 0);
|
|
292
|
+
n++;
|
|
293
|
+
}
|
|
294
|
+
return n;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return { report, retryPending, pendingCount: () => pending.size };
|
|
298
|
+
}
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -351,7 +351,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
351
351
|
// returned string for sentinel detection, and each activity is handed to
|
|
352
352
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
353
353
|
// off and keep the raw text passthrough + line sentinels.
|
|
354
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
354
|
+
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
355
355
|
return new Promise((resolve) => {
|
|
356
356
|
const rt = runtimeById(runtime);
|
|
357
357
|
if (!rt.args) {
|
|
@@ -405,6 +405,14 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
405
405
|
// runtime that can path-scope its writes needs to know WHERE the vault is,
|
|
406
406
|
// and Claude — which cannot — simply ignores it.
|
|
407
407
|
vaultDir,
|
|
408
|
+
// THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0), when this box holds one. It
|
|
409
|
+
// lives in the CHECKOUT and a turn usually runs in a worktree, so the
|
|
410
|
+
// prompt hands an absolute path OUTSIDE the cwd — and a curated Claude
|
|
411
|
+
// profile (the capture chat's read-only list, `FLOWVIANT_SAFE=1`) may
|
|
412
|
+
// refuse a read there. `--add-dir` says the directory is one it may read.
|
|
413
|
+
// Only Claude's adapter uses it; codex's sandboxes read the filesystem
|
|
414
|
+
// already, and agy's `--add-dir` is spent on the wiki vault.
|
|
415
|
+
knowledgeDir,
|
|
408
416
|
// Structured-output flags for the MEDIATED path. Handed to the adapter
|
|
409
417
|
// rather than appended here for the same reason `mcp` is: Codex takes its
|
|
410
418
|
// prompt as a trailing positional, so a flag after it is in the wrong
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
isValidPrUrl,
|
|
48
48
|
isValidBranch,
|
|
49
49
|
isSafePathSegment,
|
|
50
|
+
excludeInWorktree,
|
|
50
51
|
} from './git.mjs';
|
|
51
52
|
import { c, info, note, ok, warn, fail } from './ui.mjs';
|
|
52
53
|
import { revertPatch, withPatchLock } from './patch.mjs';
|
|
@@ -80,6 +81,7 @@ import {
|
|
|
80
81
|
THINK_MARKER,
|
|
81
82
|
} from './runtimes.mjs';
|
|
82
83
|
import { createWorkManager } from './work.mjs';
|
|
84
|
+
import { createKnowledgeSync, knowledgeFetcher, FLOWVIANT_OWN_PATHS } from './knowledge.mjs';
|
|
83
85
|
import { effectiveMaxTurns, setServerMaxTurns } from './admission.mjs';
|
|
84
86
|
import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
|
|
85
87
|
import { repoState } from './repoState.mjs';
|
|
@@ -1102,6 +1104,23 @@ export async function runFleetDaemon() {
|
|
|
1102
1104
|
*/
|
|
1103
1105
|
let baseRef = detectBaseRef(repoRoot);
|
|
1104
1106
|
const getBaseRef = () => baseRef;
|
|
1107
|
+
/**
|
|
1108
|
+
* THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0) — synced into THIS checkout's
|
|
1109
|
+
* `.flowviant/knowledge/` whenever the roster's manifest moves. One copy per
|
|
1110
|
+
* box, in the checkout, because every worktree on the box can read an
|
|
1111
|
+
* absolute path (knowledge.mjs says why a copy per worktree is wrong). The
|
|
1112
|
+
* exclude is written first so the library never shows as a change in the
|
|
1113
|
+
* operator's own `git status` or in a checkout tab's diffstat.
|
|
1114
|
+
*/
|
|
1115
|
+
/** The roster's `artifactsAccepted`, latest poll — see `getArtifactsAccepted`
|
|
1116
|
+
* on the work manager. False until a server says otherwise. */
|
|
1117
|
+
let artifactsAccepted = false;
|
|
1118
|
+
const knowledgeSync = createKnowledgeSync({
|
|
1119
|
+
checkoutDir: repoRoot,
|
|
1120
|
+
fetchFile: knowledgeFetcher({ fleetUrl: FLEET_URL, token: FLEET_TOKEN, userAgent: USER_AGENT }),
|
|
1121
|
+
onExclude: (dir) => excludeInWorktree(dir, FLOWVIANT_OWN_PATHS),
|
|
1122
|
+
log: (line) => note(c.dim(line)),
|
|
1123
|
+
});
|
|
1105
1124
|
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
1106
1125
|
// WHICH PROJECT, before anything connects — the roster names it again a few
|
|
1107
1126
|
// seconds later with the server's word, but "which project is this daemon
|
|
@@ -1527,6 +1546,12 @@ export async function runFleetDaemon() {
|
|
|
1527
1546
|
* same one turn and this can never double-count.
|
|
1528
1547
|
*/
|
|
1529
1548
|
extraLiveTurns: () => (wikiBusy || wikiChild ? 1 : 0),
|
|
1549
|
+
/**
|
|
1550
|
+
* Whether the server takes artifacts — the roster's own word, latest poll
|
|
1551
|
+
* (2026-09-22). Read lazily for the reason above: the roster loop that
|
|
1552
|
+
* writes it runs long after this manager is built.
|
|
1553
|
+
*/
|
|
1554
|
+
getArtifactsAccepted: () => artifactsAccepted,
|
|
1530
1555
|
/**
|
|
1531
1556
|
* "THE REPO JUST CHANGED — look again."
|
|
1532
1557
|
*
|
|
@@ -2554,6 +2579,15 @@ export async function runFleetDaemon() {
|
|
|
2554
2579
|
// per-session: `git show` runs from the repo ROOT, which can see a closed
|
|
2555
2580
|
// tab's branch and a shipped commit on main alike.
|
|
2556
2581
|
processDiffJobs(roster.diffJobs);
|
|
2582
|
+
// The knowledge library: synced when its rev moves, left ALONE when the key
|
|
2583
|
+
// is absent (an older server, or a project that never had one). Never
|
|
2584
|
+
// awaited — a fifty-megabyte library must not hold a roster tick, and the
|
|
2585
|
+
// sync serialises itself. The next turn to spawn after it lands reads it.
|
|
2586
|
+
void knowledgeSync.onRoster(roster.knowledge);
|
|
2587
|
+
// ARTIFACTS (0.94.0): the server's own word on whether it can show one,
|
|
2588
|
+
// re-read every poll. Absent is an older server and means false — the turn
|
|
2589
|
+
// prompts then say nothing about a panel nobody can draw.
|
|
2590
|
+
artifactsAccepted = roster.artifactsAccepted === true;
|
|
2557
2591
|
// Shares to open or tear down. CLAIMED before acted on — two daemons on one
|
|
2558
2592
|
// credential are both handed this array, and both opening a tunnel strands
|
|
2559
2593
|
// a public hostname nobody can settle.
|