@sigrun/sigrun 0.0.0-stage → 0.1.1
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 +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core-owned refresh policy 2 (spec §"Core: refresh policy"): the
|
|
3
|
+
* interval scheduler. The boot refresh (T4, `refresh.ts`) populates
|
|
4
|
+
* every dynamic provider's catalog once at boot; THIS module keeps them
|
|
5
|
+
* current — ONE host-owned `setInterval` re-running `models.refresh()`
|
|
6
|
+
* with NO provider filter on a cadence, so an upstream roster change
|
|
7
|
+
* lands without a restart (and well inside the refresh-on-miss fallback
|
|
8
|
+
* at the submit call site).
|
|
9
|
+
*
|
|
10
|
+
* The cadence resolves at boot from the settings file's
|
|
11
|
+
* `server.providers.refreshIntervalMinutes` under the
|
|
12
|
+
* `SIGRUN_PROVIDER_REFRESH_MINUTES` env override, default 15 minutes;
|
|
13
|
+
* `0` disables the scheduler outright (no timer is created — the
|
|
14
|
+
* designed off switch, not an error).
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle contract:
|
|
17
|
+
* - the timer is `unref()`ed — a running sigrun keeps the process alive,
|
|
18
|
+
* the refresh cadence alone never does;
|
|
19
|
+
* - `stop()` clears the timer (idempotent); the deploy shutdown path
|
|
20
|
+
* (`src/serve.ts`) calls it BEFORE the harness closes, alongside the
|
|
21
|
+
* ordered shutdown;
|
|
22
|
+
* - a failed tick — per-provider failures in the refresh result
|
|
23
|
+
* (`ModelsRefreshResult.errors`, the pi-ai contract: provider errors
|
|
24
|
+
* are returned, not thrown), an abort, or an unexpected throw — logs
|
|
25
|
+
* ONCE per outage, on the TRANSITION into failure. Consecutive
|
|
26
|
+
* failures stay silent (the cadence IS the retry); a successful tick
|
|
27
|
+
* resets the state so the next outage warns again. One warn per
|
|
28
|
+
* episode, not one per tick.
|
|
29
|
+
*/
|
|
30
|
+
import { refreshReadinessSnapshot } from "./refresh.js";
|
|
31
|
+
/**
|
|
32
|
+
* Start the interval refresh: one `unref()`ed `setInterval` re-running
|
|
33
|
+
* `models.refresh()` (every dynamic provider, network allowed —
|
|
34
|
+
* `refresh()` with no options is pi-ai's full refresh) every
|
|
35
|
+
* `intervalMinutes`. A tick's failures never reject and never stack
|
|
36
|
+
* retries — the next tick IS the retry; see the module docblock for
|
|
37
|
+
* the once-per-outage logging rule.
|
|
38
|
+
*/
|
|
39
|
+
export function startProviderRefresh(models, opts) {
|
|
40
|
+
if (!(opts.intervalMinutes > 0)) {
|
|
41
|
+
return { stop() { } };
|
|
42
|
+
}
|
|
43
|
+
const log = opts.log ?? console.warn;
|
|
44
|
+
// The outage latch: `true` while the current run of consecutive
|
|
45
|
+
// failures has already logged. Success resets it.
|
|
46
|
+
let inOutage = false;
|
|
47
|
+
const tick = async () => {
|
|
48
|
+
let failure;
|
|
49
|
+
try {
|
|
50
|
+
const result = await models.refresh();
|
|
51
|
+
if (result.aborted) {
|
|
52
|
+
failure = "aborted before completing";
|
|
53
|
+
}
|
|
54
|
+
else if (result.errors.size > 0) {
|
|
55
|
+
failure = [...result.errors]
|
|
56
|
+
.map(([id, error]) => `${id} (${error instanceof Error ? error.message : String(error)})`)
|
|
57
|
+
.join("; ");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
62
|
+
}
|
|
63
|
+
// The readiness observation runs for EVERY completed tick — success
|
|
64
|
+
// or failure — so a provider that flips either way is observed; a
|
|
65
|
+
// tick that threw before refreshing holds the previous snapshot.
|
|
66
|
+
try {
|
|
67
|
+
opts.onReadinessChange?.((opts.snapshotReadiness ?? refreshReadinessSnapshot)(models));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
log(`provider readiness observation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
if (failure === undefined) {
|
|
73
|
+
inOutage = false;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!inOutage) {
|
|
77
|
+
inOutage = true;
|
|
78
|
+
log(`provider interval refresh failed: ${failure} — ` +
|
|
79
|
+
`retrying on cadence (logging once per outage)`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const timer = setInterval(() => void tick(), opts.intervalMinutes * 60_000);
|
|
83
|
+
timer.unref?.();
|
|
84
|
+
return {
|
|
85
|
+
stop() {
|
|
86
|
+
clearInterval(timer);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sigrun's extension registry.
|
|
3
|
+
*
|
|
4
|
+
* pi-durable's `createRegistry()` seeds the built-in task definitions
|
|
5
|
+
* (generation, tool, compaction) that `Harness.open` requires. Sigrun-owned
|
|
6
|
+
* extensions will be installed here in later tasks; every harness opened by
|
|
7
|
+
* `openHarness` shares the registry this module creates.
|
|
8
|
+
*/
|
|
9
|
+
export { createRegistry } from "@earendil-works/pi-durable";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reserved core prompt-section keys (spec §6.5 R11).
|
|
3
|
+
*
|
|
4
|
+
* The prompt-section keys the core surfaces own (system-prompt Task 3).
|
|
5
|
+
* The registry itself permits duplicate section keys across extensions
|
|
6
|
+
* (last-writer-wins at render), so a loaded extension could silently
|
|
7
|
+
* replace a core surface. The loader therefore rejects the LOAD of any
|
|
8
|
+
* extension declaring one of these keys — a load-time quarantine, never
|
|
9
|
+
* a boot abort (src/harness/loader.ts) — while boot's post-install walk
|
|
10
|
+
* over `RESERVED_CORE_SECTION_OWNERS` (src/harness/boot.ts) stays as a
|
|
11
|
+
* core-only assertion that can fire only on a core bug.
|
|
12
|
+
*
|
|
13
|
+
* Lives in its own module (not boot.ts) so the loader can read the
|
|
14
|
+
* contract without importing boot's runtime graph; boot re-exports the
|
|
15
|
+
* constant for existing importers.
|
|
16
|
+
*/
|
|
17
|
+
export const RESERVED_CORE_SECTION_KEYS = [
|
|
18
|
+
"identity",
|
|
19
|
+
"persona",
|
|
20
|
+
"workspace-instructions",
|
|
21
|
+
"skills",
|
|
22
|
+
"tool-guidance",
|
|
23
|
+
"execution-guidance",
|
|
24
|
+
];
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host context (spec: docs/approval-ux-seams.md, "What this asks of
|
|
3
|
+
* core"): the object sigrun hands an extension factory so the extension can
|
|
4
|
+
* use the interaction/ambient seams — and its scoped extension-stores
|
|
5
|
+
* handle (`stores`: secrets/settings get/set on its own section/file plus
|
|
6
|
+
* its own raw data/cache/state paths) — without importing anything from
|
|
7
|
+
* core.
|
|
8
|
+
*
|
|
9
|
+
* Factories run BEFORE `Harness.open` (spec §3 boot order), so the context
|
|
10
|
+
* binds the harness lazily: boot assigns `bindHarness` right after open,
|
|
11
|
+
* and every seam call awaits that assignment. A seam call that arrives
|
|
12
|
+
* before the harness exists waits — never a silent drop.
|
|
13
|
+
*
|
|
14
|
+
* Failure posture: every method throws `SeamError` on misuse (bad record,
|
|
15
|
+
* unowned key, unknown kind) — fail-loud, matching the loader's quarantine
|
|
16
|
+
* posture for shape violations. A throw inside a factory quarantines the
|
|
17
|
+
* extension; a throw later (tool execute) surfaces to the caller.
|
|
18
|
+
*/
|
|
19
|
+
import { AmbientDoc, AmbientOwnersDoc, validateAmbient, } from "./ambient.js";
|
|
20
|
+
import { InteractionDoc, InteractionsIndexDoc, applyAnswer, indexPending, indexSettled, validateInteraction, } from "./interactions.js";
|
|
21
|
+
import { scopedExtensionStores, } from "./stores.js";
|
|
22
|
+
/** One seam violation. Name carries the surface ("interaction"/"ambient"). */
|
|
23
|
+
export class SeamError extends Error {
|
|
24
|
+
surface;
|
|
25
|
+
constructor(surface, message) {
|
|
26
|
+
super(`${surface}: ${message}`);
|
|
27
|
+
this.surface = surface;
|
|
28
|
+
this.name = "SeamError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the seam host for one extension. `kinds` gates writes: a record
|
|
33
|
+
* whose `kind`/`key` the extension never declared is rejected — the
|
|
34
|
+
* declaration is what channels use to inventory capabilities, so an
|
|
35
|
+
* undeclared write would silently falsify it. `sharedStores` is the
|
|
36
|
+
* boot-built store trio (one per boot, from the resolved roots); the
|
|
37
|
+
* host scopes it to its identity's slug — `rekey` re-scopes in place.
|
|
38
|
+
*/
|
|
39
|
+
export function createSeamHost(identity, kinds, bind, sharedStores) {
|
|
40
|
+
const interactionKinds = new Set(kinds.interaction ?? []);
|
|
41
|
+
const ambientKinds = new Set(kinds.ambient ?? []);
|
|
42
|
+
// The scoped stores view, keyed on the CURRENT identity — the same
|
|
43
|
+
// closed-over `let` pattern as `identity` itself: rekey re-points it
|
|
44
|
+
// and the `stores` getter below reads through the closure, so the
|
|
45
|
+
// factory-captured host object re-scopes IN PLACE (never a second
|
|
46
|
+
// host, never a stale view).
|
|
47
|
+
let stores = scopedExtensionStores(sharedStores, identity.name);
|
|
48
|
+
async function harness(context) {
|
|
49
|
+
const h = await bind.harness();
|
|
50
|
+
// The caller's context gates the wait: an aborted request/tool call
|
|
51
|
+
// unwinds instead of waiting forever for a harness that never opens.
|
|
52
|
+
context.abortSignal?.throwIfAborted();
|
|
53
|
+
return h;
|
|
54
|
+
}
|
|
55
|
+
// The server-provided host bridge (W5 Task 5): the conversation/
|
|
56
|
+
// identity/users/access surface binds LATER than the harness — the
|
|
57
|
+
// bridge only exists once `createServer` builds it — so the same
|
|
58
|
+
// lazy-promise discipline applies: every call awaits the harness
|
|
59
|
+
// assignment AND the bridge assignment, so a factory-body call waits
|
|
60
|
+
// (never a silent drop, never an early resolution before the server
|
|
61
|
+
// exists), and a call after the binding resolves immediately.
|
|
62
|
+
let bridgeReleased;
|
|
63
|
+
const bridgePromise = new Promise((resolve) => {
|
|
64
|
+
bridgeReleased = resolve;
|
|
65
|
+
});
|
|
66
|
+
const hostBridge = async () => {
|
|
67
|
+
await bind.harness();
|
|
68
|
+
return bridgePromise;
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
get stores() {
|
|
72
|
+
return stores;
|
|
73
|
+
},
|
|
74
|
+
rekey(nextIdentity, nextKinds) {
|
|
75
|
+
// In place, by design: the factory already captured THIS object, so
|
|
76
|
+
// the reassign below is visible to every closure reading it and no
|
|
77
|
+
// second host instance ever exists. The stores view re-points to the
|
|
78
|
+
// rekeyed slug the same way — same host object, corrected scope.
|
|
79
|
+
identity = nextIdentity;
|
|
80
|
+
stores = scopedExtensionStores(sharedStores, nextIdentity.name);
|
|
81
|
+
interactionKinds.clear();
|
|
82
|
+
for (const kind of nextKinds.interaction ?? [])
|
|
83
|
+
interactionKinds.add(kind);
|
|
84
|
+
ambientKinds.clear();
|
|
85
|
+
for (const kind of nextKinds.ambient ?? [])
|
|
86
|
+
ambientKinds.add(kind);
|
|
87
|
+
},
|
|
88
|
+
async seedInteraction(conversationId, record, context) {
|
|
89
|
+
if (!interactionKinds.has(record.kind)) {
|
|
90
|
+
throw new SeamError("interaction", `kind ${JSON.stringify(record.kind)} is not declared by ${identity.name} (declare it in the factory result's interactionKinds)`);
|
|
91
|
+
}
|
|
92
|
+
const problem = validateInteraction(record);
|
|
93
|
+
if (problem !== null)
|
|
94
|
+
throw new SeamError("interaction", problem);
|
|
95
|
+
const h = await harness(context);
|
|
96
|
+
await h.commit(async (tx) => {
|
|
97
|
+
// Maintain the index in the SAME commit as the record.
|
|
98
|
+
const existing = await tx.doc(InteractionDoc, conversationId, record.callId, record.callId);
|
|
99
|
+
if (existing.status !== "pending")
|
|
100
|
+
return; // first-write wins
|
|
101
|
+
Object.assign(existing, record);
|
|
102
|
+
const index = await tx.doc(InteractionsIndexDoc);
|
|
103
|
+
indexPending(index, conversationId, record.callId);
|
|
104
|
+
}, context);
|
|
105
|
+
},
|
|
106
|
+
async answerInteraction(conversationId, callId, answer, context) {
|
|
107
|
+
const h = await harness(context);
|
|
108
|
+
return await h.commit(async (tx) => {
|
|
109
|
+
const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
|
|
110
|
+
if (draft.status !== "pending")
|
|
111
|
+
return false;
|
|
112
|
+
const applied = applyAnswer(draft, answer, "extension", Date.now());
|
|
113
|
+
if (applied) {
|
|
114
|
+
const index = await tx.doc(InteractionsIndexDoc);
|
|
115
|
+
indexSettled(index, conversationId, callId);
|
|
116
|
+
}
|
|
117
|
+
return applied;
|
|
118
|
+
}, context);
|
|
119
|
+
},
|
|
120
|
+
async settleInteraction(conversationId, callId, status, context) {
|
|
121
|
+
const h = await harness(context);
|
|
122
|
+
return await h.commit(async (tx) => {
|
|
123
|
+
const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
|
|
124
|
+
if (draft.status !== "pending")
|
|
125
|
+
return false;
|
|
126
|
+
const mutable = draft;
|
|
127
|
+
mutable.status = status;
|
|
128
|
+
mutable.settledAt = Date.now();
|
|
129
|
+
const index = await tx.doc(InteractionsIndexDoc);
|
|
130
|
+
indexSettled(index, conversationId, callId);
|
|
131
|
+
return true;
|
|
132
|
+
}, context);
|
|
133
|
+
},
|
|
134
|
+
async snapshotInteraction(conversationId, callId, context) {
|
|
135
|
+
const h = await harness(context);
|
|
136
|
+
return await h.snapshot(InteractionDoc, conversationId, callId, context);
|
|
137
|
+
},
|
|
138
|
+
async setAmbient(conversationId, record, context) {
|
|
139
|
+
if (!ambientKinds.has(record.key)) {
|
|
140
|
+
throw new SeamError("ambient", `surface key ${JSON.stringify(record.key)} is not declared by ${identity.name} (declare it in the factory result's ambientKinds)`);
|
|
141
|
+
}
|
|
142
|
+
const h = await harness(context);
|
|
143
|
+
await h.commit(async (tx) => {
|
|
144
|
+
// Allocation: one owner per key. The claim happens in the same
|
|
145
|
+
// commit as the first write, so a race between two extensions
|
|
146
|
+
// serializes and the loser throws.
|
|
147
|
+
const owners = await tx.doc(AmbientOwnersDoc);
|
|
148
|
+
const owner = owners.owners[record.key];
|
|
149
|
+
if (owner === undefined) {
|
|
150
|
+
const claimed = owners.owners;
|
|
151
|
+
claimed[record.key] = identity.name;
|
|
152
|
+
}
|
|
153
|
+
else if (owner !== identity.name) {
|
|
154
|
+
throw new SeamError("ambient", `key ${JSON.stringify(record.key)} is owned by ${owner}; ${identity.name} cannot write it`);
|
|
155
|
+
}
|
|
156
|
+
const draft = await tx.doc(AmbientDoc, conversationId, record.key, record.key);
|
|
157
|
+
const full = {
|
|
158
|
+
...record,
|
|
159
|
+
owner: identity.name,
|
|
160
|
+
expires: record.body === undefined ? draft.expires : "never",
|
|
161
|
+
updatedAt: Date.now(),
|
|
162
|
+
};
|
|
163
|
+
const problem = validateAmbient(full);
|
|
164
|
+
if (problem !== null)
|
|
165
|
+
throw new SeamError("ambient", problem);
|
|
166
|
+
Object.assign(draft, full);
|
|
167
|
+
}, context);
|
|
168
|
+
},
|
|
169
|
+
async clearAmbient(conversationId, key, context) {
|
|
170
|
+
if (!ambientKinds.has(key)) {
|
|
171
|
+
throw new SeamError("ambient", `surface key ${JSON.stringify(key)} is not declared by ${identity.name}`);
|
|
172
|
+
}
|
|
173
|
+
const h = await harness(context);
|
|
174
|
+
await h.commit(async (tx) => {
|
|
175
|
+
const draft = await tx.doc(AmbientDoc, conversationId, key, key);
|
|
176
|
+
// Empty replacement: body undefined, everything else refreshed.
|
|
177
|
+
const mutable = draft;
|
|
178
|
+
mutable.updatedAt = Date.now();
|
|
179
|
+
mutable.body = undefined;
|
|
180
|
+
mutable.details = undefined;
|
|
181
|
+
}, context);
|
|
182
|
+
},
|
|
183
|
+
// The host conversation/identity/users/access surface (W5 Task 5,
|
|
184
|
+
// spec §9.5/§9.7): each member awaits the harness + bridge bindings
|
|
185
|
+
// (the lazy discipline above) and delegates to the server bridge —
|
|
186
|
+
// the SAME code paths the HTTP routes run. `create` injects the
|
|
187
|
+
// source default: the CALLING extension's slug (identity.name — the
|
|
188
|
+
// re-keyed current slug, read at call time).
|
|
189
|
+
conversations: {
|
|
190
|
+
async create(opts = {}) {
|
|
191
|
+
const bridge = await hostBridge();
|
|
192
|
+
return bridge.conversations.create({
|
|
193
|
+
...(opts.owner === undefined ? {} : { owner: opts.owner }),
|
|
194
|
+
...(opts.sharing === undefined ? {} : { sharing: opts.sharing }),
|
|
195
|
+
...(opts.name === undefined ? {} : { name: opts.name }),
|
|
196
|
+
// The source default: the CALLING extension's slug (spec §9.5).
|
|
197
|
+
source: opts.source ?? identity.name,
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
async submit(id, input, opts) {
|
|
201
|
+
const bridge = await hostBridge();
|
|
202
|
+
return bridge.conversations.submit(id, input, opts);
|
|
203
|
+
},
|
|
204
|
+
async watch(id, listener) {
|
|
205
|
+
const bridge = await hostBridge();
|
|
206
|
+
return bridge.conversations.watch(id, listener);
|
|
207
|
+
},
|
|
208
|
+
async list(opts) {
|
|
209
|
+
const bridge = await hostBridge();
|
|
210
|
+
return bridge.conversations.list(opts);
|
|
211
|
+
},
|
|
212
|
+
async meta(id) {
|
|
213
|
+
const bridge = await hostBridge();
|
|
214
|
+
return bridge.conversations.meta(id);
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
users: {
|
|
218
|
+
async list() {
|
|
219
|
+
return (await hostBridge()).users.list();
|
|
220
|
+
},
|
|
221
|
+
async get(user) {
|
|
222
|
+
return (await hostBridge()).users.get(user);
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
access: {
|
|
226
|
+
async accessOf(principal, resource) {
|
|
227
|
+
return (await hostBridge()).access.accessOf(principal, resource);
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
identity: {
|
|
231
|
+
async actingUser(conversationId) {
|
|
232
|
+
return (await hostBridge()).identity.actingUser(conversationId);
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
bindBridge(bridge) {
|
|
236
|
+
bridgeReleased(bridge);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core context prompt sections: static `identity`, `persona` from
|
|
3
|
+
* `SOUL.md` (or the shipped `DEFAULT_PERSONA`), and
|
|
4
|
+
* `workspace-instructions` from `AGENTS.md` (spec §3 + system-prompt spec
|
|
5
|
+
* §Design).
|
|
6
|
+
*
|
|
7
|
+
* Both files are read through `PromptInput.env` — the same `ExecutionEnv`
|
|
8
|
+
* the harness builds per request — so the sections follow the workspace the
|
|
9
|
+
* conversation actually resolves without this module knowing which. The
|
|
10
|
+
* bare file names resolve against the env's `cwd`: the workspace root unless the
|
|
11
|
+
* conversation narrowed its working directory (`agent.cwd`, T3), in which
|
|
12
|
+
* case the convention follows that directory — the same cwd-anchored
|
|
13
|
+
* `AGENTS.md` convention pi itself uses, and the only anchor upstream's
|
|
14
|
+
* `FileSystem` offers (there is no workspace-root accessor; a path
|
|
15
|
+
* hard-coded here would break remote/container envs).
|
|
16
|
+
*
|
|
17
|
+
* A *missing* `AGENTS.md` is the designed empty case: `render` returns
|
|
18
|
+
* `undefined` and pi-durable omits the section from the request. A missing
|
|
19
|
+
* `SOUL.md` (or no env at all) is "absent persona file", not an empty
|
|
20
|
+
* persona: the shipped `DEFAULT_PERSONA` speaks instead, and a present
|
|
21
|
+
* `SOUL.md` — including an empty one — owns the section wholesale. Any
|
|
22
|
+
* other read failure is a real error and fails loud — render throws,
|
|
23
|
+
* upstream reports it and keeps the last shown text (verified:
|
|
24
|
+
* dist/harness/prompt.js renderSections).
|
|
25
|
+
*/
|
|
26
|
+
import { defineExtension, section } from "@earendil-works/pi-durable";
|
|
27
|
+
/**
|
|
28
|
+
* Read one workspace-root context file for a prompt section render.
|
|
29
|
+
* Resolves `undefined` for the designed empty cases (no env, missing file).
|
|
30
|
+
*/
|
|
31
|
+
async function readContextFile(input, fileName, context) {
|
|
32
|
+
// Without an environment there is nothing to read: an empty section, not
|
|
33
|
+
// an error (upstream leaves `PromptInput.env` undefined when the harness
|
|
34
|
+
// opens without an `env` option).
|
|
35
|
+
const env = input.env;
|
|
36
|
+
if (env === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
const result = await env.readTextFile(fileName, context);
|
|
39
|
+
if (result.ok)
|
|
40
|
+
return result.value;
|
|
41
|
+
if (result.error.code === "not_found")
|
|
42
|
+
return undefined;
|
|
43
|
+
throw new Error(`Failed to read ${fileName}: ${result.error.message}`, { cause: result.error });
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The static `identity` section body: runtime facts only. No tool claims
|
|
47
|
+
* (the system prompt already declares the actually-offered tools per
|
|
48
|
+
* request) and no model name (it would re-emit this section on every
|
|
49
|
+
* mid-conversation model swap, defeating the minimal-patch design).
|
|
50
|
+
*/
|
|
51
|
+
export const IDENTITY_TEXT = "You are sigrun, an AI assistant running as a persistent server built on the pi-durable harness. Conversations are durable: they survive restarts and can span channels (web UI, terminal UI, HTTP API). Multiple conversations may be active at once; each is independent.";
|
|
52
|
+
/**
|
|
53
|
+
* The persona sigrun ships when no `SOUL.md` exists. A present `SOUL.md`
|
|
54
|
+
* replaces it wholesale; the default is never merged in.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_PERSONA = `You are Sigrun. You choose, and you say why — a valkyrie doesn't dither over the field. You have opinions and you give them straight: if a plan is bad, say it's bad and say what would make it good. Hedging is a way of lying politely.
|
|
57
|
+
|
|
58
|
+
Match your reply to the weight of the ask. No filler openers, no restating the request, no narrating what the user can already see. When you don't know, say so and name what would settle it.
|
|
59
|
+
|
|
60
|
+
Dry wit when it lands. Never forced, never at the user's expense — punch at the problem, not the person. Warmth is welcome; flattery is not.`;
|
|
61
|
+
/**
|
|
62
|
+
* The context-files extension: static `identity`, `persona` from `SOUL.md`
|
|
63
|
+
* (defaulting to the shipped persona when the file is absent or there is
|
|
64
|
+
* no env), and `workspace-instructions` from `AGENTS.md`, read relative to
|
|
65
|
+
* the conversation env's cwd (the workspace root by default).
|
|
66
|
+
*/
|
|
67
|
+
export const contextFilesExtension = defineExtension({
|
|
68
|
+
name: "sigrun.context-files",
|
|
69
|
+
sections: [
|
|
70
|
+
section("identity", () => IDENTITY_TEXT),
|
|
71
|
+
section("persona", async (input, context) =>
|
|
72
|
+
// `??` covers both designed empty cases (not_found, no env); a
|
|
73
|
+
// successful read — including an empty file — owns the section
|
|
74
|
+
// wholesale. The throw path inside readContextFile propagates.
|
|
75
|
+
(await readContextFile(input, "SOUL.md", context)) ?? DEFAULT_PERSONA),
|
|
76
|
+
section("workspace-instructions", (input, context) => readContextFile(input, "AGENTS.md", context)),
|
|
77
|
+
],
|
|
78
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model-family prompt guidance (system-prompt spec §Design, model
|
|
3
|
+
* table): an ordered, first-match-wins table of name patterns with tool /
|
|
4
|
+
* execution guidance, plus the extension that renders each field as its
|
|
5
|
+
* own prompt section.
|
|
6
|
+
*
|
|
7
|
+
* Section renderers have no access to the `Models` catalog (`Agent` carries
|
|
8
|
+
* no `models` field), so the matcher keys off the resolved
|
|
9
|
+
* `${provider}/${modelId}` string alone — case-insensitive substring,
|
|
10
|
+
* first hit wins, `undefined` when nothing matches. `MODEL_GUIDANCE` match
|
|
11
|
+
* strings are lowercase by convention: matching lowercases the joined ref
|
|
12
|
+
* but not the table.
|
|
13
|
+
*
|
|
14
|
+
* The two fields are separate section keys (`tool-guidance`,
|
|
15
|
+
* `execution-guidance`) rather than one guidance blob so a matched→matched
|
|
16
|
+
* model swap that changes only one dimension patches by value — a few
|
|
17
|
+
* lines re-emitted instead of the whole blob. Both render `undefined` (the
|
|
18
|
+
* section is omitted from the request) for an unmatched model or an
|
|
19
|
+
* unset `agent.model`; that is the designed empty case, not an error.
|
|
20
|
+
*
|
|
21
|
+
* Boot installs this extension LAST, after `loadExtensions` and the memory
|
|
22
|
+
* section (Task 3): guidance sections appear and disappear with the
|
|
23
|
+
* matched model, and trailing placement keeps those transitions minimal
|
|
24
|
+
* patches instead of full prompt re-emits.
|
|
25
|
+
*/
|
|
26
|
+
import { defineExtension, section } from "@earendil-works/pi-durable";
|
|
27
|
+
/**
|
|
28
|
+
* Ordered guidance table: first matching entry wins. Seeded conservative —
|
|
29
|
+
* the mechanism is the deliverable; entries accumulate with operational
|
|
30
|
+
* experience.
|
|
31
|
+
*/
|
|
32
|
+
export const MODEL_GUIDANCE = [
|
|
33
|
+
// Reasoning family: spend the thinking on planning and verification.
|
|
34
|
+
{
|
|
35
|
+
match: "kimi",
|
|
36
|
+
guidance: {
|
|
37
|
+
execution: "Think through the full plan before acting; verify the result before finalizing.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
// Reasoning family: spend the thinking on planning and verification.
|
|
41
|
+
{
|
|
42
|
+
match: "deepseek",
|
|
43
|
+
guidance: {
|
|
44
|
+
execution: "Think through the full plan before acting; verify the result before finalizing.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
// Known under-caller family: prefer checking over recollecting.
|
|
48
|
+
{
|
|
49
|
+
match: "qwen",
|
|
50
|
+
guidance: {
|
|
51
|
+
tool: "Prefer your tools over recollection when the answer is checkable on disk or in the environment.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the first table entry whose pattern is a substring of the
|
|
57
|
+
* lowercased `${provider}/${modelId}`, or `undefined` when the ref is
|
|
58
|
+
* unset or nothing matches.
|
|
59
|
+
*/
|
|
60
|
+
export function matchGuidance(modelRef) {
|
|
61
|
+
if (modelRef === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
const key = `${modelRef.provider}/${modelRef.modelId}`.toLowerCase();
|
|
64
|
+
return MODEL_GUIDANCE.find((entry) => key.includes(entry.match))?.guidance;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The model-guidance extension: `tool-guidance` and `execution-guidance`
|
|
68
|
+
* sections keyed on the request's resolved model, each rendering the
|
|
69
|
+
* matched entry's field or `undefined` (absent section).
|
|
70
|
+
*/
|
|
71
|
+
export const modelGuidanceExtension = defineExtension({
|
|
72
|
+
name: "sigrun.model-guidance",
|
|
73
|
+
sections: [
|
|
74
|
+
section("tool-guidance", (input) => matchGuidance(input.agent.model)?.tool),
|
|
75
|
+
section("execution-guidance", (input) => matchGuidance(input.agent.model)?.execution),
|
|
76
|
+
],
|
|
77
|
+
});
|