@akalsey/openclaw-sapience 0.1.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/README.md +169 -0
- package/dist/index.js +1 -0
- package/dist/src/action-log.js +20 -0
- package/dist/src/autonomy.js +18 -0
- package/dist/src/calibration.js +53 -0
- package/dist/src/delivery.js +54 -0
- package/dist/src/processed-passes.js +20 -0
- package/dist/src/proposal-adapter.js +52 -0
- package/dist/src/service.js +86 -0
- package/dist/src/types.js +22 -0
- package/dist/src/utils.js +23 -0
- package/dist/src/weekly-digest.js +42 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Sapience Suite for OpenClaw
|
|
2
|
+
|
|
3
|
+
The Sapience Suite transforms OpenClaw from a reactive assistant into a proactive agent with genuine autonomy. It learns when to act, when to propose, when to ask, and when to explore — calibrated to your actual preferences, not a static policy you had to configure upfront.
|
|
4
|
+
|
|
5
|
+
The suite has four plugins that each work independently and compose into a whole:
|
|
6
|
+
|
|
7
|
+
| Plugin | Does |
|
|
8
|
+
|--------|------|
|
|
9
|
+
| `openclaw-proactive-thinking` | Periodic thinking passes; generates observations and proposals |
|
|
10
|
+
| `openclaw-sapience` *(this plugin)* | Routes proposals through autonomy tiers; calibrates to your preferences; delivers weekly digest |
|
|
11
|
+
| `openclaw-feedback` | Captures corrections and confirmations from chat; recalibrates autonomy profile |
|
|
12
|
+
| `openclaw-goals` | Accepts fuzzy long-running goals; decomposes them; tracks progress; weekly status |
|
|
13
|
+
|
|
14
|
+
## How it works
|
|
15
|
+
|
|
16
|
+
`openclaw-proactive-thinking` runs a thinking pass every 15 minutes and writes proposals to `proposals.jsonl`. `openclaw-sapience` reads that sidecar, routes each proposal through an autonomy decision function, and delivers it to your main session at the right level:
|
|
17
|
+
|
|
18
|
+
- **Act** — high-confidence, reversible, low-blast-radius → done immediately, brief notification
|
|
19
|
+
- **Propose** — worth doing, needs your approval → surfaces it for a yes/no
|
|
20
|
+
- **Ask** — agent can do it but needs one piece of information → asks exactly what's needed
|
|
21
|
+
- **Explore** — the problem is real but the right path is unclear → presents 2–3 options with tradeoffs
|
|
22
|
+
- **Learning** — new domain or low confidence → calibration question before acting
|
|
23
|
+
|
|
24
|
+
The routing decision uses a calibration profile: per-domain, per-action-class entries with a confidence score. Until a domain is calibrated, everything goes through **Learning** mode and will ask you to confirm it's choices before acting.
|
|
25
|
+
|
|
26
|
+
## Setup
|
|
27
|
+
|
|
28
|
+
### Prerequisites
|
|
29
|
+
|
|
30
|
+
Install `openclaw-proactive-thinking` first. Sapience reads its output.
|
|
31
|
+
|
|
32
|
+
### Install order
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
openclaw plugins install local:/path/to/openclaw-proactive-thinking
|
|
36
|
+
openclaw plugins install local:/path/to/openclaw-sapience
|
|
37
|
+
openclaw plugins install local:/path/to/openclaw-feedback # optional
|
|
38
|
+
openclaw plugins install local:/path/to/openclaw-goals # optional
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Configuration (sapience)
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"plugins": {
|
|
46
|
+
"sapience": {
|
|
47
|
+
"autonomy": {
|
|
48
|
+
"defaultTier": "propose",
|
|
49
|
+
"domainFloors": {
|
|
50
|
+
"github": "propose",
|
|
51
|
+
"salesforce": "ask"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"learning": {
|
|
55
|
+
"enabled": true,
|
|
56
|
+
"confidenceDropThreshold": 0.4
|
|
57
|
+
},
|
|
58
|
+
"digest": {
|
|
59
|
+
"enabled": true,
|
|
60
|
+
"day": "friday",
|
|
61
|
+
"time": "17:00"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**`defaultTier`** — What tier to use for uncalibrated actions when learning mode is off. Default: `"propose"`.
|
|
69
|
+
|
|
70
|
+
**`domainFloors`** — Minimum tier for a domain. If calibration says `act` for a domain with floor `propose`, it routes as `propose`. Use this for domains where you never want autonomous action regardless of confidence.
|
|
71
|
+
|
|
72
|
+
**`confidenceDropThreshold`** — Below this confidence, Learning mode fires instead of the calibrated tier. Default: `0.4`.
|
|
73
|
+
|
|
74
|
+
**`digest`** — Weekly summary of what was acted on, what's pending review, and what's planned. Delivered at the configured day and time.
|
|
75
|
+
|
|
76
|
+
### Output files
|
|
77
|
+
|
|
78
|
+
| File | Purpose |
|
|
79
|
+
|------|---------|
|
|
80
|
+
| `~/.openclaw/sapience/calibration.json` | Autonomy calibration profile (shared with `openclaw-feedback`) |
|
|
81
|
+
| `~/.openclaw/sapience/processed-passes.json` | Tracks which proactive-thinking passes have been routed |
|
|
82
|
+
| `~/.openclaw/sapience/action-log.md` | Log of every Act-tier item delivered |
|
|
83
|
+
|
|
84
|
+
## Training: calibrating autonomy
|
|
85
|
+
|
|
86
|
+
Calibration is the process of teaching the agent your preferences per domain and action type.
|
|
87
|
+
|
|
88
|
+
### Learning mode
|
|
89
|
+
|
|
90
|
+
When sapience sees a domain/action-class combination with no calibration data (or low confidence), it fires a **Learning** prompt instead of acting:
|
|
91
|
+
|
|
92
|
+
> "I noticed [item]. My instinct is to surface this as a proposal. Is that the right level of initiative, or would you prefer I handle this differently?"
|
|
93
|
+
|
|
94
|
+
You respond to confirm or redirect. The calibration profile updates accordingly.
|
|
95
|
+
|
|
96
|
+
### How confidence builds
|
|
97
|
+
|
|
98
|
+
| Event | Effect |
|
|
99
|
+
|-------|--------|
|
|
100
|
+
| You confirm the proposed approach ("yes, that's right") | Confidence +0.1 |
|
|
101
|
+
| You correct the approach ("no, just do it") | Confidence −0.3, tier updated |
|
|
102
|
+
| No feedback | Confidence unchanged |
|
|
103
|
+
|
|
104
|
+
Confidence caps at 1.0 and floors at 0.0. A domain needs roughly 3–5 confirmations to reach the default threshold (0.4) from zero.
|
|
105
|
+
|
|
106
|
+
### Reading the calibration profile
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
cat ~/.openclaw/sapience/calibration.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Each entry:
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"domain": "github",
|
|
116
|
+
"action_class": "github/action",
|
|
117
|
+
"tier": "propose",
|
|
118
|
+
"confidence": 0.7,
|
|
119
|
+
"confirmed_count": 4,
|
|
120
|
+
"corrected_count": 1,
|
|
121
|
+
"last_calibrated": "2026-05-20T14:00:00Z",
|
|
122
|
+
"notes": ""
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Resetting a domain
|
|
127
|
+
|
|
128
|
+
Delete the entry from `calibration.json` to reset a domain to Learning mode.
|
|
129
|
+
|
|
130
|
+
## Day-to-day use
|
|
131
|
+
|
|
132
|
+
Once installed, the suite runs in the background. What you'll see in your sessions:
|
|
133
|
+
|
|
134
|
+
- `[SAPIENCE: PROPOSE]` — a proposal needing your yes/no
|
|
135
|
+
- `[SAPIENCE: ACT]` — notification of something just done
|
|
136
|
+
- `[SAPIENCE: ASK]` — a question needed before proceeding
|
|
137
|
+
- `[SAPIENCE: EXPLORE]` — a problem with options for you to choose from
|
|
138
|
+
- `[SAPIENCE: CALIBRATE]` — a calibration question for a new domain
|
|
139
|
+
- `[SAPIENCE: WEEKLY DIGEST]` — Friday summary of actions, pending items, and plans
|
|
140
|
+
|
|
141
|
+
You don't need to do anything to receive these — they arrive as injected turns in your active session.
|
|
142
|
+
|
|
143
|
+
### Weekly digest
|
|
144
|
+
|
|
145
|
+
Every Friday at 5pm (or your configured time), the digest summarizes:
|
|
146
|
+
- What was acted on this week
|
|
147
|
+
- Proposals still waiting on your input
|
|
148
|
+
- What's planned for next week
|
|
149
|
+
|
|
150
|
+
## Troubleshooting
|
|
151
|
+
|
|
152
|
+
**Nothing being delivered to my session**
|
|
153
|
+
Check that proactive-thinking is writing `proposals.jsonl`:
|
|
154
|
+
```bash
|
|
155
|
+
cat ~/.openclaw/proactive-thinking/proposals.jsonl | tail -1 | python3 -m json.tool
|
|
156
|
+
```
|
|
157
|
+
If the file is empty or missing, proactive-thinking isn't running. Check its logs first.
|
|
158
|
+
|
|
159
|
+
**Everything is going to Learning mode**
|
|
160
|
+
Expected behavior for the first week or two. Each calibration response builds confidence. If it continues beyond 2–3 weeks for a domain you use daily, check `calibration.json` — entries may not be getting written.
|
|
161
|
+
|
|
162
|
+
**Calibration profile not updating**
|
|
163
|
+
Feedback plugin (`openclaw-feedback`) handles explicit correction/confirmation capture. If it's not installed, calibrations only happen through the Learning mode prompts. Install `openclaw-feedback` for passive capture from chat messages.
|
|
164
|
+
|
|
165
|
+
**`domainFloors` not respected**
|
|
166
|
+
Floors only prevent routing *above* the floor — they don't push Act-tier items down to propose. `"github": "propose"` means github/action can be at most `propose`, `ask`, or `explore`, never `act`. If you're seeing Act-tier github items, check the floor config key matches the domain name exactly (lowercase).
|
|
167
|
+
|
|
168
|
+
**Duplicate deliveries**
|
|
169
|
+
`processed-passes.json` tracks which proactive-thinking passes have been routed. If it's missing or corrupt, passes get re-delivered. Delete it and it will rebuild from the current pass forward.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/service.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { resolvePath } from "./utils.js";
|
|
4
|
+
export async function appendAction(item, note, logPath) {
|
|
5
|
+
const resolved = resolvePath(logPath);
|
|
6
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
7
|
+
const ts = new Date().toISOString();
|
|
8
|
+
const entry = [
|
|
9
|
+
`## ${ts}`,
|
|
10
|
+
``,
|
|
11
|
+
`**Action:** ${item.text}`,
|
|
12
|
+
`**Domain/class:** ${item.domain} / ${item.action_class}`,
|
|
13
|
+
`**Tier:** Act (confidence ${item.confidence.toFixed(2)})`,
|
|
14
|
+
`**Note:** ${note}`,
|
|
15
|
+
``,
|
|
16
|
+
`---`,
|
|
17
|
+
``,
|
|
18
|
+
].join("\n");
|
|
19
|
+
await appendFile(resolved, entry, "utf-8");
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getEntry, needsCalibration } from "./calibration.js";
|
|
2
|
+
const TIER_ORDER = ["act", "propose", "ask", "explore"];
|
|
3
|
+
export function routeItem(item, profile, config) {
|
|
4
|
+
const entry = getEntry(profile, item.domain, item.action_class);
|
|
5
|
+
if (config.learning.enabled && needsCalibration(entry, config.learning.confidenceDropThreshold)) {
|
|
6
|
+
return { ...item, tier: "learning", confidence: entry?.confidence ?? 0 };
|
|
7
|
+
}
|
|
8
|
+
let tier = (entry?.tier ?? config.autonomy.defaultTier);
|
|
9
|
+
const confidence = entry?.confidence ?? 0;
|
|
10
|
+
const floor = config.autonomy.domainFloors[item.domain];
|
|
11
|
+
if (floor) {
|
|
12
|
+
const tierIdx = TIER_ORDER.indexOf(tier);
|
|
13
|
+
const floorIdx = TIER_ORDER.indexOf(floor);
|
|
14
|
+
if (tierIdx !== -1 && floorIdx !== -1 && tierIdx < floorIdx)
|
|
15
|
+
tier = floor;
|
|
16
|
+
}
|
|
17
|
+
return { ...item, tier, confidence };
|
|
18
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { resolvePath } from "./utils.js";
|
|
4
|
+
export async function loadProfile(path) {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(await readFile(resolvePath(path), "utf-8"));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function saveProfile(profile, path) {
|
|
13
|
+
const resolved = resolvePath(path);
|
|
14
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
15
|
+
await writeFile(resolved, JSON.stringify(profile, null, 2), "utf-8");
|
|
16
|
+
}
|
|
17
|
+
export function getEntry(profile, domain, action_class) {
|
|
18
|
+
return profile.find(e => e.domain === domain && e.action_class === action_class) ?? null;
|
|
19
|
+
}
|
|
20
|
+
export function needsCalibration(entry, threshold) {
|
|
21
|
+
return !entry || entry.confidence < threshold;
|
|
22
|
+
}
|
|
23
|
+
export function upsertEntry(profile, domain, action_class, update) {
|
|
24
|
+
const idx = profile.findIndex(e => e.domain === domain && e.action_class === action_class);
|
|
25
|
+
const base = idx === -1
|
|
26
|
+
? {
|
|
27
|
+
domain, action_class, tier: "propose", confidence: 0,
|
|
28
|
+
confirmed_count: 0, corrected_count: 0,
|
|
29
|
+
last_calibrated: new Date().toISOString(), notes: "",
|
|
30
|
+
}
|
|
31
|
+
: profile[idx];
|
|
32
|
+
const updated = { ...base, ...update, last_calibrated: new Date().toISOString() };
|
|
33
|
+
if (idx === -1)
|
|
34
|
+
return [...profile, updated];
|
|
35
|
+
return profile.map((e, i) => i === idx ? updated : e);
|
|
36
|
+
}
|
|
37
|
+
export function applyConfirmation(entry) {
|
|
38
|
+
return {
|
|
39
|
+
...entry,
|
|
40
|
+
confidence: Math.min(1, entry.confidence + 0.1),
|
|
41
|
+
confirmed_count: entry.confirmed_count + 1,
|
|
42
|
+
last_calibrated: new Date().toISOString(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function applyCorrection(entry, newTier) {
|
|
46
|
+
return {
|
|
47
|
+
...entry,
|
|
48
|
+
tier: newTier,
|
|
49
|
+
confidence: Math.max(0, entry.confidence - 0.3),
|
|
50
|
+
corrected_count: entry.corrected_count + 1,
|
|
51
|
+
last_calibrated: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { appendAction } from "./action-log.js";
|
|
2
|
+
export function buildTierPrompt(item) {
|
|
3
|
+
switch (item.tier) {
|
|
4
|
+
case "act":
|
|
5
|
+
return `[SAPIENCE: ACT] High-confidence action — execute immediately without asking the user for approval, then notify them briefly.
|
|
6
|
+
|
|
7
|
+
Action: ${item.text}
|
|
8
|
+
Domain: ${item.domain} / ${item.action_class}
|
|
9
|
+
Confidence: ${(item.confidence * 100).toFixed(0)}%
|
|
10
|
+
|
|
11
|
+
Execute now. After completion, send a brief message: "I just [what you did]. [Reversible/not reversible]."`;
|
|
12
|
+
case "propose":
|
|
13
|
+
return `[SAPIENCE: PROPOSE] A thinking pass identified this as worth doing. Surface it to the user for approval.
|
|
14
|
+
|
|
15
|
+
Proposal: ${item.text}
|
|
16
|
+
Priority: ${item.priority}/5
|
|
17
|
+
|
|
18
|
+
Present this concisely and ask if they'd like you to proceed.`;
|
|
19
|
+
case "ask":
|
|
20
|
+
return `[SAPIENCE: ASK] You're capable of this but need information to proceed. Ask the user for exactly what you need.
|
|
21
|
+
|
|
22
|
+
Action: ${item.text}
|
|
23
|
+
Domain: ${item.domain}
|
|
24
|
+
|
|
25
|
+
State what you can do, then ask the one or two specific questions that would unblock you.`;
|
|
26
|
+
case "explore":
|
|
27
|
+
return `[SAPIENCE: EXPLORE] A problem was identified but the right approach isn't obvious. Present it with options.
|
|
28
|
+
|
|
29
|
+
Problem: ${item.text}
|
|
30
|
+
Priority: ${item.priority}/5
|
|
31
|
+
|
|
32
|
+
Name the problem, offer 2–3 concrete approaches with their tradeoffs, and ask which fits what they're trying to accomplish.`;
|
|
33
|
+
case "learning":
|
|
34
|
+
return `[SAPIENCE: CALIBRATE] This domain/action class hasn't been calibrated yet. Check with the user before routing.
|
|
35
|
+
|
|
36
|
+
Item: ${item.text}
|
|
37
|
+
Domain: ${item.domain} / ${item.action_class}
|
|
38
|
+
Current confidence: ${(item.confidence * 100).toFixed(0)}%
|
|
39
|
+
|
|
40
|
+
Tell the user: "I noticed [item]. My instinct is to [what you'd do at the propose tier]. Is that the right level of initiative, or would you prefer I handle this differently?"`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function deliverItems(items, api, config) {
|
|
44
|
+
const sorted = [...items].sort((a, b) => (a.tier === "act" ? 0 : 1) - (b.tier === "act" ? 0 : 1));
|
|
45
|
+
for (const item of sorted) {
|
|
46
|
+
if (item.tier === "act") {
|
|
47
|
+
await appendAction(item, "Queued for immediate execution", config.output.actionLogPath);
|
|
48
|
+
}
|
|
49
|
+
await api.session.workflow.enqueueNextTurnInjection({
|
|
50
|
+
sessionTarget: "main",
|
|
51
|
+
text: buildTierPrompt(item),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/processed-passes.ts
|
|
2
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
import { resolvePath } from "./utils.js";
|
|
5
|
+
export async function loadProcessedPasses(path) {
|
|
6
|
+
try {
|
|
7
|
+
const data = JSON.parse(await readFile(resolvePath(path), "utf-8"));
|
|
8
|
+
return new Set(data.pass_ids);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return new Set();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function markPassProcessed(passId, path, processed) {
|
|
15
|
+
const updated = new Set([...processed, passId]);
|
|
16
|
+
const resolved = resolvePath(path);
|
|
17
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
18
|
+
await writeFile(resolved, JSON.stringify({ pass_ids: [...updated] }, null, 2), "utf-8");
|
|
19
|
+
return updated;
|
|
20
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { resolvePath } from "./utils.js";
|
|
3
|
+
const DOMAIN_PATTERNS = [
|
|
4
|
+
[/github/i, "github"],
|
|
5
|
+
[/salesforce/i, "salesforce"],
|
|
6
|
+
[/posthog/i, "posthog"],
|
|
7
|
+
[/lovable/i, "lovable"],
|
|
8
|
+
[/slack/i, "slack"],
|
|
9
|
+
[/google[\s-]?docs?/i, "google-docs"],
|
|
10
|
+
[/slides?|deck/i, "slides"],
|
|
11
|
+
[/okr/i, "okr-system"],
|
|
12
|
+
[/linear/i, "linear"],
|
|
13
|
+
];
|
|
14
|
+
export function extractDomain(text) {
|
|
15
|
+
for (const [pattern, domain] of DOMAIN_PATTERNS) {
|
|
16
|
+
if (pattern.test(text))
|
|
17
|
+
return domain;
|
|
18
|
+
}
|
|
19
|
+
return "general";
|
|
20
|
+
}
|
|
21
|
+
export function proposalSetToItems(raw) {
|
|
22
|
+
if (raw.nothing_to_report)
|
|
23
|
+
return [];
|
|
24
|
+
const items = [];
|
|
25
|
+
for (const obs of raw.observations) {
|
|
26
|
+
const domain = extractDomain(obs.text + " " + obs.evidence);
|
|
27
|
+
items.push({ id: obs.id, type: "observation", text: obs.text, domain, action_class: "observation", priority: obs.priority, pass_id: raw.pass_id, pass_timestamp: raw.timestamp });
|
|
28
|
+
}
|
|
29
|
+
for (const action of raw.proposed_actions) {
|
|
30
|
+
const domain = extractDomain(action.text + " " + action.rationale);
|
|
31
|
+
items.push({ id: action.id, type: "action", text: action.text, domain, action_class: `${domain}/action`, priority: action.priority, pass_id: raw.pass_id, pass_timestamp: raw.timestamp });
|
|
32
|
+
}
|
|
33
|
+
for (const audit of raw.proposed_audits) {
|
|
34
|
+
const domain = extractDomain(audit.domain + " " + audit.rationale);
|
|
35
|
+
items.push({ id: audit.id, type: "audit", text: `${audit.domain}: ${audit.rationale}`, domain, action_class: `${domain}/audit`, priority: audit.priority, pass_id: raw.pass_id, pass_timestamp: raw.timestamp });
|
|
36
|
+
}
|
|
37
|
+
for (const q of raw.open_questions) {
|
|
38
|
+
const domain = extractDomain(q.text + " " + q.blocking_what);
|
|
39
|
+
items.push({ id: q.id, type: "question", text: q.text, domain, action_class: "question", priority: 3, pass_id: raw.pass_id, pass_timestamp: raw.timestamp });
|
|
40
|
+
}
|
|
41
|
+
return items;
|
|
42
|
+
}
|
|
43
|
+
export async function readUnprocessedPasses(proposalsPath, processedIds) {
|
|
44
|
+
try {
|
|
45
|
+
const content = await readFile(resolvePath(proposalsPath), "utf-8");
|
|
46
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
47
|
+
return lines.map(l => JSON.parse(l)).filter(p => !processedIds.has(p.pass_id));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// src/service.ts
|
|
2
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
4
|
+
import { resolveDataPath, isWithinActiveHours } from "./utils.js";
|
|
5
|
+
import { loadProfile, saveProfile, upsertEntry } from "./calibration.js";
|
|
6
|
+
import { routeItem } from "./autonomy.js";
|
|
7
|
+
import { readUnprocessedPasses, proposalSetToItems } from "./proposal-adapter.js";
|
|
8
|
+
import { loadProcessedPasses, markPassProcessed } from "./processed-passes.js";
|
|
9
|
+
import { deliverItems } from "./delivery.js";
|
|
10
|
+
import { isDigestDay, buildDigestPrompt } from "./weekly-digest.js";
|
|
11
|
+
function mergeConfig(raw, workspaceDir) {
|
|
12
|
+
return {
|
|
13
|
+
...DEFAULT_CONFIG,
|
|
14
|
+
...raw,
|
|
15
|
+
activeHours: { ...DEFAULT_CONFIG.activeHours, ...(raw.activeHours ?? {}) },
|
|
16
|
+
proactiveThinking: {
|
|
17
|
+
...DEFAULT_CONFIG.proactiveThinking,
|
|
18
|
+
...(raw.proactiveThinking ?? {}),
|
|
19
|
+
proposalsPath: resolveDataPath(raw.proactiveThinking?.proposalsPath, workspaceDir, DEFAULT_CONFIG.proactiveThinking.proposalsPath),
|
|
20
|
+
},
|
|
21
|
+
learning: { ...DEFAULT_CONFIG.learning, ...(raw.learning ?? {}) },
|
|
22
|
+
autonomy: { ...DEFAULT_CONFIG.autonomy, ...(raw.autonomy ?? {}) },
|
|
23
|
+
digest: { ...DEFAULT_CONFIG.digest, ...(raw.digest ?? {}) },
|
|
24
|
+
output: {
|
|
25
|
+
...DEFAULT_CONFIG.output,
|
|
26
|
+
...(raw.output ?? {}),
|
|
27
|
+
calibrationPath: resolveDataPath(raw.output?.calibrationPath, workspaceDir, DEFAULT_CONFIG.output.calibrationPath),
|
|
28
|
+
actionLogPath: resolveDataPath(raw.output?.actionLogPath, workspaceDir, DEFAULT_CONFIG.output.actionLogPath),
|
|
29
|
+
processedPassesPath: resolveDataPath(raw.output?.processedPassesPath, workspaceDir, DEFAULT_CONFIG.output.processedPassesPath),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export default definePluginEntry({
|
|
34
|
+
id: "sapience",
|
|
35
|
+
name: "Sapience",
|
|
36
|
+
description: "Autonomy layer: routes proactive-thinking proposals through tier function, calibrates to human preferences, delivers weekly digest",
|
|
37
|
+
register(api) {
|
|
38
|
+
const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(api.pluginConfig);
|
|
39
|
+
const config = mergeConfig(api.pluginConfig, workspaceDir);
|
|
40
|
+
api.registerTool({
|
|
41
|
+
name: "process_proposals",
|
|
42
|
+
description: "Process new proposals from the proactive-thinking log and route them through the autonomy tier function. Called by the sapience cron.",
|
|
43
|
+
parameters: {},
|
|
44
|
+
async execute(_id, _params) {
|
|
45
|
+
if (!isWithinActiveHours(config)) {
|
|
46
|
+
return { content: [{ type: "text", text: "SILENT_REPLY_TOKEN" }] };
|
|
47
|
+
}
|
|
48
|
+
const [processed, profile] = await Promise.all([
|
|
49
|
+
loadProcessedPasses(config.output.processedPassesPath),
|
|
50
|
+
loadProfile(config.output.calibrationPath),
|
|
51
|
+
]);
|
|
52
|
+
const newPasses = await readUnprocessedPasses(config.proactiveThinking.proposalsPath, processed);
|
|
53
|
+
let updatedProcessed = processed;
|
|
54
|
+
let updatedProfile = profile;
|
|
55
|
+
for (const pass of newPasses) {
|
|
56
|
+
const items = proposalSetToItems(pass);
|
|
57
|
+
const routed = items.map(item => routeItem(item, updatedProfile, config));
|
|
58
|
+
await deliverItems(routed, api, config);
|
|
59
|
+
updatedProcessed = await markPassProcessed(pass.pass_id, config.output.processedPassesPath, updatedProcessed);
|
|
60
|
+
for (const item of routed) {
|
|
61
|
+
const exists = updatedProfile.find(e => e.domain === item.domain && e.action_class === item.action_class);
|
|
62
|
+
if (!exists) {
|
|
63
|
+
updatedProfile = upsertEntry(updatedProfile, item.domain, item.action_class, {
|
|
64
|
+
tier: config.autonomy.defaultTier,
|
|
65
|
+
confidence: 0,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await saveProfile(updatedProfile, config.output.calibrationPath);
|
|
71
|
+
if (config.digest.enabled && isDigestDay(config) && newPasses.length === 0) {
|
|
72
|
+
const prompt = await buildDigestPrompt(config);
|
|
73
|
+
await api.session.workflow.enqueueNextTurnInjection({ sessionTarget: "main", text: prompt });
|
|
74
|
+
}
|
|
75
|
+
return { content: [{ type: "text", text: "SILENT_REPLY_TOKEN" }] };
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
api.session.workflow.scheduleSessionTurn({
|
|
79
|
+
schedule: { cron: config.schedule },
|
|
80
|
+
sessionTarget: "isolated",
|
|
81
|
+
tag: "sapience-routing-pass",
|
|
82
|
+
systemPrompt: `You are the sapience routing agent. Call process_proposals() to route new thinking pass proposals. Reply SILENT_REPLY_TOKEN after the tool call.`,
|
|
83
|
+
maxTurns: 2,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const DEFAULT_CONFIG = {
|
|
2
|
+
schedule: "*/15 * * * *",
|
|
3
|
+
activeHours: { start: "08:00", end: "20:00", timezone: "America/Los_Angeles" },
|
|
4
|
+
proactiveThinking: {
|
|
5
|
+
proposalsPath: "proactive-thinking/proposals.jsonl",
|
|
6
|
+
},
|
|
7
|
+
learning: {
|
|
8
|
+
enabled: true,
|
|
9
|
+
recalibrateOnNewDomain: true,
|
|
10
|
+
confidenceDropThreshold: 0.4,
|
|
11
|
+
},
|
|
12
|
+
autonomy: {
|
|
13
|
+
defaultTier: "propose",
|
|
14
|
+
domainFloors: {},
|
|
15
|
+
},
|
|
16
|
+
digest: { enabled: true, day: "friday", time: "17:00" },
|
|
17
|
+
output: {
|
|
18
|
+
calibrationPath: "sapience/calibration.json",
|
|
19
|
+
actionLogPath: "sapience/action-log.md",
|
|
20
|
+
processedPassesPath: "sapience/processed-passes.json",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
export function resolvePath(p) {
|
|
4
|
+
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
5
|
+
}
|
|
6
|
+
export function resolveDataPath(override, workspaceDir, defaultRelative) {
|
|
7
|
+
if (!override)
|
|
8
|
+
return join(workspaceDir, defaultRelative);
|
|
9
|
+
if (override.startsWith('/') || override.startsWith('~/'))
|
|
10
|
+
return resolvePath(override);
|
|
11
|
+
return join(workspaceDir, override);
|
|
12
|
+
}
|
|
13
|
+
export function isWithinActiveHours(config) {
|
|
14
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
15
|
+
timeZone: config.activeHours.timezone,
|
|
16
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
17
|
+
});
|
|
18
|
+
const [hours, minutes] = formatter.format(new Date()).split(":").map(Number);
|
|
19
|
+
const now = (hours ?? 0) * 60 + (minutes ?? 0);
|
|
20
|
+
const [sh, sm] = config.activeHours.start.split(":").map(Number);
|
|
21
|
+
const [eh, em] = config.activeHours.end.split(":").map(Number);
|
|
22
|
+
return now >= (sh ?? 0) * 60 + (sm ?? 0) && now <= (eh ?? 0) * 60 + (em ?? 0);
|
|
23
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { resolvePath } from "./utils.js";
|
|
3
|
+
export function isDigestDay(config) {
|
|
4
|
+
const now = new Date();
|
|
5
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
6
|
+
timeZone: config.activeHours.timezone,
|
|
7
|
+
weekday: "long", hour: "2-digit", minute: "2-digit", hour12: false,
|
|
8
|
+
}).formatToParts(now);
|
|
9
|
+
const weekday = parts.find(p => p.type === "weekday")?.value?.toLowerCase() ?? "";
|
|
10
|
+
const hour = parseInt(parts.find(p => p.type === "hour")?.value ?? "0");
|
|
11
|
+
const minute = parseInt(parts.find(p => p.type === "minute")?.value ?? "0");
|
|
12
|
+
const [digestHour, digestMinute] = config.digest.time.split(":").map(Number);
|
|
13
|
+
return weekday === config.digest.day.toLowerCase()
|
|
14
|
+
&& hour === (digestHour ?? 17)
|
|
15
|
+
&& minute < 30;
|
|
16
|
+
}
|
|
17
|
+
export async function buildDigestPrompt(config) {
|
|
18
|
+
let actionLog = "No actions logged this week.";
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(resolvePath(config.output.actionLogPath), "utf-8");
|
|
21
|
+
actionLog = raw.length > 3000
|
|
22
|
+
? "...(earlier entries omitted)\n\n" + raw.slice(-3000)
|
|
23
|
+
: raw;
|
|
24
|
+
}
|
|
25
|
+
catch { /* file absent is fine */ }
|
|
26
|
+
return `[SAPIENCE: WEEKLY DIGEST] Build and deliver a weekly summary to the user.
|
|
27
|
+
|
|
28
|
+
## Action log from this week
|
|
29
|
+
${actionLog}
|
|
30
|
+
|
|
31
|
+
## Instructions
|
|
32
|
+
|
|
33
|
+
Deliver a brief weekly summary with these sections:
|
|
34
|
+
|
|
35
|
+
**What I did this week:** List actions actually taken (from the action log above). If nothing was logged, say so.
|
|
36
|
+
|
|
37
|
+
**Pending your review:** Any proposals from this week that are still waiting on human input.
|
|
38
|
+
|
|
39
|
+
**What I plan next week:** Based on any active goals or pending work you're aware of.
|
|
40
|
+
|
|
41
|
+
Keep it concise. This is a status ping, not a report. Omit sections you have nothing meaningful to say about.`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "sapience",
|
|
3
|
+
"name": "Sapience",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Autonomy layer: routes proactive-thinking proposals through tier function, calibrates to human preferences, delivers weekly digest",
|
|
6
|
+
"contracts": {
|
|
7
|
+
"tools": ["process_proposals"]
|
|
8
|
+
},
|
|
9
|
+
"activation": {
|
|
10
|
+
"onStartup": true
|
|
11
|
+
},
|
|
12
|
+
"configSchema": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": true
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@akalsey/openclaw-sapience",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"openclaw.plugin.json",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"openclaw": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./dist/index.js"
|
|
24
|
+
],
|
|
25
|
+
"compat": {
|
|
26
|
+
"pluginApi": ">=2026.3.24-beta.2",
|
|
27
|
+
"minGatewayVersion": "2026.3.24-beta.2"
|
|
28
|
+
},
|
|
29
|
+
"install": {
|
|
30
|
+
"localPath": "."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@sinclair/typebox": "^0.33.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.0.0",
|
|
38
|
+
"openclaw": "latest",
|
|
39
|
+
"typescript": "^5.5.0",
|
|
40
|
+
"vitest": "^2.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"openclaw": ">=2026.3.24-beta.2"
|
|
44
|
+
}
|
|
45
|
+
}
|