@sigrun/sigrun 0.7.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 +78 -59
- package/README.md +32 -0
- 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 +331 -4
- package/dist/harness/boot.js +114 -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 +285 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +149 -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/notices/events.js +8 -0
- package/dist/notices/service.js +297 -0
- package/dist/notices/store.js +331 -0
- package/dist/notices/validate.js +311 -0
- package/dist/server/conversation-ops.js +50 -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 +62 -1
- package/dist/server/index.js +26 -0
- package/dist/server/routes/events.js +20 -0
- 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/notices.js +94 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/server/routes/users.js +4 -0
- package/dist/server/surfaces/dedup.js +6 -0
- package/dist/server/visibility.js +30 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -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/notices.d.ts +112 -0
- package/node_modules/@sigrun/shared/dist/notices.js +49 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- 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-BI5k-Oci.js +0 -55
- package/webui/dist/assets/index-EGluyRXk.css +0 -1
|
@@ -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) => {
|
|
@@ -73,6 +73,12 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
73
73
|
// The §7.3 shared-data flag: channels refetch
|
|
74
74
|
// fully when a since fetch answers ops-only.
|
|
75
75
|
...(def.sharedData === true ? { sharedData: true } : {}),
|
|
76
|
+
// The per-conversation opt-in: channels attach the
|
|
77
|
+
// panel with the open conversation's id (sparse —
|
|
78
|
+
// present only when the panel opted in).
|
|
79
|
+
...(def.perConversation === true
|
|
80
|
+
? { perConversation: true }
|
|
81
|
+
: {}),
|
|
76
82
|
})),
|
|
77
83
|
}),
|
|
78
84
|
};
|
|
@@ -174,6 +180,17 @@ export function registerMetaRoutes(app, ctx) {
|
|
|
174
180
|
providers: opened.providerCollisions,
|
|
175
181
|
commands: commandCatalog.collisions,
|
|
176
182
|
},
|
|
183
|
+
// Knowledge C7: the published-hook registry — one row per OWNED
|
|
184
|
+
// hook name (`provider` is the owner slug; `shadowed` lists the
|
|
185
|
+
// losing providers). Empty when nothing published.
|
|
186
|
+
publishedHooks: opened.publishedHooks(),
|
|
187
|
+
// Warden Task 7: the decision-provider seam projection (the same
|
|
188
|
+
// pure-derivation discipline — the registry's boot-built rows).
|
|
189
|
+
decisions: {
|
|
190
|
+
providers: opened.decisions.providers(),
|
|
191
|
+
default: opened.decisions.resolvedDefault(),
|
|
192
|
+
collisions: opened.decisions.collisions(),
|
|
193
|
+
},
|
|
177
194
|
};
|
|
178
195
|
});
|
|
179
196
|
// The slash-command catalog: the thin metadata list the composer's `/`
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/v1/notices` routes (notices Task 4, spec §3/§4): the user's
|
|
3
|
+
* notice pile and its one action dispatch, over the shared
|
|
4
|
+
* `NoticesService` (the store files' one mediation point).
|
|
5
|
+
*
|
|
6
|
+
* Access: every notice id is per-user — a notice only ever exists on
|
|
7
|
+
* its recipient's pile. A foreign / unknown / user-less id is therefore
|
|
8
|
+
* indistinguishable from an absent one: 404 `notice_not_found` on
|
|
9
|
+
* every `:id` route (never a 403 tell), and the user-less principal's
|
|
10
|
+
* GET is the empty list. There is no cross-user selector: an admin
|
|
11
|
+
* asking with another user's id still 404s — the id is only ever
|
|
12
|
+
* addressed through the CALLER's pile.
|
|
13
|
+
*
|
|
14
|
+
* Route params: notice ids are UUID strings (store-assigned), so the
|
|
15
|
+
* schemas declare a string param — never the conversations'
|
|
16
|
+
* `ID_PARAMS_SCHEMA` (integer).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The one notice-id param: a UUID string (the store mints random ids).
|
|
20
|
+
* Declared as a string so Fastify's param validation cannot mask the
|
|
21
|
+
* lookup with an integer-coercion 400.
|
|
22
|
+
*/
|
|
23
|
+
const NOTICE_ID_PARAMS_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: { id: { type: "string" } },
|
|
26
|
+
required: ["id"],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
};
|
|
29
|
+
/** The action route's params: the notice id plus the action id. */
|
|
30
|
+
const NOTICE_ACTION_PARAMS_SCHEMA = {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { id: { type: "string" }, actionId: { type: "string" } },
|
|
33
|
+
required: ["id", "actionId"],
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
};
|
|
36
|
+
/** The action route's body: the optional client request id (dedup). */
|
|
37
|
+
const NOTICE_ACTION_BODY_SCHEMA = {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { requestId: { type: "string" } },
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
};
|
|
42
|
+
/** The not-found body — shared by every notice route's lookup. */
|
|
43
|
+
const NOT_FOUND = { error: "notice_not_found" };
|
|
44
|
+
/** Register the `/v1/notices` routes. */
|
|
45
|
+
export function registerNoticeRoutes(app, ctx) {
|
|
46
|
+
const { notices } = ctx;
|
|
47
|
+
// The user's own pile (spec §3): newest first, minus any
|
|
48
|
+
// conversation notice whose share was revoked (the service prunes
|
|
49
|
+
// them off disk on the same read). A user-less principal has no
|
|
50
|
+
// pile — the empty list, never an error.
|
|
51
|
+
app.get("/v1/notices", async (request) => {
|
|
52
|
+
const user = request.principal.user;
|
|
53
|
+
if (user === undefined)
|
|
54
|
+
return { notices: [] };
|
|
55
|
+
return { notices: await notices.listForUser(user) };
|
|
56
|
+
});
|
|
57
|
+
// Dismiss one of the caller's notices: 204 when it existed, 404
|
|
58
|
+
// when it did not (foreign / unknown / already dismissed — all the
|
|
59
|
+
// same answer).
|
|
60
|
+
app.post("/v1/notices/:id/dismiss", { schema: { params: NOTICE_ID_PARAMS_SCHEMA } }, async (request, reply) => {
|
|
61
|
+
const user = request.principal.user;
|
|
62
|
+
if (user === undefined) {
|
|
63
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
64
|
+
}
|
|
65
|
+
const { id } = request.params;
|
|
66
|
+
if (!(await notices.dismiss(user, id))) {
|
|
67
|
+
return await reply.code(404).send(NOT_FOUND);
|
|
68
|
+
}
|
|
69
|
+
return await reply.code(204).send();
|
|
70
|
+
});
|
|
71
|
+
// Dismiss everything of the caller's. Idempotent: always 204.
|
|
72
|
+
app.post("/v1/notices/dismiss-all", async (request, reply) => {
|
|
73
|
+
const user = request.principal.user;
|
|
74
|
+
if (user !== undefined) {
|
|
75
|
+
await notices.dismissAll(user);
|
|
76
|
+
}
|
|
77
|
+
return await reply.code(204).send();
|
|
78
|
+
});
|
|
79
|
+
// The one action dispatch (spec §4): the service's dispatch order
|
|
80
|
+
// (rate-limit → dedup → lookup → action find → binding find →
|
|
81
|
+
// handler call) answers the 200/400/404/409/429 union; the route
|
|
82
|
+
// only maps it onto the wire.
|
|
83
|
+
app.post("/v1/notices/:id/actions/:actionId", {
|
|
84
|
+
schema: {
|
|
85
|
+
params: NOTICE_ACTION_PARAMS_SCHEMA,
|
|
86
|
+
body: NOTICE_ACTION_BODY_SCHEMA,
|
|
87
|
+
},
|
|
88
|
+
}, async (request, reply) => {
|
|
89
|
+
const { id, actionId } = request.params;
|
|
90
|
+
const body = (request.body ?? {});
|
|
91
|
+
const answer = await notices.runAction(request.principal, id, actionId, body.requestId);
|
|
92
|
+
return await reply.code(answer.status).send(answer.body);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -87,8 +87,30 @@ function providerReadinessReport(opened) {
|
|
|
87
87
|
for (const { id: providerId } of opened.models.getProviders()) {
|
|
88
88
|
providers.push(readinessEntry(opened, providerId, producersByName));
|
|
89
89
|
}
|
|
90
|
+
// Warden Task 7: decision providers append as their own entries
|
|
91
|
+
// (`kind: "decision"`, the model ids listed) — a decision provider
|
|
92
|
+
// whose id collides with a chat provider stays SEPARATE (distinct
|
|
93
|
+
// kinds, never merged).
|
|
94
|
+
for (const decision of opened.decisions.providers()) {
|
|
95
|
+
providers.push(decisionEntry(decision));
|
|
96
|
+
}
|
|
90
97
|
return { providers };
|
|
91
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* One decision provider's report entry (warden Task 7): the registry
|
|
101
|
+
* row projected onto the shared entry shape — `models` the count,
|
|
102
|
+
* `modelIds` the bare ids, `owner` the contributing extension.
|
|
103
|
+
*/
|
|
104
|
+
function decisionEntry(decision) {
|
|
105
|
+
return {
|
|
106
|
+
provider: decision.id,
|
|
107
|
+
kind: "decision",
|
|
108
|
+
status: decision.status,
|
|
109
|
+
models: decision.models.length,
|
|
110
|
+
modelIds: decision.models,
|
|
111
|
+
owner: decision.extension,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
92
114
|
/** Register the model-catalog and provider-readiness routes. */
|
|
93
115
|
export function registerProviderRoutes(app, ctx) {
|
|
94
116
|
const { opened } = ctx;
|
|
@@ -109,6 +131,35 @@ export function registerProviderRoutes(app, ctx) {
|
|
|
109
131
|
// serves, so the 404 vocabulary matches it). Key material never
|
|
110
132
|
// appears in any of these routes — statuses and counts only.
|
|
111
133
|
const isKnownProvider = (providerId) => opened.models.getProvider(providerId) !== undefined;
|
|
134
|
+
// Warden Task 7: a DECISION-only provider id is equally routable (the
|
|
135
|
+
// registry enumerates its ids). The mutation bodies branch on the
|
|
136
|
+
// kind: chat ids refresh, decision ids re-read — the registry
|
|
137
|
+
// derives readiness per call, so a credential write is visible
|
|
138
|
+
// without a refresh (and the decision seam has no catalog leg to
|
|
139
|
+
// refresh).
|
|
140
|
+
const isDecisionProvider = (providerId) => opened.decisions.providers().some((row) => row.id === providerId);
|
|
141
|
+
const isRoutableProvider = (providerId) => isKnownProvider(providerId) || isDecisionProvider(providerId);
|
|
142
|
+
// The entry back from a mutation: the chat derivation for catalog
|
|
143
|
+
// providers, the decision entry for decision-only ids (a chat AND
|
|
144
|
+
// decision provider sharing an id answer the CHAT entry — the
|
|
145
|
+
// mutation there targets the catalog provider's refresh leg).
|
|
146
|
+
const routableEntry = (providerId) => {
|
|
147
|
+
if (isKnownProvider(providerId)) {
|
|
148
|
+
return readinessEntry(opened, providerId, producersIndex(opened));
|
|
149
|
+
}
|
|
150
|
+
const decision = opened.decisions
|
|
151
|
+
.providers()
|
|
152
|
+
.find((row) => row.id === providerId);
|
|
153
|
+
if (decision === undefined) {
|
|
154
|
+
throw new Error(`provider "${providerId}" is not routable`);
|
|
155
|
+
}
|
|
156
|
+
return decisionEntry(decision);
|
|
157
|
+
};
|
|
158
|
+
// The post-write refresh: chat ids only.
|
|
159
|
+
const refreshAfterWrite = async (providerId) => {
|
|
160
|
+
if (isKnownProvider(providerId))
|
|
161
|
+
await opened.refreshProvider(providerId);
|
|
162
|
+
};
|
|
112
163
|
// One bounded refresh of the named provider (W3 Task 6, spec §7.6):
|
|
113
164
|
// the SAME policy the boot/interval/refresh-on-miss paths run — bounded
|
|
114
165
|
// by the deadline, failures warned (never the route's error), a
|
|
@@ -138,7 +189,7 @@ export function registerProviderRoutes(app, ctx) {
|
|
|
138
189
|
return await reply.code(403).send({ error: "forbidden" });
|
|
139
190
|
}
|
|
140
191
|
const { id } = request.params;
|
|
141
|
-
if (!
|
|
192
|
+
if (!isRoutableProvider(id)) {
|
|
142
193
|
return await reply.code(404).send({ error: "provider_not_found" });
|
|
143
194
|
}
|
|
144
195
|
const body = request.body;
|
|
@@ -153,9 +204,14 @@ export function registerProviderRoutes(app, ctx) {
|
|
|
153
204
|
.code(400)
|
|
154
205
|
.send({ error: "invalid_field", field: "apiKey" });
|
|
155
206
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
207
|
+
// The credential key: chat ids redirect the DEFAULT provider's id
|
|
208
|
+
// to the magic "default" key; decision-only ids use the id
|
|
209
|
+
// verbatim (the magic key names the default CHAT provider, never a
|
|
210
|
+
// decision credential).
|
|
211
|
+
const key = isKnownProvider(id) ? credentialKey(id) : id;
|
|
212
|
+
await opened.auth.setApiKey(key, apiKey);
|
|
213
|
+
await refreshAfterWrite(id);
|
|
214
|
+
return routableEntry(id);
|
|
159
215
|
});
|
|
160
216
|
// Remove the provider's credential (admin): store delete of that SAME
|
|
161
217
|
// key → one refresh → the entry. Restart-free the same way — the
|
|
@@ -165,12 +221,13 @@ export function registerProviderRoutes(app, ctx) {
|
|
|
165
221
|
return await reply.code(403).send({ error: "forbidden" });
|
|
166
222
|
}
|
|
167
223
|
const { id } = request.params;
|
|
168
|
-
if (!
|
|
224
|
+
if (!isRoutableProvider(id)) {
|
|
169
225
|
return await reply.code(404).send({ error: "provider_not_found" });
|
|
170
226
|
}
|
|
171
|
-
|
|
172
|
-
await opened.
|
|
173
|
-
|
|
227
|
+
const key = isKnownProvider(id) ? credentialKey(id) : id;
|
|
228
|
+
await opened.auth.deleteApiKey(key);
|
|
229
|
+
await refreshAfterWrite(id);
|
|
230
|
+
return routableEntry(id);
|
|
174
231
|
});
|
|
175
232
|
// Models: the merged chat-model catalog as picker entries (no provider
|
|
176
233
|
// internals — baseUrl/headers/auth stay server-side). The shared
|
|
@@ -52,6 +52,9 @@ export function registerSubmissionRoutes(app, ctx) {
|
|
|
52
52
|
? {}
|
|
53
53
|
: { user: request.principal.user }),
|
|
54
54
|
...(body.whenBusy === undefined ? {} : { whenBusy: body.whenBusy }),
|
|
55
|
+
// Ask-user Task 2: a human's message through the HTTP submit
|
|
56
|
+
// route declines pending declinable questions (spec §5.3).
|
|
57
|
+
declinesQuestions: true,
|
|
55
58
|
...(request.query?.waitMs ===
|
|
56
59
|
undefined
|
|
57
60
|
? {}
|
|
@@ -272,6 +272,10 @@ export function registerUserRoutes(app, ctx) {
|
|
|
272
272
|
// must not load for the recipient. In-flight curator runs fail
|
|
273
273
|
// closed through the stores' user-exists gate (nothing recreated).
|
|
274
274
|
await removeUserCuration(ctx.managedPaths.userAreas(user));
|
|
275
|
+
// Notices spec §3.2 rule 8: notices are personal and never
|
|
276
|
+
// transferred. The user area itself stays for the workspace's
|
|
277
|
+
// recipient, so the notices file is removed on its own.
|
|
278
|
+
await ctx.notices.removeUser(user);
|
|
275
279
|
return await reply.code(204).send();
|
|
276
280
|
});
|
|
277
281
|
}
|
|
@@ -18,6 +18,12 @@ export const DEDUP_WINDOW_MS = 10 * 60 * 1000;
|
|
|
18
18
|
* same-instance duplicate (and `null` when a different instance already
|
|
19
19
|
* holds the actionId — the caller treats that as a fresh action);
|
|
20
20
|
* `record` stores the first result.
|
|
21
|
+
*
|
|
22
|
+
* Generic over the stored result type (notices Task 4): the surface
|
|
23
|
+
* actions' default (`ActionResponse`) and the notices service's
|
|
24
|
+
* `NoticeActionResponse` share the `{ok, ...}` result shape but are
|
|
25
|
+
* different unions — the notice dispatch stores its own type through
|
|
26
|
+
* the same mechanics without a cast.
|
|
21
27
|
*/
|
|
22
28
|
export class ActionDedup {
|
|
23
29
|
entries = new Map();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility for an ARBITRARY directory user (notices Task 3): the
|
|
3
|
+
* request-scoped `visibleTo` (routes/interactions.ts) answers for the
|
|
4
|
+
* REQUEST principal; the notices service needs the same answer for the
|
|
5
|
+
* notice's RECIPIENT — usually not the caller at all (a post targeting
|
|
6
|
+
* another user's conversation, or the list read pruning a revoked
|
|
7
|
+
* share).
|
|
8
|
+
*
|
|
9
|
+
* The rule is the same one `visibleTo` runs: `accessOf` over the
|
|
10
|
+
* conversation's SessionMetaDoc snapshot, with the principal projected
|
|
11
|
+
* from the DIRECTORY (stored groups only — never request-supplied
|
|
12
|
+
* claims), absent record → not visible. A userless conversation (no
|
|
13
|
+
* meta) reads as ownerless → visible, exactly like `visibleTo`.
|
|
14
|
+
*/
|
|
15
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
16
|
+
import { accessOf } from "@sigrun/shared/access";
|
|
17
|
+
import { storedGroups } from "../harness/identity/groups.js";
|
|
18
|
+
import { SessionMetaDoc } from "./session-meta.js";
|
|
19
|
+
/**
|
|
20
|
+
* Whether `user` (a directory username) can SEE conversation `id`:
|
|
21
|
+
* `accessOf` over the session-meta snapshot with the user's stored
|
|
22
|
+
* groups. `false` when the directory has no record for the name.
|
|
23
|
+
*/
|
|
24
|
+
export async function visibleToUser(ctx, id, user) {
|
|
25
|
+
const record = ctx.opened.identity.users.get(user);
|
|
26
|
+
if (record === undefined)
|
|
27
|
+
return false;
|
|
28
|
+
const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT);
|
|
29
|
+
return (accessOf({ user, superuser: false, groups: storedGroups(record) }, meta ?? {}) !== "none");
|
|
30
|
+
}
|
|
@@ -133,6 +133,18 @@ export const CORE_SETTING_KEYS = [
|
|
|
133
133
|
section: "general",
|
|
134
134
|
reason: "read per settled run by the auto-namer (src/harness/naming.ts)",
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
key: "decisionModel",
|
|
138
|
+
apply: "live",
|
|
139
|
+
section: "general",
|
|
140
|
+
reason: "read per host.decisions.evaluate call by the decision registry (src/harness/decisions.ts)",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
key: "decisions.tool",
|
|
144
|
+
apply: "live",
|
|
145
|
+
section: "tools",
|
|
146
|
+
reason: "read at each decision_evaluate tool call (default off)",
|
|
147
|
+
},
|
|
136
148
|
{
|
|
137
149
|
key: "defaultProvider.baseUrl",
|
|
138
150
|
apply: "restart",
|
|
@@ -332,6 +344,12 @@ export const CORE_SETTING_KEYS = [
|
|
|
332
344
|
section: "advanced",
|
|
333
345
|
reason: "the event bus's queue bound is set at open (boot)",
|
|
334
346
|
},
|
|
347
|
+
{
|
|
348
|
+
key: "server.decisions.timeoutMs",
|
|
349
|
+
apply: "live",
|
|
350
|
+
section: "providers",
|
|
351
|
+
reason: "the decision registry reads the settings' timeoutMs per evaluate call (src/harness/decisions.ts)",
|
|
352
|
+
},
|
|
335
353
|
{
|
|
336
354
|
key: "server.providers.refreshIntervalMinutes",
|
|
337
355
|
apply: "live",
|
|
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-11-05
|
|
10
|
+
|
|
11
|
+
- `InteractionRecord` gains optional `declinable: true` and settled `declinedVia:
|
|
12
|
+
"button" | "message"` (the ask-user decline wire surface — additive, optional).
|
|
13
|
+
- `InteractionDeclined` wire type beside `InteractionAnswered` (the decline route's
|
|
14
|
+
200 body).
|
|
15
|
+
|
|
16
|
+
## [0.8.0] - 2026-11-04
|
|
17
|
+
|
|
18
|
+
- New `./notices` subpath: the `UserNotice*` wire types, `NOTICE_LIMITS`, and the
|
|
19
|
+
key/action-id regexes (the client-shared contract for the notices feature).
|
|
20
|
+
|
|
9
21
|
## [0.7.0] - 2026-11-04
|
|
10
22
|
|
|
11
23
|
- New `./model-label` subpath: `modelBreadcrumb` collapses every non-leaf path segment of
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision-provider wire types (the decision-providers spec): a
|
|
3
|
+
* provider TYPE whose models answer typed questions (noul / choice /
|
|
4
|
+
* score) over a JSON state, for extensions that need small structured
|
|
5
|
+
* judgments rather than a chat completion.
|
|
6
|
+
*
|
|
7
|
+
* TYPE-ONLY module, with NO imports at all: `JsonValue`/`JsonObject` are
|
|
8
|
+
* declared locally exactly the way `shared/src/json.ts` mirrors them, so
|
|
9
|
+
* the shared leaf keeps its zero-import invariant (enforced by
|
|
10
|
+
* test/views-purity.test.ts). The two `DECISION_MAX_*` constants are
|
|
11
|
+
* pure VALUES — wire modules carry "types + pure constants".
|
|
12
|
+
*/
|
|
13
|
+
/** A strict-JSON value (local mirror — see shared/src/json.ts). */
|
|
14
|
+
export type DecisionJsonValue = null | boolean | number | string | DecisionJsonValue[] | {
|
|
15
|
+
[key: string]: DecisionJsonValue;
|
|
16
|
+
};
|
|
17
|
+
/** A strict-JSON object (local mirror — see shared/src/json.ts). */
|
|
18
|
+
export type DecisionJsonObject = {
|
|
19
|
+
[key: string]: DecisionJsonValue;
|
|
20
|
+
};
|
|
21
|
+
/** The state a decision question is asked over: a string or strict JSON. */
|
|
22
|
+
export type DecisionState = string | DecisionJsonObject | DecisionJsonValue[];
|
|
23
|
+
/** One typed question. Choice needs at least one label; score at least two levels. */
|
|
24
|
+
export type DecisionQuestion = {
|
|
25
|
+
readonly type: "noul";
|
|
26
|
+
readonly instructions?: DecisionJsonValue;
|
|
27
|
+
readonly criteria?: {
|
|
28
|
+
readonly true?: DecisionJsonValue;
|
|
29
|
+
readonly false?: DecisionJsonValue;
|
|
30
|
+
} | null;
|
|
31
|
+
} | {
|
|
32
|
+
readonly type: "choice";
|
|
33
|
+
readonly instructions?: DecisionJsonValue;
|
|
34
|
+
readonly criteria: Readonly<Record<string, DecisionJsonValue | null>>;
|
|
35
|
+
} | {
|
|
36
|
+
readonly type: "score";
|
|
37
|
+
readonly instructions?: DecisionJsonValue;
|
|
38
|
+
readonly criteria: readonly DecisionJsonValue[];
|
|
39
|
+
};
|
|
40
|
+
/** One evaluation: a state plus 1..32 typed questions. */
|
|
41
|
+
export interface DecisionRequest {
|
|
42
|
+
readonly state: DecisionState;
|
|
43
|
+
readonly questions: Readonly<Record<string, DecisionQuestion>>;
|
|
44
|
+
}
|
|
45
|
+
/** One answer, matching its question's type; probabilities are in [0, 1]. */
|
|
46
|
+
export type DecisionAnswer = {
|
|
47
|
+
readonly type: "noul";
|
|
48
|
+
readonly noul: number;
|
|
49
|
+
} | {
|
|
50
|
+
readonly type: "choice";
|
|
51
|
+
readonly choice: string;
|
|
52
|
+
readonly confidence: number;
|
|
53
|
+
readonly probabilities: Readonly<Record<string, number>>;
|
|
54
|
+
} | {
|
|
55
|
+
readonly type: "score";
|
|
56
|
+
readonly score: number;
|
|
57
|
+
readonly confidence: number;
|
|
58
|
+
readonly probabilities: Readonly<Record<string, number>>;
|
|
59
|
+
readonly legend?: Readonly<Record<string, DecisionJsonValue>>;
|
|
60
|
+
};
|
|
61
|
+
/** The provider's answer set plus optional usage bookkeeping. */
|
|
62
|
+
export interface DecisionAnswers {
|
|
63
|
+
readonly answers: Readonly<Record<string, DecisionAnswer>>;
|
|
64
|
+
readonly usage?: {
|
|
65
|
+
readonly input_tokens: number | null;
|
|
66
|
+
readonly output_tokens: number | null;
|
|
67
|
+
};
|
|
68
|
+
readonly model?: string;
|
|
69
|
+
}
|
|
70
|
+
/** The outcome of one `evaluate` call; NEVER a throw. */
|
|
71
|
+
export type DecisionResult = {
|
|
72
|
+
readonly ok: true;
|
|
73
|
+
readonly model: string;
|
|
74
|
+
readonly answers: Readonly<Record<string, DecisionAnswer>>;
|
|
75
|
+
readonly usage: {
|
|
76
|
+
readonly input_tokens: number | null;
|
|
77
|
+
readonly output_tokens: number | null;
|
|
78
|
+
};
|
|
79
|
+
readonly elapsedMs: number;
|
|
80
|
+
} | {
|
|
81
|
+
readonly ok: false;
|
|
82
|
+
readonly code: "no_model" | "unknown_model" | "invalid_request" | "over_limits" | "not_configured" | "timeout" | "aborted" | "failed" | "bad_answer";
|
|
83
|
+
readonly error: string;
|
|
84
|
+
};
|
|
85
|
+
/** A model's declared limits (narrower than the built-in caps win). */
|
|
86
|
+
export interface DecisionLimits {
|
|
87
|
+
readonly maxQuestions?: number;
|
|
88
|
+
readonly maxInputBytes?: number;
|
|
89
|
+
readonly minChoiceOptions?: number;
|
|
90
|
+
readonly maxScoreLevels?: number;
|
|
91
|
+
}
|
|
92
|
+
/** Built-in cap: at most this many questions per request. */
|
|
93
|
+
export declare const DECISION_MAX_QUESTIONS = 32;
|
|
94
|
+
/** Built-in cap: the JSON state serialized size, in bytes. */
|
|
95
|
+
export declare const DECISION_MAX_BYTES = 65536;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision-provider wire types (the decision-providers spec): a
|
|
3
|
+
* provider TYPE whose models answer typed questions (noul / choice /
|
|
4
|
+
* score) over a JSON state, for extensions that need small structured
|
|
5
|
+
* judgments rather than a chat completion.
|
|
6
|
+
*
|
|
7
|
+
* TYPE-ONLY module, with NO imports at all: `JsonValue`/`JsonObject` are
|
|
8
|
+
* declared locally exactly the way `shared/src/json.ts` mirrors them, so
|
|
9
|
+
* the shared leaf keeps its zero-import invariant (enforced by
|
|
10
|
+
* test/views-purity.test.ts). The two `DECISION_MAX_*` constants are
|
|
11
|
+
* pure VALUES — wire modules carry "types + pure constants".
|
|
12
|
+
*/
|
|
13
|
+
/** Built-in cap: at most this many questions per request. */
|
|
14
|
+
export const DECISION_MAX_QUESTIONS = 32;
|
|
15
|
+
/** Built-in cap: the JSON state serialized size, in bytes. */
|
|
16
|
+
export const DECISION_MAX_BYTES = 65_536;
|
|
@@ -54,6 +54,20 @@ export type InteractionRecord = {
|
|
|
54
54
|
* model-asserted. Absent on the naive single-operator path (no user).
|
|
55
55
|
*/
|
|
56
56
|
readonly answeredByUser?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Decline affordance (ask-user Task 1, spec §5.2): `true` marks the
|
|
59
|
+
* record declinable — a human may skip it without answering (the
|
|
60
|
+
* decline route / `host.declineInteraction`). Absent or omitted means
|
|
61
|
+
* NOT declinable; `false` fails validation (the flag is `true`-only).
|
|
62
|
+
*/
|
|
63
|
+
readonly declinable?: true;
|
|
64
|
+
/**
|
|
65
|
+
* How the record was declined (ask-user Task 1/2): "button" via the
|
|
66
|
+
* decline route or host method, "message" via an admitted decline
|
|
67
|
+
* message. Set only on a `skipped` decline; absent on every other
|
|
68
|
+
* terminal status.
|
|
69
|
+
*/
|
|
70
|
+
readonly declinedVia?: "button" | "message";
|
|
57
71
|
/**
|
|
58
72
|
* The interaction body: a block tree (placement `interaction` — a
|
|
59
73
|
* root `form` whose `submit` is the answer). Legacy-shaped bodies
|