@sigrun/sigrun 0.8.0 → 0.9.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/CHANGELOG.md +35 -87
- package/dist/config.js +47 -0
- package/dist/curation/host.js +125 -21
- package/dist/curation/runs.js +15 -4
- package/dist/extension.d.ts +268 -3
- package/dist/harness/boot.js +113 -3
- package/dist/harness/complete.js +127 -0
- package/dist/harness/decision-tool.js +93 -0
- package/dist/harness/decisions.js +477 -0
- package/dist/harness/event-bus.js +12 -0
- package/dist/harness/interactions.js +81 -0
- package/dist/harness/interactive-tools.js +97 -0
- package/dist/harness/loader.js +243 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +136 -1
- package/dist/harness/settings.js +41 -0
- package/dist/history/git.js +6 -2
- package/dist/jobs/fire.js +10 -0
- package/dist/jobs/observe.js +27 -2
- package/dist/server/conversation-ops.js +31 -0
- package/dist/server/core-views/skills-tree.js +3 -12
- package/dist/server/core-views/skills.js +11 -23
- package/dist/server/host-bridge.js +53 -1
- package/dist/server/routes/extensions.js +5 -0
- package/dist/server/routes/interactions.js +48 -1
- package/dist/server/routes/meta.js +17 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
- package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
- package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +58 -0
- package/node_modules/@sigrun/shared/package.json +6 -2
- package/package.json +6 -6
- package/webui/dist/assets/index-D5KowS7k.css +1 -0
- package/webui/dist/assets/index-DGIrLC6F.js +55 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B3uMNF59.css +0 -1
- package/webui/dist/assets/index-BXsCyod4.js +0 -55
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* extension; a throw later (tool execute) surfaces to the caller.
|
|
18
18
|
*/
|
|
19
19
|
import { AmbientDoc, AmbientOwnersDoc, validateAmbient, } from "./ambient.js";
|
|
20
|
-
import { InteractionDoc, InteractionsIndexDoc, applyAnswer, asInteractionRecord, indexPending, indexSettled, sweepSecretDelivery, takeSecretDelivery, validateInteraction, } from "./interactions.js";
|
|
20
|
+
import { InteractionDoc, InteractionsIndexDoc, QueuedHumanInputsDoc, applyAnswer, applyDecline, asInteractionRecord, indexPending, indexSettled, sweepSecretDelivery, takeSecretDelivery, validateInteraction, } from "./interactions.js";
|
|
21
21
|
import { toDurableContext } from "./context.js";
|
|
22
22
|
import { scopedExtensionStores, } from "./stores.js";
|
|
23
23
|
/** One seam violation. Name carries the surface ("interaction"/"ambient"). */
|
|
@@ -212,11 +212,79 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
212
212
|
throw new SeamError("interaction", problem);
|
|
213
213
|
context = toDurableContext(context);
|
|
214
214
|
const h = await harness(context);
|
|
215
|
+
// A declinable record seeded after a human message is immediately
|
|
216
|
+
// declined when that message is still queued (ask-user Task 2,
|
|
217
|
+
// spec 5.3): the message was admitted BEFORE the card existed and
|
|
218
|
+
// must never get stuck behind it. Read each recorded submission's
|
|
219
|
+
// current status here (outside the commit — submission status is
|
|
220
|
+
// a table read) and drop any entry that is no longer queued.
|
|
221
|
+
let preDeclineUser;
|
|
222
|
+
let preDeclined = false;
|
|
223
|
+
const stillQueuedIds = new Set();
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
if (record.declinable === true) {
|
|
226
|
+
const queuedDoc = await h.snapshot(QueuedHumanInputsDoc, conversationId, context);
|
|
227
|
+
const byId = queuedDoc?.byId ?? {};
|
|
228
|
+
// Snapshot key set: the commit-time cleanup below may only drop
|
|
229
|
+
// keys THIS read saw. A submission recorded by a concurrent
|
|
230
|
+
// submitOp ledger commit that lands between the snapshot and our
|
|
231
|
+
// commit is not in `seen` and must survive.
|
|
232
|
+
for (const key of Object.keys(byId))
|
|
233
|
+
seen.add(key);
|
|
234
|
+
const stillQueued = [];
|
|
235
|
+
for (const [submissionId] of Object.entries(byId)) {
|
|
236
|
+
const submission = await h.submission(Number(submissionId), context);
|
|
237
|
+
if (submission === undefined)
|
|
238
|
+
continue; // gone — drop below
|
|
239
|
+
const status = await submission.status(context);
|
|
240
|
+
// Fork isolation: QueuedHumanInputsDoc forks with the
|
|
241
|
+
// conversation, so a child inherits the parent's entries.
|
|
242
|
+
// Only same-conversation submissions count as queued here.
|
|
243
|
+
if (status.status === "queued" &&
|
|
244
|
+
status.conversationId === conversationId) {
|
|
245
|
+
stillQueued.push(submissionId);
|
|
246
|
+
stillQueuedIds.add(submissionId);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (stillQueued.length > 0) {
|
|
250
|
+
// Earliest recorded submission wins (ascending id order).
|
|
251
|
+
stillQueued.sort((a, b) => Number(a) - Number(b));
|
|
252
|
+
preDeclineUser = byId[stillQueued[0]]?.user;
|
|
253
|
+
preDeclined = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
215
256
|
await h.commit(async (tx) => {
|
|
216
257
|
// Maintain the index in the SAME commit as the record.
|
|
217
258
|
const existing = await tx.doc(InteractionDoc, conversationId, record.callId, record.callId);
|
|
218
259
|
if (existing.status !== "pending")
|
|
219
260
|
return; // first-write wins
|
|
261
|
+
if (record.declinable === true) {
|
|
262
|
+
// Drop ledger entries whose submission is no longer queued
|
|
263
|
+
// (they were read above; only stale ones are removed).
|
|
264
|
+
const queuedDraft = await tx.doc(QueuedHumanInputsDoc, conversationId);
|
|
265
|
+
const mutableById = queuedDraft.byId;
|
|
266
|
+
for (const key of Object.keys(mutableById)) {
|
|
267
|
+
if (seen.has(key) && !stillQueuedIds.has(key)) {
|
|
268
|
+
delete mutableById[key];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (preDeclined) {
|
|
273
|
+
// Seed already settled: the human's queued message declines
|
|
274
|
+
// the question before it is ever shown. Reuses applyDecline's
|
|
275
|
+
// single definition of the settled shape.
|
|
276
|
+
Object.assign(existing, record);
|
|
277
|
+
// A self-declared-terminal seed (the extension passed a
|
|
278
|
+
// non-pending status) must NOT survive the assign: the
|
|
279
|
+
// queued human message declined it, so applyDecline stamps
|
|
280
|
+
// declinedVia/answeredBy on a pending baseline.
|
|
281
|
+
Object.assign(existing, { status: "pending" });
|
|
282
|
+
applyDecline(existing, "message", Date.now(), preDeclineUser);
|
|
283
|
+
// No index entry — it is not pending. Sweep any secret
|
|
284
|
+
// delivery staged for this record (defensive).
|
|
285
|
+
sweepSecretDelivery(conversationId, record.callId);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
220
288
|
Object.assign(existing, record);
|
|
221
289
|
const index = await tx.doc(InteractionsIndexDoc);
|
|
222
290
|
indexPending(index, conversationId, record.callId);
|
|
@@ -264,6 +332,22 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
264
332
|
return true;
|
|
265
333
|
}, context);
|
|
266
334
|
},
|
|
335
|
+
async declineInteraction(conversationId, callId, context, opts) {
|
|
336
|
+
context = toDurableContext(context);
|
|
337
|
+
const h = await harness(context);
|
|
338
|
+
// Same commit shape as settleInteraction: the sweep lands INSIDE
|
|
339
|
+
// the commit and only on the applied terminal transition.
|
|
340
|
+
return await h.commit(async (tx) => {
|
|
341
|
+
const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
|
|
342
|
+
const outcome = applyDecline(draft, "button", Date.now(), opts?.user);
|
|
343
|
+
if (outcome !== "declined")
|
|
344
|
+
return false;
|
|
345
|
+
const index = await tx.doc(InteractionsIndexDoc);
|
|
346
|
+
indexSettled(index, conversationId, callId);
|
|
347
|
+
sweepSecretDelivery(conversationId, callId);
|
|
348
|
+
return true;
|
|
349
|
+
}, context);
|
|
350
|
+
},
|
|
267
351
|
async peekInteraction(conversationId, callId, context) {
|
|
268
352
|
// The non-consuming inspection read (release-readiness-2 §1):
|
|
269
353
|
// the SAME stored projection as snapshotInteraction, with NO
|
|
@@ -422,6 +506,12 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
422
506
|
async get(id) {
|
|
423
507
|
return (await hostBridge("jobs.get")).jobs.get(id);
|
|
424
508
|
},
|
|
509
|
+
async activeRun(conversationId) {
|
|
510
|
+
return (await hostBridge("jobs.activeRun")).jobs.activeRun(conversationId);
|
|
511
|
+
},
|
|
512
|
+
async isJobRun(conversationId, context) {
|
|
513
|
+
return (await hostBridge("jobs.isJobRun")).jobs.isJobRun(conversationId, context);
|
|
514
|
+
},
|
|
425
515
|
},
|
|
426
516
|
users: {
|
|
427
517
|
async list() {
|
|
@@ -530,6 +620,29 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
530
620
|
async list() {
|
|
531
621
|
return (await hostBridge("models.list")).models.list();
|
|
532
622
|
},
|
|
623
|
+
async complete(input) {
|
|
624
|
+
return (await hostBridge("models.complete")).models.complete(input);
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
// Warden Task 6: the decisions surface delegates to the host
|
|
628
|
+
// bridge (the bridge owns the registry's body); `caller` is the
|
|
629
|
+
// identity name read AT CALL TIME (the same rekey-aware pattern as
|
|
630
|
+
// `publishedHooks.call` — the host rekeys in place), so a calling
|
|
631
|
+
// extension can never claim another identity.
|
|
632
|
+
decisions: {
|
|
633
|
+
async evaluate(request, opts = {}) {
|
|
634
|
+
const bridge = await hostBridge("decisions.evaluate");
|
|
635
|
+
if (bridge.decisions === undefined) {
|
|
636
|
+
return { ok: false, code: "no_model", error: "no decision provider is registered" };
|
|
637
|
+
}
|
|
638
|
+
return bridge.decisions.evaluate(identity.name, request, opts);
|
|
639
|
+
},
|
|
640
|
+
async list() {
|
|
641
|
+
const bridge = await hostBridge("decisions.list");
|
|
642
|
+
if (bridge.decisions === undefined)
|
|
643
|
+
return [];
|
|
644
|
+
return bridge.decisions.list();
|
|
645
|
+
},
|
|
533
646
|
},
|
|
534
647
|
commands: {
|
|
535
648
|
async list() {
|
|
@@ -587,6 +700,28 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
|
|
|
587
700
|
});
|
|
588
701
|
},
|
|
589
702
|
},
|
|
703
|
+
// Knowledge C7: the seam's published-hook surface. `get` reads the
|
|
704
|
+
// slot directly (undefined while loading still runs); `call` waits
|
|
705
|
+
// for the registry-ready deferred, then delegates with the caller
|
|
706
|
+
// identity read AT CALL TIME (the host rekeys in place).
|
|
707
|
+
publishedHooks: {
|
|
708
|
+
get(name) {
|
|
709
|
+
return options?.publishedHooks?.current.registry?.get(name);
|
|
710
|
+
},
|
|
711
|
+
async call(name, payload, opts) {
|
|
712
|
+
const slot = options?.publishedHooks;
|
|
713
|
+
if (slot === undefined) {
|
|
714
|
+
// No registry wiring (a bare-loader test rig): every name is
|
|
715
|
+
// absent — `call` never throws.
|
|
716
|
+
return { status: "absent" };
|
|
717
|
+
}
|
|
718
|
+
await slot.ready;
|
|
719
|
+
const registry = slot.current.registry;
|
|
720
|
+
if (registry === undefined)
|
|
721
|
+
return { status: "absent" };
|
|
722
|
+
return registry.call(identity.name, name, payload, opts);
|
|
723
|
+
},
|
|
724
|
+
},
|
|
590
725
|
bindBridge(bridge) {
|
|
591
726
|
bridgeBound = true;
|
|
592
727
|
bridgeReleased(bridge);
|
package/dist/harness/settings.js
CHANGED
|
@@ -91,6 +91,8 @@ const SETTING_KEYS = [
|
|
|
91
91
|
"jobHistory",
|
|
92
92
|
"defaultModel",
|
|
93
93
|
"namingModel",
|
|
94
|
+
"decisionModel",
|
|
95
|
+
"decisions",
|
|
94
96
|
"defaultProvider",
|
|
95
97
|
"skills",
|
|
96
98
|
"notifications",
|
|
@@ -326,6 +328,33 @@ function validate(value) {
|
|
|
326
328
|
throw new SettingsValidationError("namingModel", "must have a non-empty provider and model id");
|
|
327
329
|
}
|
|
328
330
|
}
|
|
331
|
+
if (value.decisionModel !== undefined) {
|
|
332
|
+
// Exactly defaultModel's spec rules (no `false` disable — a
|
|
333
|
+
// decision call without a default resolves the first-registered
|
|
334
|
+
// provider's first model).
|
|
335
|
+
const spec = value.decisionModel;
|
|
336
|
+
if (typeof spec !== "string" || spec.trim() === "") {
|
|
337
|
+
throw new SettingsValidationError("decisionModel", "must be a non-empty provider/model-id string");
|
|
338
|
+
}
|
|
339
|
+
const trimmed = spec.trim();
|
|
340
|
+
const slash = trimmed.indexOf("/");
|
|
341
|
+
if (slash !== -1 &&
|
|
342
|
+
(trimmed.slice(0, slash) === "" || trimmed.slice(slash + 1) === "")) {
|
|
343
|
+
throw new SettingsValidationError("decisionModel", "must have a non-empty provider and model id");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// The decision surface's toggles (warden Task 6): a flat section —
|
|
347
|
+
// `tool` a boolean.
|
|
348
|
+
if (value.decisions !== undefined) {
|
|
349
|
+
const decisions = asRecord(value.decisions);
|
|
350
|
+
if (decisions === undefined) {
|
|
351
|
+
throw new SettingsValidationError("decisions", "must be an object");
|
|
352
|
+
}
|
|
353
|
+
checkKeys("decisions.", decisions, ["tool"]);
|
|
354
|
+
if (decisions.tool !== undefined && typeof decisions.tool !== "boolean") {
|
|
355
|
+
throw new SettingsValidationError("decisions.tool", "must be a boolean");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
329
358
|
if (value.defaultProvider !== undefined) {
|
|
330
359
|
const provider = asRecord(value.defaultProvider);
|
|
331
360
|
if (provider === undefined) {
|
|
@@ -418,6 +447,7 @@ function validateServer(server) {
|
|
|
418
447
|
"dev",
|
|
419
448
|
"supervised",
|
|
420
449
|
"backup",
|
|
450
|
+
"decisions",
|
|
421
451
|
]);
|
|
422
452
|
// The jobs default timezone (§8.2): an IANA name — `Intl` accepts
|
|
423
453
|
// exactly the names croner's evaluation path resolves, and throws on
|
|
@@ -570,6 +600,17 @@ function validateServer(server) {
|
|
|
570
600
|
}
|
|
571
601
|
}
|
|
572
602
|
}
|
|
603
|
+
if (record.decisions !== undefined) {
|
|
604
|
+
const decisions = asRecord(record.decisions);
|
|
605
|
+
if (decisions === undefined) {
|
|
606
|
+
throw new SettingsValidationError("server.decisions", "must be an object");
|
|
607
|
+
}
|
|
608
|
+
checkKeys("server.decisions.", decisions, ["timeoutMs"]);
|
|
609
|
+
if (decisions.timeoutMs !== undefined &&
|
|
610
|
+
!isPositiveInt(decisions.timeoutMs)) {
|
|
611
|
+
throw new SettingsValidationError("server.decisions.timeoutMs", "must be a positive integer");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
573
614
|
if (record.isolation !== undefined) {
|
|
574
615
|
const isolation = asRecord(record.isolation);
|
|
575
616
|
if (isolation === undefined) {
|
package/dist/history/git.js
CHANGED
|
@@ -490,8 +490,12 @@ export class GitHistory {
|
|
|
490
490
|
}
|
|
491
491
|
/** Corrupt git dir → move aside, re-init, snapshot commit (loud log). */
|
|
492
492
|
async recoverCorrupt() {
|
|
493
|
-
|
|
494
|
-
|
|
493
|
+
// ONE stamp: the summary must name the directory the rename made —
|
|
494
|
+
// two utcStamp(now) calls can straddle a clock tick and name a
|
|
495
|
+
// path that was never created (seen as a CI flake).
|
|
496
|
+
const stamp = utcStamp(this.now());
|
|
497
|
+
const brokenName = `${this.spec.gitDir.split(sep).pop() ?? "repo"}.broken-${stamp}`;
|
|
498
|
+
const brokenPath = `${this.spec.gitDir}.broken-${stamp}`;
|
|
495
499
|
await rename(this.spec.gitDir, brokenPath);
|
|
496
500
|
this.logLine(`corrupt git dir moved aside: ${this.spec.gitDir} → ${brokenPath}`);
|
|
497
501
|
await this.run(["init", "--initial-branch=main"]);
|
package/dist/jobs/fire.js
CHANGED
|
@@ -38,6 +38,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
|
38
38
|
import { parseModelSpec } from "../config.js";
|
|
39
39
|
import { expandInput } from "../commands/expand.js";
|
|
40
40
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
41
|
+
import { excludeInteractiveTools } from "../harness/interactive-tools.js";
|
|
41
42
|
import { noteThen } from "../server/turns/submit-turn.js";
|
|
42
43
|
import { recordAuthor } from "../curation/attribution.js";
|
|
43
44
|
import { JobRunDoc, patchJob, readJob } from "./docs.js";
|
|
@@ -172,6 +173,11 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
|
|
|
172
173
|
if (prior === undefined) {
|
|
173
174
|
await deps.harness.commit(async (tx) => {
|
|
174
175
|
await tx.doc(JobRunDoc, fireKey, run);
|
|
176
|
+
// NO meta.jobId stamp here (the 2026-11-05 reconciliation with
|
|
177
|
+
// ask-user's isJobRun): a fire INTO an existing conversation must
|
|
178
|
+
// not mark every later run of that conversation job-driven. The
|
|
179
|
+
// runSettled observer settles this run from the submission's
|
|
180
|
+
// `job:<fireKey>` requestId instead (observe.ts).
|
|
175
181
|
}, context);
|
|
176
182
|
await patchJob(deps.harness, id, (draft) => {
|
|
177
183
|
draft.lastRun = run;
|
|
@@ -233,6 +239,10 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
|
|
|
233
239
|
if (owner !== undefined)
|
|
234
240
|
meta.owner = owner;
|
|
235
241
|
await deps.rewriteUserSelection?.(tx, newId, undefined, owner);
|
|
242
|
+
// Ask-user Task 3: the job-run conversation never offers an
|
|
243
|
+
// interactive tool (the tool is never listed, so the model
|
|
244
|
+
// never tries — §6's prompt section keys off tool presence).
|
|
245
|
+
await excludeInteractiveTools(tx, newId, deps.interactiveTools ?? new Set());
|
|
236
246
|
if (action.runSharing !== undefined)
|
|
237
247
|
meta.sharing = action.runSharing;
|
|
238
248
|
meta.name = runName(record.spec.name, deps.now(), record, deps);
|
package/dist/jobs/observe.js
CHANGED
|
@@ -66,9 +66,34 @@ export function registerJobObservers(deps) {
|
|
|
66
66
|
// auto-namer — a job observation never delays an extension's).
|
|
67
67
|
const runSettled = async (event) => {
|
|
68
68
|
try {
|
|
69
|
+
// The job to settle from, in resolution order:
|
|
70
|
+
// 1. the session meta's `jobId` (a `conversation: "new"` fire —
|
|
71
|
+
// the stamp persists for the conversation's life);
|
|
72
|
+
// 2. a `job:<fireKey>` requestId on one of the run's inputs (the
|
|
73
|
+
// FIXED-conversation fire shape — `jobs/fire.ts` stamps the
|
|
74
|
+
// prefix on BOTH shapes; a fire INTO an existing conversation
|
|
75
|
+
// deliberately leaves the meta unstamped so later ordinary
|
|
76
|
+
// runs there are not job runs, per isJobRun's contract).
|
|
77
|
+
let jobId;
|
|
69
78
|
const meta = await deps.harness.snapshot(SessionMetaDoc, event.conversationId, ctx);
|
|
70
|
-
const
|
|
71
|
-
if (typeof
|
|
79
|
+
const metaJobId = meta?.jobId;
|
|
80
|
+
if (typeof metaJobId === "string") {
|
|
81
|
+
jobId = metaJobId;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
for (const id of event.inputs) {
|
|
85
|
+
const submission = await deps.harness.submission(id, ctx);
|
|
86
|
+
if (submission === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const record = await submission.status(ctx);
|
|
89
|
+
const requestId = record.requestId;
|
|
90
|
+
if (requestId !== undefined && requestId.startsWith("job:")) {
|
|
91
|
+
jobId = requestId.slice("job:".length).split(":")[0];
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (jobId === undefined)
|
|
72
97
|
return;
|
|
73
98
|
const runs = await listRuns(deps.storage, jobId, Number.MAX_SAFE_INTEGER, ctx);
|
|
74
99
|
const run = runs.find((one) => one.outcome === "running" &&
|
|
@@ -20,7 +20,9 @@ import { AgentDoc, configure } from "@earendil-works/pi-durable";
|
|
|
20
20
|
import { accessOf, isSharing, } from "@sigrun/shared/access";
|
|
21
21
|
import { extname } from "node:path";
|
|
22
22
|
import { touchActivity } from "../harness/activity.js";
|
|
23
|
+
import { excludeInteractiveTools } from "../harness/interactive-tools.js";
|
|
23
24
|
import { ensureRequestId, recordAuthor, } from "../curation/attribution.js";
|
|
25
|
+
import { QueuedHumanInputsDoc, declinePendingOnMessage, } from "../harness/interactions.js";
|
|
24
26
|
import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
|
|
25
27
|
import { isSuperuser } from "./auth/principal.js";
|
|
26
28
|
import { isConfinedPath, parseSubmissionInput } from "./content-parts.js";
|
|
@@ -243,6 +245,16 @@ export async function createConversationOp(ctx, input = {}) {
|
|
|
243
245
|
if (input.source !== undefined)
|
|
244
246
|
draft.source = input.source;
|
|
245
247
|
await opened.rewriteUserSelection(tx, conversation.id, undefined, input.owner);
|
|
248
|
+
// Ask-user Task 3 (spec §6): a conversation created FOR a job hides
|
|
249
|
+
// the interactive tools — the creation-time filter edits the stored
|
|
250
|
+
// enabled-tools filter, so the tool is neither offered nor reachable
|
|
251
|
+
// from nested `executeTool()` calls. An explicit `agent.tools`
|
|
252
|
+
// selection naming one loses it the same way. A SECOND commit after
|
|
253
|
+
// `harness.createConversation` is fine: document access is
|
|
254
|
+
// unrestricted here.
|
|
255
|
+
if (input.job !== undefined) {
|
|
256
|
+
await excludeInteractiveTools(tx, conversation.id, opened.interactiveTools);
|
|
257
|
+
}
|
|
246
258
|
}, BACKGROUND_CONTEXT);
|
|
247
259
|
const view = await readView(conversation);
|
|
248
260
|
return { id: conversation.id, view };
|
|
@@ -390,6 +402,25 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
|
|
|
390
402
|
// The submit pass-through is retired (§6.7): the memory seam's
|
|
391
403
|
// runSettled owns capture, so submissions admit directly.
|
|
392
404
|
const submission = await conversation.submit(draft, BACKGROUND_CONTEXT);
|
|
405
|
+
// Ask-user Task 2: a human message (no entry, declinesQuestions: true)
|
|
406
|
+
// declines every pending declinable question and records itself in the
|
|
407
|
+
// queued-inputs ledger when it was admitted while busy. A rejected
|
|
408
|
+
// submit (whenBusy: "reject" while busy) throws above and reaches none
|
|
409
|
+
// of this.
|
|
410
|
+
if (opts.declinesQuestions === true && opts.entry === undefined) {
|
|
411
|
+
const declined = await declinePendingOnMessage(opened.harness, conversation.id, opts.user, Date.now(), BACKGROUND_CONTEXT);
|
|
412
|
+
void declined; // the returned callIds are observational only
|
|
413
|
+
const admitted = await submission.status(BACKGROUND_CONTEXT);
|
|
414
|
+
if (admitted.status === "queued") {
|
|
415
|
+
await opened.harness.commit(async (tx) => {
|
|
416
|
+
const doc = await tx.doc(QueuedHumanInputsDoc, conversation.id);
|
|
417
|
+
const byId = doc.byId;
|
|
418
|
+
byId[String(submission.id)] = {
|
|
419
|
+
...(opts.user !== undefined ? { user: opts.user } : {}),
|
|
420
|
+
};
|
|
421
|
+
}, BACKGROUND_CONTEXT);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
393
424
|
await recordAuthor(conversation, { submissionId: submission.id }, opts.user, BACKGROUND_CONTEXT);
|
|
394
425
|
// The implied clear (notices spec §3.2 rule 2): the user's next prompt
|
|
395
426
|
// in a conversation is them dealing with that conversation's notices,
|
|
@@ -346,20 +346,11 @@ function skillActions(params, historyAvailable, deleted) {
|
|
|
346
346
|
}
|
|
347
347
|
return buttons;
|
|
348
348
|
}
|
|
349
|
-
/** The form fields (new skill vs edit: the name is read-only on an
|
|
349
|
+
/** The form fields (new skill vs edit: the name is read-only on an
|
|
350
|
+
* existing skill). A new skill always lands in the workspace: the
|
|
351
|
+
* learned root is the curator's, so the form offers no choice. */
|
|
350
352
|
function formFields(editingExisting) {
|
|
351
|
-
const whereChoice = {
|
|
352
|
-
type: "choice",
|
|
353
|
-
name: "where",
|
|
354
|
-
label: "Where",
|
|
355
|
-
required: true,
|
|
356
|
-
options: [
|
|
357
|
-
{ id: "workspace", label: "Workspace" },
|
|
358
|
-
{ id: "learned", label: "Learned" },
|
|
359
|
-
],
|
|
360
|
-
};
|
|
361
353
|
return [
|
|
362
|
-
...(editingExisting ? [] : [whereChoice]),
|
|
363
354
|
{
|
|
364
355
|
type: "text",
|
|
365
356
|
name: "name",
|
|
@@ -436,7 +436,7 @@ export function createSkillsView(deps) {
|
|
|
436
436
|
/** The form's pre-filled values (a new skill starts empty; an edit reads the current text). */
|
|
437
437
|
async function formDataOf(params, selection) {
|
|
438
438
|
if (params.skill === undefined || selection === undefined) {
|
|
439
|
-
return { name: "", description: "", body: ""
|
|
439
|
+
return { name: "", description: "", body: "" };
|
|
440
440
|
}
|
|
441
441
|
try {
|
|
442
442
|
const text = await readSkillFile(selection.skill.rootDir, selection.skill.name, "SKILL.md");
|
|
@@ -445,7 +445,6 @@ export function createSkillsView(deps) {
|
|
|
445
445
|
name: selection.skill.name,
|
|
446
446
|
description: parsed?.description ?? selection.skill.description,
|
|
447
447
|
body: parsed?.body ?? "",
|
|
448
|
-
where: "workspace",
|
|
449
448
|
};
|
|
450
449
|
}
|
|
451
450
|
catch {
|
|
@@ -453,7 +452,6 @@ export function createSkillsView(deps) {
|
|
|
453
452
|
name: selection.skill.name,
|
|
454
453
|
description: selection.skill.description,
|
|
455
454
|
body: "",
|
|
456
|
-
where: "workspace",
|
|
457
455
|
};
|
|
458
456
|
}
|
|
459
457
|
}
|
|
@@ -722,34 +720,24 @@ export function createSkillsView(deps) {
|
|
|
722
720
|
const user = userOf(ctx);
|
|
723
721
|
if (user === undefined)
|
|
724
722
|
return { ok: false, error: { message: "forbidden" } };
|
|
725
|
-
|
|
723
|
+
// New skills land in the workspace only: the learned root is
|
|
724
|
+
// the curator's (a user edits, pins or deletes learned skills,
|
|
725
|
+
// never creates one).
|
|
726
|
+
const where = fieldOf(payload, "where");
|
|
727
|
+
if (where !== undefined && where !== "workspace") {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: { message: "new skills go in the workspace; learned skills come from the curator", field: "where" },
|
|
731
|
+
};
|
|
732
|
+
}
|
|
726
733
|
const name = fieldOf(payload, "name");
|
|
727
734
|
const description = fieldOf(payload, "description");
|
|
728
|
-
const body = fieldOf(payload, "body");
|
|
729
735
|
if (name === undefined || !validSkillName(name)) {
|
|
730
736
|
return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
|
|
731
737
|
}
|
|
732
738
|
if (description === undefined || description.trim() === "") {
|
|
733
739
|
return { ok: false, error: { message: "a description is required", field: "description" } };
|
|
734
740
|
}
|
|
735
|
-
if (where === "learned") {
|
|
736
|
-
const curation = deps.curationOf(user);
|
|
737
|
-
if (curation === undefined) {
|
|
738
|
-
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
739
|
-
}
|
|
740
|
-
try {
|
|
741
|
-
await curation.store.apply(name, { kind: "create", content: skillFileText(name, description.trim(), body ?? "") }, { actor: "user" });
|
|
742
|
-
}
|
|
743
|
-
catch (error) {
|
|
744
|
-
return failure(error);
|
|
745
|
-
}
|
|
746
|
-
deps.bump();
|
|
747
|
-
return {
|
|
748
|
-
ok: true,
|
|
749
|
-
toast: `Created ${name}`,
|
|
750
|
-
navigate: { type: "navigate", params: { skill: name, root: "learned" } },
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
741
|
return workspaceWrite(ctx, payload, { mode: "create" });
|
|
754
742
|
},
|
|
755
743
|
save: async (ctx, payload) => {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
19
|
import { accessOf as accessOfShared } from "@sigrun/shared/access";
|
|
20
|
+
import { completeOnce } from "../harness/complete.js";
|
|
20
21
|
import { effectiveAdmin } from "../harness/identity/groups.js";
|
|
21
22
|
import { userWire } from "./routes/users.js";
|
|
22
23
|
import { accessPrincipalOf } from "./auth/access-principal.js";
|
|
@@ -24,6 +25,7 @@ import { abortOp, compactOp, configureOp, ConversationOpError, createConversatio
|
|
|
24
25
|
import { listModels } from "./helpers.js";
|
|
25
26
|
import { notifyExtension } from "./notifications.js";
|
|
26
27
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
28
|
+
import { isJobRunConversation } from "../harness/interactive-tools.js";
|
|
27
29
|
import { accountLinkWire, serializeConversationView } from "./views.js";
|
|
28
30
|
/**
|
|
29
31
|
* Host-side callers see effective admin derived from stored groups;
|
|
@@ -70,6 +72,9 @@ export function createHostBridge(ctx) {
|
|
|
70
72
|
...(opts?.whenBusy === undefined
|
|
71
73
|
? {}
|
|
72
74
|
: { whenBusy: opts.whenBusy }),
|
|
75
|
+
// Ask-user Task 2: a host submit counts as a human message
|
|
76
|
+
// (declines pending questions) only when it carries a user.
|
|
77
|
+
declinesQuestions: opts?.user !== undefined,
|
|
73
78
|
});
|
|
74
79
|
},
|
|
75
80
|
async watch(id, listener) {
|
|
@@ -193,6 +198,32 @@ export function createHostBridge(ctx) {
|
|
|
193
198
|
state: job.state,
|
|
194
199
|
};
|
|
195
200
|
},
|
|
201
|
+
async activeRun(conversationId) {
|
|
202
|
+
const jobs = await opened.jobs.list();
|
|
203
|
+
let best;
|
|
204
|
+
for (const job of jobs) {
|
|
205
|
+
const run = job.lastRun;
|
|
206
|
+
if (run === undefined)
|
|
207
|
+
continue;
|
|
208
|
+
if (run.outcome !== "running")
|
|
209
|
+
continue;
|
|
210
|
+
if (run.conversationId !== Number(conversationId))
|
|
211
|
+
continue;
|
|
212
|
+
if (best !== undefined && run.startedAt <= best.startedAt)
|
|
213
|
+
continue;
|
|
214
|
+
best = { jobId: job.id, owner: job.owner, startedAt: run.startedAt };
|
|
215
|
+
}
|
|
216
|
+
// startedAt is the tiebreak only — the wire shape is {jobId, owner}.
|
|
217
|
+
return best === undefined ? undefined : { jobId: best.jobId, owner: best.owner };
|
|
218
|
+
},
|
|
219
|
+
async isJobRun(conversationId) {
|
|
220
|
+
// The detection reads COMMITTED durable state (session meta,
|
|
221
|
+
// the live doc, submission records) — it never participates in
|
|
222
|
+
// the caller's transaction, so the bridge's own background
|
|
223
|
+
// context is the honest one (the HandlerContext arg is a
|
|
224
|
+
// cancellation surface only).
|
|
225
|
+
return await isJobRunConversation(opened.harness, conversationId, BACKGROUND_CONTEXT);
|
|
226
|
+
},
|
|
196
227
|
},
|
|
197
228
|
users: {
|
|
198
229
|
// The directory in wire form (emails are admin material; the
|
|
@@ -252,11 +283,32 @@ export function createHostBridge(ctx) {
|
|
|
252
283
|
},
|
|
253
284
|
},
|
|
254
285
|
// Gateway C4b: the model catalog — the SAME source function the
|
|
255
|
-
// `GET /v1/models` route serves (one construction, never two)
|
|
286
|
+
// `GET /v1/models` route serves (one construction, never two) —
|
|
287
|
+
// plus one-shot completions (warden Task 4), read-live settings
|
|
288
|
+
// per call so a `namingModel` change applies without a restart.
|
|
289
|
+
// Warden Task 6: the decision registry — the seam host's
|
|
290
|
+
// `decisions` members delegate here; `slug` is the CALLING
|
|
291
|
+
// extension's identity (read at call time), which the registry
|
|
292
|
+
// stamps as `caller` on the provider's context.
|
|
293
|
+
decisions: {
|
|
294
|
+
async evaluate(slug, request, opts = {}) {
|
|
295
|
+
return opened.decisions.evaluate(slug, request, opts);
|
|
296
|
+
},
|
|
297
|
+
async list() {
|
|
298
|
+
return opened.decisions.list();
|
|
299
|
+
},
|
|
300
|
+
},
|
|
256
301
|
models: {
|
|
257
302
|
async list() {
|
|
258
303
|
return listModels(opened);
|
|
259
304
|
},
|
|
305
|
+
async complete(input) {
|
|
306
|
+
return completeOnce({
|
|
307
|
+
models: opened.models,
|
|
308
|
+
defaultModel: opened.defaultModel,
|
|
309
|
+
readSettings: () => opened.settings.read(),
|
|
310
|
+
}, input);
|
|
311
|
+
},
|
|
260
312
|
},
|
|
261
313
|
commands: {
|
|
262
314
|
// The merged catalog rows (`GET /v1/commands`' core + extension
|
|
@@ -88,6 +88,11 @@ export function registerExtensionRoutes(app, ctx) {
|
|
|
88
88
|
...(def.sharedData === true
|
|
89
89
|
? { sharedData: true }
|
|
90
90
|
: {}),
|
|
91
|
+
// The per-conversation opt-in (sparse: present only
|
|
92
|
+
// when the panel opted in).
|
|
93
|
+
...(def.perConversation === true
|
|
94
|
+
? { perConversation: true }
|
|
95
|
+
: {}),
|
|
91
96
|
})),
|
|
92
97
|
}),
|
|
93
98
|
};
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
36
36
|
import { AmbientDoc, AmbientOwnersDoc, asAmbientRecord, } from "../../harness/ambient.js";
|
|
37
|
-
import { InteractionDoc, InteractionsIndexDoc, answerFromBody, applyAnswer, asInteractionRecord, indexSettled, maskedAnswerValues, } from "../../harness/interactions.js";
|
|
37
|
+
import { InteractionDoc, InteractionsIndexDoc, answerFromBody, applyAnswer, applyDecline, asInteractionRecord, indexSettled, maskedAnswerValues, sweepSecretDelivery, } from "../../harness/interactions.js";
|
|
38
38
|
import { loadConversation } from "../conversation-handler.js";
|
|
39
39
|
import { ID_PARAMS_SCHEMA, conversationId } from "../helpers.js";
|
|
40
40
|
import { accessOf } from "@sigrun/shared/access";
|
|
@@ -221,6 +221,53 @@ export function registerInteractionRoutes(app, ctx) {
|
|
|
221
221
|
answer: committed.answer,
|
|
222
222
|
};
|
|
223
223
|
});
|
|
224
|
+
// The decline route (ask-user Task 1): the answer route's peer — a
|
|
225
|
+
// human declines a DECLINABLE record (status "skipped", declinedVia
|
|
226
|
+
// "button"). Same access (write), same commit structure, same
|
|
227
|
+
// indexSettled + reply-after-commit; every successful decline sweeps
|
|
228
|
+
// the record's unread secret delivery inside the commit (the
|
|
229
|
+
// settleInteraction pattern). 404 unknown / 404 not-pending /
|
|
230
|
+
// 409 not-declinable.
|
|
231
|
+
app.post("/v1/conversations/:id/interactions/:callId/decline", {
|
|
232
|
+
schema: { params: ANSWER_PARAMS_SCHEMA },
|
|
233
|
+
preHandler: loadConversation(ctx, "write"),
|
|
234
|
+
}, async (request, reply) => {
|
|
235
|
+
const { callId } = request.params;
|
|
236
|
+
const conversation = request.conversation;
|
|
237
|
+
const committed = await conversation.commit(async (tx) => {
|
|
238
|
+
const draft = await tx.doc(InteractionDoc, conversation.id, callId, callId);
|
|
239
|
+
// Unknown callId: the doc materializes empty — kind "" never
|
|
240
|
+
// seeded (the answer route's detection).
|
|
241
|
+
if (draft.kind === "") {
|
|
242
|
+
return { found: false };
|
|
243
|
+
}
|
|
244
|
+
const outcome = applyDecline(draft, "button", Date.now(), request.principal.user);
|
|
245
|
+
if (outcome !== "declined") {
|
|
246
|
+
// A loser of the first-wins race never sweeps: the delivery
|
|
247
|
+
// (when there is one) belongs to the winner (the answer
|
|
248
|
+
// route's rule).
|
|
249
|
+
return { found: true, outcome };
|
|
250
|
+
}
|
|
251
|
+
const index = await tx.doc(InteractionsIndexDoc);
|
|
252
|
+
indexSettled(index, conversation.id, callId);
|
|
253
|
+
sweepSecretDelivery(conversation.id, callId);
|
|
254
|
+
return { found: true, outcome };
|
|
255
|
+
}, BACKGROUND_CONTEXT);
|
|
256
|
+
if (!committed.found) {
|
|
257
|
+
return await reply.code(404).send({ error: "no_such_interaction" });
|
|
258
|
+
}
|
|
259
|
+
if (committed.outcome === "not_pending") {
|
|
260
|
+
return await reply.code(404).send({ error: "not_pending" });
|
|
261
|
+
}
|
|
262
|
+
if (committed.outcome === "not_declinable") {
|
|
263
|
+
return await reply.code(409).send({ error: "not_declinable" });
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
callId,
|
|
267
|
+
status: "skipped",
|
|
268
|
+
declinedVia: "button",
|
|
269
|
+
};
|
|
270
|
+
});
|
|
224
271
|
// ─── Ambient seam ────────────────────────────────────────────────────
|
|
225
272
|
// The conversation's ambient surfaces, replacement-state reads.
|
|
226
273
|
app.get("/v1/conversations/:id/ambient", { schema: { params: ID_PARAMS_SCHEMA } }, async (request, reply) => {
|