@saadnvd1/agent-os 0.3.557 → 0.3.560
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/README.md +1 -1
- package/app/api/learning-cards/[id]/route.test.ts +62 -0
- package/app/api/learning-cards/[id]/route.ts +36 -0
- package/app/api/recipes/draft/route.ts +6 -1
- package/app/api/recipes/route.ts +3 -0
- package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
- package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
- package/app/api/sessions/route.ts +3 -3
- package/components/Chat/ChatPanel.tsx +2 -0
- package/components/LearningCards/LearningCard.test.ts +156 -0
- package/components/LearningCards/LearningCard.tsx +210 -0
- package/data/learning-cards/index.ts +79 -0
- package/lib/chat/store.ts +16 -0
- package/lib/db/migrations.ts +53 -0
- package/lib/db/queries.ts +4 -3
- package/lib/experiments/hooks.ts +12 -4
- package/lib/learning-cards/__fixtures__/db.ts +52 -0
- package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
- package/lib/learning-cards/analyze.ts +228 -0
- package/lib/learning-cards/apply.ts +196 -0
- package/lib/learning-cards/index.ts +29 -0
- package/lib/learning-cards/input.ts +116 -0
- package/lib/learning-cards/pairs.ts +111 -0
- package/lib/learning-cards/signals.ts +98 -0
- package/lib/learning-cards/store.ts +103 -0
- package/lib/learning-cards/types.ts +79 -0
- package/lib/models/plan-step.ts +107 -15
- package/lib/orchestrator/conditions.ts +12 -4
- package/lib/orchestrator/events.ts +23 -0
- package/lib/orchestrator/quick.ts +14 -1
- package/lib/orchestrator/reconcile.ts +201 -0
- package/lib/plans/features.ts +69 -13
- package/lib/plans/pieces.ts +70 -15
- package/lib/plans/release.ts +46 -4
- package/lib/recipes/draft.ts +62 -33
- package/lib/recipes/settle.ts +3 -0
- package/lib/sidebar/shelves.ts +5 -6
- package/package.json +1 -1
package/lib/plans/pieces.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface PiecesResult {
|
|
|
37
37
|
pieces: Piece[];
|
|
38
38
|
folded: Folded[];
|
|
39
39
|
warnings: string[];
|
|
40
|
+
// Items the plan's Pieces block lists (0 when it has none), however well
|
|
41
|
+
// they parsed: a plan that builds as fewer than it listed is never silent.
|
|
42
|
+
listed: number;
|
|
43
|
+
// Problems that must stop a task from building on a guess: the pieces
|
|
44
|
+
// returned are then the one-piece fallback, for a person to approve or fix.
|
|
45
|
+
errors: string[];
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
interface Raw {
|
|
@@ -51,7 +57,10 @@ interface Raw {
|
|
|
51
57
|
|
|
52
58
|
const HEADING = /^##\s+pieces\s*$/i;
|
|
53
59
|
const ITEM = /^\s{0,3}(\d+)[.)]\s+(.*\S)\s*$/;
|
|
54
|
-
|
|
60
|
+
// An item as a person might write it, to count what the plan listed even
|
|
61
|
+
// when the strict parser rejects the block.
|
|
62
|
+
const LOOSE_ITEM = /^\s{0,3}(?:[-*+]\s+)?\**\d+[.)]/;
|
|
63
|
+
const AFTER = /^\s*after:\s*(?:piece\s+)?(\d+)\s+is\s+(merged|deployed)\b.*$/i;
|
|
55
64
|
const WHY = /^\s*why split:\s*(.+?)\s*$/i;
|
|
56
65
|
const OWNS = /^\s*owns:\s*(.+?)\s*$/i;
|
|
57
66
|
|
|
@@ -70,6 +79,14 @@ function block(text: string): string[] | null {
|
|
|
70
79
|
return out;
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
// Bullets and bold around the numbers and keys a model sometimes adds
|
|
83
|
+
// ("- **after:** 1 is merged") don't change what a line says.
|
|
84
|
+
function plain(line: string): string {
|
|
85
|
+
return line
|
|
86
|
+
.replace(/^(\s*)[-*+]\s+(?=\**(?:\d+[.)]|after|why split|owns))/i, "$1")
|
|
87
|
+
.replace(/\*\*|__/g, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
function parseItem(position: number, rest: string): Raw {
|
|
74
91
|
let repo: string | null = null;
|
|
75
92
|
const withRepo = rest.match(/^(.*?)\s*\(repo:\s*([^)]+)\)\s*$/i);
|
|
@@ -93,8 +110,9 @@ function parseItem(position: number, rest: string): Raw {
|
|
|
93
110
|
|
|
94
111
|
function parseBlock(lines: string[]): Raw[] | null {
|
|
95
112
|
const items: Raw[] = [];
|
|
96
|
-
for (const
|
|
97
|
-
if (!
|
|
113
|
+
for (const original of lines) {
|
|
114
|
+
if (!original.trim()) continue;
|
|
115
|
+
const line = plain(original);
|
|
98
116
|
const item = line.match(ITEM);
|
|
99
117
|
if (item) {
|
|
100
118
|
items.push(parseItem(Number(item[1]), item[2]));
|
|
@@ -123,6 +141,8 @@ function parseBlock(lines: string[]): Raw[] | null {
|
|
|
123
141
|
.split(/[,;]/)
|
|
124
142
|
.map((o) => o.trim().toLowerCase())
|
|
125
143
|
.filter(Boolean);
|
|
144
|
+
// A wrapped slice: an indented line that is none of the keys.
|
|
145
|
+
else if (/^\s{2,}\S/.test(original)) current.slice += ` ${line.trim()}`;
|
|
126
146
|
else return null;
|
|
127
147
|
}
|
|
128
148
|
if (!items.length || items.some((it, i) => it.position !== i + 1))
|
|
@@ -136,7 +156,13 @@ export interface CheckContext {
|
|
|
136
156
|
find: (name: string) => ProjectRef | null;
|
|
137
157
|
}
|
|
138
158
|
|
|
139
|
-
function one(
|
|
159
|
+
function one(
|
|
160
|
+
text: string,
|
|
161
|
+
ctx: CheckContext,
|
|
162
|
+
warnings: string[],
|
|
163
|
+
listed = 0,
|
|
164
|
+
errors: string[] = []
|
|
165
|
+
): PiecesResult {
|
|
140
166
|
return {
|
|
141
167
|
pieces: [
|
|
142
168
|
{
|
|
@@ -152,30 +178,41 @@ function one(text: string, ctx: CheckContext, warnings: string[]) {
|
|
|
152
178
|
],
|
|
153
179
|
folded: [] as Folded[],
|
|
154
180
|
warnings,
|
|
181
|
+
listed,
|
|
182
|
+
errors,
|
|
155
183
|
};
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
159
187
|
const lines = block(text);
|
|
160
188
|
if (!lines) return one("", ctx, []);
|
|
189
|
+
const listed = lines.filter((l) => LOOSE_ITEM.test(l)).length;
|
|
161
190
|
const raw = parseBlock(lines);
|
|
162
191
|
if (!raw)
|
|
163
|
-
return one("", ctx, [
|
|
164
|
-
"
|
|
192
|
+
return one("", ctx, [], listed, [
|
|
193
|
+
"the Pieces block couldn't be read (a numbered item followed by anything but after:, why split: and owns: lines)",
|
|
194
|
+
]);
|
|
195
|
+
if (raw.length !== listed)
|
|
196
|
+
return one("", ctx, [], listed, [
|
|
197
|
+
`the Pieces block lists ${listed} items but ${raw.length} were read`,
|
|
165
198
|
]);
|
|
166
199
|
if (raw.length > MAX_PIECES)
|
|
167
|
-
return one("", ctx, [
|
|
168
|
-
`${raw.length} pieces is past the limit of ${MAX_PIECES}
|
|
200
|
+
return one("", ctx, [], listed, [
|
|
201
|
+
`${raw.length} pieces is past the limit of ${MAX_PIECES}`,
|
|
169
202
|
]);
|
|
170
203
|
|
|
171
204
|
const warnings: string[] = [];
|
|
205
|
+
const errors: string[] = [];
|
|
172
206
|
const projects = raw.map((r) => {
|
|
173
207
|
if (!r.repo) return ctx.own;
|
|
174
208
|
const found = ctx.find(r.repo);
|
|
175
209
|
if (!found)
|
|
176
|
-
|
|
210
|
+
errors.push(
|
|
211
|
+
`"${r.name}": (repo: ${r.repo}) is not a project of this workspace`
|
|
212
|
+
);
|
|
177
213
|
return found ?? ctx.own;
|
|
178
214
|
});
|
|
215
|
+
if (errors.length) return one("", ctx, [], listed, errors);
|
|
179
216
|
|
|
180
217
|
// Where each original piece ended up, for `after:` lines that pointed at a
|
|
181
218
|
// piece that was folded.
|
|
@@ -212,12 +249,15 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
212
249
|
}
|
|
213
250
|
|
|
214
251
|
let reasons = r.reasons;
|
|
215
|
-
if (!reasons.length)
|
|
252
|
+
if (!reasons.length)
|
|
253
|
+
return fold("no `why split:` line naming one of (a) to (d)");
|
|
254
|
+
const lost: string[] = [];
|
|
216
255
|
|
|
217
256
|
if (reasons.includes("a")) {
|
|
218
257
|
const others = projects.filter((_, j) => j !== i);
|
|
219
258
|
if (others.some((p) => p.id === project.id)) {
|
|
220
259
|
reasons = reasons.filter((x) => x !== "a");
|
|
260
|
+
lost.push("(a) shares a repo with another piece");
|
|
221
261
|
warnings.push(
|
|
222
262
|
`"${r.name}" claimed (a) but shares a repo with another piece.`
|
|
223
263
|
);
|
|
@@ -240,8 +280,10 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
240
280
|
const dep = afterPosition ? pieces[afterPosition - 1] : null;
|
|
241
281
|
if (afterKind !== "deployed" || !dep) {
|
|
242
282
|
reasons = reasons.filter((x) => x !== "b");
|
|
283
|
+
lost.push("(b) needs `after: N is deployed`, not merged");
|
|
243
284
|
} else if (!dep.project.deploys) {
|
|
244
285
|
reasons = reasons.filter((x) => x !== "b");
|
|
286
|
+
lost.push(`(b) waits on a deploy ${dep.project.name} can't check`);
|
|
245
287
|
afterKind = "merged";
|
|
246
288
|
warnings.push(
|
|
247
289
|
`"${r.name}" waits on a deploy that ${dep.project.name} can't check; it waits for the merge instead.`
|
|
@@ -252,7 +294,8 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
252
294
|
if (!dep?.project.deploys) afterKind = "merged";
|
|
253
295
|
}
|
|
254
296
|
|
|
255
|
-
if (!reasons.length)
|
|
297
|
+
if (!reasons.length)
|
|
298
|
+
return fold(`its reason didn't hold: ${lost.join("; ")}`);
|
|
256
299
|
|
|
257
300
|
keep({
|
|
258
301
|
name: r.name,
|
|
@@ -274,7 +317,15 @@ export function checkPieces(text: string, ctx: CheckContext): PiecesResult {
|
|
|
274
317
|
else seen.set(o, p.position);
|
|
275
318
|
}
|
|
276
319
|
|
|
277
|
-
return { pieces, folded, warnings };
|
|
320
|
+
return { pieces, folded, warnings, listed, errors };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// What a task that builds fewer pieces than its plan listed is told, so the
|
|
324
|
+
// folded work is built here and not left for pieces that will never start.
|
|
325
|
+
export function foldedBrief(
|
|
326
|
+
result: Pick<PiecesResult, "listed" | "folded">
|
|
327
|
+
): string {
|
|
328
|
+
return `The plan listed ${result.listed} pieces; they're built as ONE here (${result.folded.map((f) => `${f.name}: ${f.why}`).join("; ")}). Build all of it in this PR, including the folded pieces: ${result.folded.map((f) => f.name).join(", ")}. Nothing else will build them.`;
|
|
278
329
|
}
|
|
279
330
|
|
|
280
331
|
export const PIECES_PROMPT = `At the end of the plan, add a \`## Pieces\` block. The default is exactly one piece, which is what almost every task is, and one piece needs no more than:
|
|
@@ -292,14 +343,15 @@ Diff size is never a reason. A later piece looks like:
|
|
|
292
343
|
after: 1 is merged (or: 1 is deployed)
|
|
293
344
|
why split: (a) + (b)
|
|
294
345
|
owns: migration 81
|
|
295
|
-
A shared resource (a migration id, a new table, a config key) goes on exactly one piece's \`owns:\` line. A piece with no valid reason is folded into the one before it.`;
|
|
346
|
+
A shared resource (a migration id, a new table, a config key) goes on exactly one piece's \`owns:\` line. A piece with no valid reason is folded into the one before it. Reason (b) needs \`after: N is deployed\`; \`after: N is merged\` alone is only ordering, not a reason. A piece in another project runs as its own task in that project; this task builds only the pieces in its own repository.`;
|
|
296
347
|
|
|
297
348
|
// Shown to a piece's agent in place of the whole plan.
|
|
298
349
|
export function pieceBrief(
|
|
299
350
|
total: number,
|
|
300
351
|
piece: Pick<Piece, "position" | "name" | "slice" | "owns">,
|
|
301
352
|
planPath: string,
|
|
302
|
-
before: string[] = []
|
|
353
|
+
before: string[] = [],
|
|
354
|
+
project?: string
|
|
303
355
|
): string {
|
|
304
356
|
const owns = piece.owns.length
|
|
305
357
|
? `\nThis piece owns: ${piece.owns.join(", ")}. No other piece touches it.`
|
|
@@ -307,7 +359,10 @@ export function pieceBrief(
|
|
|
307
359
|
const earlier = before.length
|
|
308
360
|
? `\nPieces before you: ${before.join("; ")}.`
|
|
309
361
|
: "";
|
|
310
|
-
|
|
362
|
+
const where = project
|
|
363
|
+
? `\nWork only in this task's repository (${project}) and its worktree. Don't create worktrees, branches or PRs in another repository: AgentOS starts the pieces that belong there as their own tasks.`
|
|
364
|
+
: "";
|
|
365
|
+
return `Build piece ${piece.position} of ${total} only, "${piece.name}": ${piece.slice}${owns}${earlier}${where}\nThe rest of the plan is other pieces' work; don't start it. Full plan: ${planPath}`;
|
|
311
366
|
}
|
|
312
367
|
|
|
313
368
|
export interface PlanEdits {
|
package/lib/plans/release.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { loadProjectConfig } from "../project-config";
|
|
|
8
8
|
import { createTask } from "../tasks";
|
|
9
9
|
import { startOrQueue } from "../tasks/queue";
|
|
10
10
|
import { braked, BrakeRefused } from "../orchestrator/brakes";
|
|
11
|
-
import { queueEvent } from "../orchestrator/events";
|
|
11
|
+
import { queueEvent, queueEventOnce } from "../orchestrator/events";
|
|
12
12
|
import { amendBrief } from "../orchestrator/amendments";
|
|
13
13
|
import { usageRows } from "../session-logs/gather";
|
|
14
14
|
import { pieceBrief } from "./pieces";
|
|
@@ -190,7 +190,8 @@ async function releaseOne(
|
|
|
190
190
|
)
|
|
191
191
|
.run((piece.session_id ??= randomUUID()), piece.id).changes;
|
|
192
192
|
if (!claimed) return;
|
|
193
|
-
const
|
|
193
|
+
const project = getProject(piece.project_id);
|
|
194
|
+
const prompt = `[Feature "${feature.name}", piece ${piece.position} of ${all.length}, started by AgentOS from an approved plan]\n${pieceBrief(all.length, { position: piece.position, name: piece.name, slice: piece.slice, owns: piece.owns ? piece.owns.split(", ") : [] }, planPathOf(feature.id), beforeLines(all, piece.position), project?.name)}`;
|
|
194
195
|
try {
|
|
195
196
|
const id = await deps.start(feature, piece, prompt, piece.session_id);
|
|
196
197
|
if (id !== piece.session_id)
|
|
@@ -246,16 +247,56 @@ function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
|
|
|
246
247
|
)
|
|
247
248
|
.run(cost, ended > started ? ended - started : null, feature.id).changes;
|
|
248
249
|
if (!claimed) return;
|
|
250
|
+
// A plan that listed more than it built is told, not closed quietly.
|
|
251
|
+
const listed = feature.listed ?? live.length;
|
|
252
|
+
const left = [
|
|
253
|
+
feature.folded ? `folded: ${feature.folded.split("\n").join("; ")}` : "",
|
|
254
|
+
all.some((p) => p.status === "dropped")
|
|
255
|
+
? `dropped: ${all
|
|
256
|
+
.filter((p) => p.status === "dropped")
|
|
257
|
+
.map((p) => p.name)
|
|
258
|
+
.join(", ")}`
|
|
259
|
+
: "",
|
|
260
|
+
].filter(Boolean);
|
|
261
|
+
const short =
|
|
262
|
+
listed > live.length || left.length
|
|
263
|
+
? ` Its plan listed ${listed}; check the rest landed (${left.join("; ") || "not built as pieces"}).`
|
|
264
|
+
: "";
|
|
249
265
|
say(
|
|
250
266
|
feature,
|
|
251
267
|
`done-${feature.id}`,
|
|
252
|
-
`Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}
|
|
268
|
+
`Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}.${short}`
|
|
253
269
|
);
|
|
254
270
|
deps.pushFeatureMerged(feature);
|
|
255
271
|
}
|
|
256
272
|
|
|
273
|
+
// A dependency merged and the piece after it still hasn't started: the sweep
|
|
274
|
+
// that should have started it is stuck, or the start keeps being held.
|
|
275
|
+
export const STUCK_PIECE_MS = 30 * 60 * 1000;
|
|
276
|
+
|
|
277
|
+
function stuckPieces(feature: Feature, all: FeaturePiece[], now: number): void {
|
|
278
|
+
for (const piece of all) {
|
|
279
|
+
if (piece.status !== "waiting" || !piece.after_position) continue;
|
|
280
|
+
const dep = all.find((p) => p.position === piece.after_position);
|
|
281
|
+
const merged = dep ? sessionOf(dep.session_id)?.merged_at : null;
|
|
282
|
+
if (!dep || dep.status !== "merged" || !merged) continue;
|
|
283
|
+
const since = Date.parse(`${merged}Z`);
|
|
284
|
+
const wait =
|
|
285
|
+
piece.after_kind === "deployed" ? STUCK_PIECE_MS * 2 : STUCK_PIECE_MS;
|
|
286
|
+
if (now - since < wait) continue;
|
|
287
|
+
queueEventOnce(
|
|
288
|
+
feature.workspace_id,
|
|
289
|
+
`feature:stuck-${piece.id}-${since}`,
|
|
290
|
+
feature.plan_session_id,
|
|
291
|
+
`Feature "${feature.name}": piece ${piece.position} ${piece.name} is still waiting ${Math.floor((now - since) / 60000)}m after ${dep.position} ${dep.name} merged. Release it anyway or drop it.`,
|
|
292
|
+
now
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
257
297
|
export async function releasePieces(
|
|
258
|
-
deps: ReleaseDeps = realDeps
|
|
298
|
+
deps: ReleaseDeps = realDeps,
|
|
299
|
+
now = Date.now()
|
|
259
300
|
): Promise<void> {
|
|
260
301
|
const features = db
|
|
261
302
|
.prepare(`SELECT * FROM features WHERE status = 'building'`)
|
|
@@ -267,6 +308,7 @@ export async function releasePieces(
|
|
|
267
308
|
await releaseOne(feature, piecesOf(feature.id), piece, deps).catch((e) =>
|
|
268
309
|
console.error(`[features] piece ${piece.id}:`, e)
|
|
269
310
|
);
|
|
311
|
+
stuckPieces(feature, piecesOf(feature.id), now);
|
|
270
312
|
close(feature, piecesOf(feature.id), deps);
|
|
271
313
|
}
|
|
272
314
|
}
|
package/lib/recipes/draft.ts
CHANGED
|
@@ -40,10 +40,10 @@ const SCHEMA = {
|
|
|
40
40
|
required: ["brief", "summary"],
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
):
|
|
43
|
+
export type RecipePrefill = Omit<RecipeDraft, "draft">;
|
|
44
|
+
|
|
45
|
+
// The form's deterministic part; refuses a session that can't become a recipe.
|
|
46
|
+
export function recipePrefill(sessionId: string): RecipePrefill {
|
|
47
47
|
const s = queries.getSession(db).get(sessionId) as Session | undefined;
|
|
48
48
|
if (!s) throw new Error("Session not found");
|
|
49
49
|
const project = s.project_id ? getProject(s.project_id) : null;
|
|
@@ -52,34 +52,6 @@ export async function recipeDraft(
|
|
|
52
52
|
const first = itemsOfKind(s.id, "user")[0]?.text ?? "";
|
|
53
53
|
const prompt = (s.task_prompt || first).trim().slice(0, MAX_BRIEF);
|
|
54
54
|
if (!prompt) throw new Error("That session has no prompt to save");
|
|
55
|
-
const final = finalMessage(s.id);
|
|
56
|
-
let draft: RecipeDraft["draft"] = null;
|
|
57
|
-
try {
|
|
58
|
-
const out = (await run({
|
|
59
|
-
cwd: os.tmpdir(),
|
|
60
|
-
system: SYSTEM,
|
|
61
|
-
prompt: [
|
|
62
|
-
`What it was asked:\n${prompt.slice(0, 1500)}`,
|
|
63
|
-
final ? `What it ended with:\n${final.slice(-2000)}` : "",
|
|
64
|
-
]
|
|
65
|
-
.filter(Boolean)
|
|
66
|
-
.join("\n\n"),
|
|
67
|
-
schema: SCHEMA,
|
|
68
|
-
tools: [],
|
|
69
|
-
role: "helper",
|
|
70
|
-
timeoutMs: 20_000,
|
|
71
|
-
})) as { brief?: unknown; summary?: unknown };
|
|
72
|
-
if (typeof out?.brief === "string" && out.brief.trim())
|
|
73
|
-
draft = {
|
|
74
|
-
brief: out.brief.trim().slice(0, MAX_BRIEF),
|
|
75
|
-
summary: typeof out.summary === "string" ? out.summary.trim() : "",
|
|
76
|
-
};
|
|
77
|
-
} catch (error) {
|
|
78
|
-
console.warn(
|
|
79
|
-
"[recipes] no drafted brief:",
|
|
80
|
-
error instanceof Error ? error.message : error
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
55
|
return {
|
|
84
56
|
name: recipeNameFrom(s.name),
|
|
85
57
|
workspaceId: project.workspace_id,
|
|
@@ -88,6 +60,63 @@ export async function recipeDraft(
|
|
|
88
60
|
model: s.model_overrides ?? null,
|
|
89
61
|
view: s.view,
|
|
90
62
|
prompt,
|
|
91
|
-
draft,
|
|
92
63
|
};
|
|
93
64
|
}
|
|
65
|
+
|
|
66
|
+
export const REPEATABLE_SYSTEM = `${SYSTEM} Also say whether this is a job someone would plausibly run again (repeatable); a one-off fix or a feature built once is not.`;
|
|
67
|
+
const REPEATABLE_SCHEMA = {
|
|
68
|
+
...SCHEMA,
|
|
69
|
+
properties: { ...SCHEMA.properties, repeatable: { type: "boolean" } },
|
|
70
|
+
required: ["brief", "summary", "repeatable"],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// The model's rewrite of a brief and final report (never the transcript).
|
|
74
|
+
export const draftRequest = (
|
|
75
|
+
prompt: string,
|
|
76
|
+
final: string | null,
|
|
77
|
+
withRepeatable = false
|
|
78
|
+
) => ({
|
|
79
|
+
cwd: os.tmpdir(),
|
|
80
|
+
system: withRepeatable ? REPEATABLE_SYSTEM : SYSTEM,
|
|
81
|
+
prompt: [
|
|
82
|
+
`What it was asked:\n${prompt.slice(0, 1500)}`,
|
|
83
|
+
final ? `What it ended with:\n${final.slice(-2000)}` : "",
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join("\n\n"),
|
|
87
|
+
schema: withRepeatable ? REPEATABLE_SCHEMA : SCHEMA,
|
|
88
|
+
tools: [] as string[],
|
|
89
|
+
role: "helper" as const,
|
|
90
|
+
timeoutMs: 20_000,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export function parseDraft(out: unknown): RecipeDraft["draft"] {
|
|
94
|
+
const o = out as { brief?: unknown; summary?: unknown } | null;
|
|
95
|
+
if (typeof o?.brief !== "string" || !o.brief.trim()) return null;
|
|
96
|
+
return {
|
|
97
|
+
brief: o.brief.trim().slice(0, MAX_BRIEF),
|
|
98
|
+
summary: typeof o.summary === "string" ? o.summary.trim() : "",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function recipeDraft(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
run: ClaudeRunner = runClaude,
|
|
105
|
+
// A brief already drafted for this session (a learning card's).
|
|
106
|
+
cached?: RecipeDraft["draft"]
|
|
107
|
+
): Promise<RecipeDraft> {
|
|
108
|
+
const base = recipePrefill(sessionId);
|
|
109
|
+
if (cached) return { ...base, draft: cached };
|
|
110
|
+
let draft: RecipeDraft["draft"] = null;
|
|
111
|
+
try {
|
|
112
|
+
draft = parseDraft(
|
|
113
|
+
await run(draftRequest(base.prompt, finalMessage(sessionId)))
|
|
114
|
+
);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.warn(
|
|
117
|
+
"[recipes] no drafted brief:",
|
|
118
|
+
error instanceof Error ? error.message : error
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return { ...base, draft };
|
|
122
|
+
}
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -459,6 +459,9 @@ export function startSettling(): void {
|
|
|
459
459
|
busy = true;
|
|
460
460
|
void settleRuns()
|
|
461
461
|
.then(() => releasePieces())
|
|
462
|
+
.then(async () =>
|
|
463
|
+
(await import("../orchestrator/reconcile")).reconcileSweep()
|
|
464
|
+
)
|
|
462
465
|
.catch((e) => console.error("[recipes] settle:", e))
|
|
463
466
|
.finally(() => {
|
|
464
467
|
busy = false;
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -209,13 +209,12 @@ function wokeUnread(
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
// When a row went to Done: marked by hand, quiet for 3 days, or (a task)
|
|
212
|
-
// last changed.
|
|
212
|
+
// when it was finished, else last changed.
|
|
213
213
|
export function doneAtMs(row: SidebarRow): number {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
: ms(row.session.updated_at);
|
|
214
|
+
if (row.settled === "manual") return ms(row.session.settled_at);
|
|
215
|
+
if (row.settled === "auto") return row.activeAt;
|
|
216
|
+
const { settled_at, merged_at, updated_at } = row.session;
|
|
217
|
+
return ms(settled_at) || ms(merged_at) || ms(updated_at);
|
|
219
218
|
}
|
|
220
219
|
|
|
221
220
|
const OPEN_RUN = ["queued", "starting", "running"];
|