@akalsey/sapience-thinking 0.1.4
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 +92 -0
- package/dist/index.js +1 -0
- package/dist/src/context-builder.js +87 -0
- package/dist/src/delivery.js +34 -0
- package/dist/src/log-writer.js +53 -0
- package/dist/src/outcome-tracker.js +47 -0
- package/dist/src/output-parser.js +16 -0
- package/dist/src/prompt-builder.js +34 -0
- package/dist/src/service.js +152 -0
- package/dist/src/signal-analyzer.js +30 -0
- package/dist/src/types.js +49 -0
- package/dist/src/utils.js +16 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# OpenClaw Thinking
|
|
2
|
+
|
|
3
|
+
Your agent notices things. While it works, it watches for anomalies, patterns, and opportunities that don't fit neatly into scheduled tasks — the kind of "I noticed this while doing something else" observations that a thoughtful colleague would flag. Every 15 minutes during working hours, it runs a brief thinking pass over recent activity, produces a structured list of observations and proposed actions, and surfaces anything worth your attention.
|
|
4
|
+
|
|
5
|
+
The output is a reviewable log file, not autonomous action. You see what it noticed, you decide what to do with it.
|
|
6
|
+
|
|
7
|
+
This plugin is part of the Sapience Suite that gives your OpenClaw agent genuine agency — not just the ability to execute tasks, but the judgment to know when to act, when to ask, when to propose, and when to say "I'm not sure how you want me to handle this."
|
|
8
|
+
|
|
9
|
+
This plugin can be used without Sapience if all you want to do is surface observations to the human.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
### Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
openclaw plugins install npm:@akalsey/sapience-thinking
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Configuration
|
|
20
|
+
|
|
21
|
+
Add to your OpenClaw config:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"plugins": {
|
|
26
|
+
"sapience-thinking": {
|
|
27
|
+
"schedule": "*/15 * * * *",
|
|
28
|
+
"activeHours": {
|
|
29
|
+
"start": "08:00",
|
|
30
|
+
"end": "20:00",
|
|
31
|
+
"timezone": "America/Los_Angeles"
|
|
32
|
+
},
|
|
33
|
+
"output": {
|
|
34
|
+
"logPath": "~/.openclaw/proactive-thinking/thinking-log.md"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
All settings are optional — the defaults above are used if omitted.
|
|
42
|
+
|
|
43
|
+
### Output files
|
|
44
|
+
|
|
45
|
+
| File | Purpose |
|
|
46
|
+
|------|---------|
|
|
47
|
+
| `~/.openclaw/proactive-thinking/thinking-log.md` | Human-readable log of every pass |
|
|
48
|
+
| `~/.openclaw/proactive-thinking/outcomes.json` | Tracks which proposals you acted on |
|
|
49
|
+
| `~/.openclaw/proactive-thinking/proposals.jsonl` | Structured sidecar read by `sapience` |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## What a pass looks like
|
|
54
|
+
|
|
55
|
+
Each entry in `thinking-log.md` has:
|
|
56
|
+
|
|
57
|
+
- **Observations** — things noticed with supporting evidence and priority (1–5)
|
|
58
|
+
- **Proposed actions** — concrete things to do, with estimated effort
|
|
59
|
+
- **Proposed audits** — domains worth reviewing
|
|
60
|
+
- **Open questions** — things blocking analysis
|
|
61
|
+
- **Summary** — one-paragraph overview
|
|
62
|
+
|
|
63
|
+
A pass that found nothing useful logs `nothing_to_report: true`. Over time, this data shows when thinking passes are productive.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Active hours
|
|
68
|
+
|
|
69
|
+
Passes only fire within `activeHours`. Outside that window, the cron fires but silently skips — no log entry. Set the window to match your working hours.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Troubleshooting
|
|
74
|
+
|
|
75
|
+
**Nothing in the log after install**
|
|
76
|
+
The plugin fires on cron schedule, not immediately. Wait for the next 15-minute boundary, or manually trigger:
|
|
77
|
+
```bash
|
|
78
|
+
openclaw cron run sapience-thinking-pass
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Passes are running but log is empty**
|
|
82
|
+
Check that `logPath` is writable and that the path (including `~`) is resolving correctly. Try an absolute path first.
|
|
83
|
+
|
|
84
|
+
**Too many proposals, too much noise**
|
|
85
|
+
The signal-to-noise data in `outcomes.json` feeds back into future prompts after 14 days. Mark proposals as acted-on or dismissed to train the signal:
|
|
86
|
+
```bash
|
|
87
|
+
openclaw thinking resolve <proposal-id> acted_on
|
|
88
|
+
openclaw thinking resolve <proposal-id> dismissed
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**`nothing_to_report` on every pass**
|
|
92
|
+
This usually means the context bundle is too thin — no recent session activity to analyze. The plugin needs active use of OpenClaw to have something to think about.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/service.js";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { estimateTokens, resolvePath } from "./utils.js";
|
|
5
|
+
function extractText(entry) {
|
|
6
|
+
if (!entry.content)
|
|
7
|
+
return "";
|
|
8
|
+
if (typeof entry.content === "string")
|
|
9
|
+
return entry.content;
|
|
10
|
+
return entry.content.filter((c) => c.type === "text").map((c) => c.text ?? "").join(" ");
|
|
11
|
+
}
|
|
12
|
+
export async function buildContextFromDirs(config, sessionDir, memoryDir) {
|
|
13
|
+
const cutoff = Date.now() - config.context.lookbackHours * 60 * 60 * 1000;
|
|
14
|
+
const transcriptBudget = Math.floor(config.context.maxContextTokens * 0.7);
|
|
15
|
+
const memoryBudget = Math.floor(config.context.maxContextTokens * 0.2);
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let usedTokens = 0;
|
|
18
|
+
try {
|
|
19
|
+
const files = (await readdir(sessionDir)).filter((f) => f.endsWith(".jsonl")).sort().reverse();
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
if (usedTokens >= transcriptBudget)
|
|
22
|
+
break;
|
|
23
|
+
const filePath = join(sessionDir, file);
|
|
24
|
+
const fileStat = await stat(filePath);
|
|
25
|
+
if (fileStat.mtimeMs < cutoff)
|
|
26
|
+
continue;
|
|
27
|
+
const lines = (await readFile(filePath, "utf-8")).trim().split("\n").filter(Boolean);
|
|
28
|
+
const entries = lines
|
|
29
|
+
.map((l) => { try {
|
|
30
|
+
return JSON.parse(l);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
} })
|
|
35
|
+
.filter((e) => e !== null);
|
|
36
|
+
for (const entry of entries.slice(-50).reverse()) {
|
|
37
|
+
if (!entry.role || !["user", "assistant"].includes(entry.role))
|
|
38
|
+
continue;
|
|
39
|
+
const text = extractText(entry).slice(0, 500);
|
|
40
|
+
if (!text)
|
|
41
|
+
continue;
|
|
42
|
+
const chunk = `[${entry.role}]: ${text}`;
|
|
43
|
+
const tokens = estimateTokens(chunk);
|
|
44
|
+
if (usedTokens + tokens > transcriptBudget)
|
|
45
|
+
break;
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
usedTokens += tokens;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { /* session dir absent — proceed with empty */ }
|
|
52
|
+
let memoryText = "";
|
|
53
|
+
try {
|
|
54
|
+
const files = (await readdir(memoryDir)).filter((f) => f.endsWith(".md")).slice(0, 20);
|
|
55
|
+
const memChunks = [];
|
|
56
|
+
let memTokens = 0;
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const content = (await readFile(join(memoryDir, file), "utf-8")).slice(0, 1000);
|
|
59
|
+
const t = estimateTokens(content);
|
|
60
|
+
if (memTokens + t > memoryBudget)
|
|
61
|
+
break;
|
|
62
|
+
memChunks.push(content);
|
|
63
|
+
memTokens += t;
|
|
64
|
+
}
|
|
65
|
+
if (memChunks.length > 0)
|
|
66
|
+
memoryText = `\n\n## Recent Memory\n\n${memChunks.join("\n---\n")}`;
|
|
67
|
+
}
|
|
68
|
+
catch { /* memory dir absent — skip */ }
|
|
69
|
+
// chunks were pushed newest-first (files desc, entries reversed); restore chronological order
|
|
70
|
+
const activity = chunks.length > 0 ? chunks.reverse().join("\n") : "No recent session activity found.";
|
|
71
|
+
const full = activity + memoryText;
|
|
72
|
+
return { recentActivity: full, recentPasses: "", tokenEstimate: estimateTokens(full) };
|
|
73
|
+
}
|
|
74
|
+
export async function buildContext(config, agentId) {
|
|
75
|
+
const base = join(homedir(), ".openclaw", "agents", agentId);
|
|
76
|
+
return buildContextFromDirs(config, join(base, "sessions"), join(base, "memory"));
|
|
77
|
+
}
|
|
78
|
+
export async function getLastThreePasses(logPath) {
|
|
79
|
+
try {
|
|
80
|
+
const content = await readFile(resolvePath(logPath), "utf-8");
|
|
81
|
+
const sections = content.split(/^## /m).filter(Boolean).slice(-3);
|
|
82
|
+
return sections.length > 0 ? "## " + sections.join("## ") : "";
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { buildHeartbeatPrompt } from "./prompt-builder.js";
|
|
2
|
+
export function getHighPriorityProposals(proposals, threshold, maxCount) {
|
|
3
|
+
const items = [
|
|
4
|
+
...proposals.observations
|
|
5
|
+
.filter((o) => o.priority >= threshold)
|
|
6
|
+
.map((o) => ({ id: o.id, type: "observation", priority: o.priority, text: o.text })),
|
|
7
|
+
...proposals.proposed_actions
|
|
8
|
+
.filter((a) => a.priority >= threshold)
|
|
9
|
+
.map((a) => ({ id: a.id, type: "action", priority: a.priority, text: a.text })),
|
|
10
|
+
...proposals.proposed_audits
|
|
11
|
+
.filter((a) => a.priority >= threshold)
|
|
12
|
+
.map((a) => ({ id: a.id, type: "audit", priority: a.priority, text: `${a.domain}: ${a.rationale}` })),
|
|
13
|
+
];
|
|
14
|
+
return items.sort((a, b) => b.priority - a.priority).slice(0, maxCount);
|
|
15
|
+
}
|
|
16
|
+
export async function maybeDeliver(proposals,
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
api, config) {
|
|
19
|
+
if (!config.delivery.heartbeatTrigger)
|
|
20
|
+
return;
|
|
21
|
+
const high = getHighPriorityProposals(proposals, config.delivery.priorityThreshold, config.delivery.maxProposalsPerHeartbeat);
|
|
22
|
+
if (high.length === 0)
|
|
23
|
+
return;
|
|
24
|
+
const proposalsList = high
|
|
25
|
+
.map((p) => `- (P${p.priority}) [${p.type}] ${p.text}`)
|
|
26
|
+
.join("\n");
|
|
27
|
+
const heartbeatContent = await buildHeartbeatPrompt(proposalsList);
|
|
28
|
+
// Inject into main session's next turn.
|
|
29
|
+
// NOTE: exact API shape may need adjustment based on installed SDK version.
|
|
30
|
+
await api.session.workflow.enqueueNextTurnInjection({
|
|
31
|
+
content: heartbeatContent,
|
|
32
|
+
sessionTarget: "main",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
export async function appendPass(proposals, logPath) {
|
|
4
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
5
|
+
const lines = [`## ${proposals.timestamp} — Pass ${proposals.pass_id}`, ""];
|
|
6
|
+
lines.push(`**Summary:** ${proposals.summary}`, "");
|
|
7
|
+
if (proposals.nothing_to_report) {
|
|
8
|
+
lines.push("**nothing_to_report: true**", "");
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
if (proposals.observations.length > 0) {
|
|
12
|
+
lines.push("**Observations:**");
|
|
13
|
+
for (const o of proposals.observations)
|
|
14
|
+
lines.push(`- (P${o.priority}) ${o.text} [${o.id}]`);
|
|
15
|
+
lines.push("");
|
|
16
|
+
}
|
|
17
|
+
if (proposals.proposed_actions.length > 0) {
|
|
18
|
+
lines.push("**Proposed Actions:**");
|
|
19
|
+
for (const a of proposals.proposed_actions)
|
|
20
|
+
lines.push(`- (P${a.priority}) ${a.text}. Effort: ${a.estimated_effort}. [${a.id}]`);
|
|
21
|
+
lines.push("");
|
|
22
|
+
}
|
|
23
|
+
if (proposals.proposed_audits.length > 0) {
|
|
24
|
+
lines.push("**Proposed Audits:**");
|
|
25
|
+
for (const a of proposals.proposed_audits)
|
|
26
|
+
lines.push(`- (P${a.priority}) ${a.domain}: ${a.rationale} [${a.id}]`);
|
|
27
|
+
lines.push("");
|
|
28
|
+
}
|
|
29
|
+
if (proposals.open_questions.length > 0) {
|
|
30
|
+
lines.push("**Open Questions:**");
|
|
31
|
+
for (const q of proposals.open_questions)
|
|
32
|
+
lines.push(`- ${q.text} (blocking: ${q.blocking_what}) [${q.id}]`);
|
|
33
|
+
lines.push("");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
lines.push("---", "");
|
|
37
|
+
await appendFile(logPath, lines.join("\n") + "\n");
|
|
38
|
+
}
|
|
39
|
+
export async function appendError(passId, reason, logPath) {
|
|
40
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
41
|
+
const ts = new Date().toISOString();
|
|
42
|
+
await appendFile(logPath, `## ${ts} — Pass ${passId}\n\n**parse error:** ${reason}\n\n---\n\n`);
|
|
43
|
+
}
|
|
44
|
+
export async function appendSkipped(reason, logPath) {
|
|
45
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
46
|
+
const ts = new Date().toISOString();
|
|
47
|
+
await appendFile(logPath, `## ${ts} — Skipped: ${reason}\n\n---\n\n`);
|
|
48
|
+
}
|
|
49
|
+
export async function appendStructuredProposals(proposals, logPath) {
|
|
50
|
+
const jsonlPath = logPath.replace(/\.md$/, ".jsonl");
|
|
51
|
+
await mkdir(dirname(jsonlPath), { recursive: true });
|
|
52
|
+
await appendFile(jsonlPath, JSON.stringify(proposals) + "\n", "utf-8");
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
export async function loadOutcomes(trackerPath) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(await readFile(trackerPath, "utf-8"));
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export async function saveOutcomes(outcomes, trackerPath) {
|
|
12
|
+
await mkdir(dirname(trackerPath), { recursive: true });
|
|
13
|
+
await writeFile(trackerPath, JSON.stringify(outcomes, null, 2), "utf-8");
|
|
14
|
+
}
|
|
15
|
+
export function addProposals(outcomes, proposals) {
|
|
16
|
+
const updated = { ...outcomes };
|
|
17
|
+
const now = new Date().toISOString();
|
|
18
|
+
const add = (id, type) => {
|
|
19
|
+
if (!updated[id]) {
|
|
20
|
+
updated[id] = { proposal_id: id, proposal_type: type, pass_id: proposals.pass_id, created_at: now, state: "pending" };
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
for (const o of proposals.observations)
|
|
24
|
+
add(o.id, "observation");
|
|
25
|
+
for (const a of proposals.proposed_actions)
|
|
26
|
+
add(a.id, "action");
|
|
27
|
+
for (const a of proposals.proposed_audits)
|
|
28
|
+
add(a.id, "audit");
|
|
29
|
+
for (const q of proposals.open_questions)
|
|
30
|
+
add(q.id, "question");
|
|
31
|
+
return updated;
|
|
32
|
+
}
|
|
33
|
+
export function expireOldProposals(outcomes, expiryDays = 7) {
|
|
34
|
+
const updated = { ...outcomes };
|
|
35
|
+
const cutoff = Date.now() - expiryDays * 24 * 60 * 60 * 1000;
|
|
36
|
+
for (const [id, r] of Object.entries(updated)) {
|
|
37
|
+
if (r.state === "pending" && new Date(r.created_at).getTime() < cutoff) {
|
|
38
|
+
updated[id] = { ...r, state: "expired", resolved_at: new Date().toISOString() };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return updated;
|
|
42
|
+
}
|
|
43
|
+
export function resolveProposal(outcomes, id, state) {
|
|
44
|
+
if (!outcomes[id])
|
|
45
|
+
throw new Error(`Proposal ${id} not found`);
|
|
46
|
+
return { ...outcomes, [id]: { ...outcomes[id], state, resolved_at: new Date().toISOString() } };
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Value } from "@sinclair/typebox/value";
|
|
2
|
+
import { ProposalSetSchema } from "./types.js";
|
|
3
|
+
export class ParseError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ParseError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function parseProposals(raw) {
|
|
10
|
+
if (!Value.Check(ProposalSetSchema, raw)) {
|
|
11
|
+
const errors = [...Value.Errors(ProposalSetSchema, raw)];
|
|
12
|
+
const detail = errors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
13
|
+
throw new ParseError(`Invalid proposal schema: ${detail}`);
|
|
14
|
+
}
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
function pct(n, total) {
|
|
6
|
+
return total === 0 ? "0%" : `${Math.round((n / total) * 100)}%`;
|
|
7
|
+
}
|
|
8
|
+
function formatSignal(signal) {
|
|
9
|
+
return [
|
|
10
|
+
"## Your Recent Proposal Signal-to-Noise",
|
|
11
|
+
"",
|
|
12
|
+
`- Observations: ${pct(signal.observations.reviewed, signal.observations.total)} reviewed, ${pct(signal.observations.acted_on, signal.observations.total)} acted on`,
|
|
13
|
+
`- Proposed actions: ${pct(signal.actions.acted_on, signal.actions.total)} acted on, ${pct(signal.actions.rejected, signal.actions.total)} rejected`,
|
|
14
|
+
`- Proposed audits: ${pct(signal.audits.accepted, signal.audits.total)} accepted`,
|
|
15
|
+
`- Open questions: ${pct(signal.questions.answered, signal.questions.total)} answered`,
|
|
16
|
+
"",
|
|
17
|
+
"Use this signal to calibrate. Be more selective in categories with low acceptance.",
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
export async function buildPrompt(bundle, signal) {
|
|
21
|
+
const template = await readFile(join(__dirname, "prompts", "thinking-prompt.md"), "utf-8");
|
|
22
|
+
const sections = [template];
|
|
23
|
+
sections.push(["## Recent Activity Context", "", bundle.recentActivity].join("\n"));
|
|
24
|
+
if (bundle.recentPasses) {
|
|
25
|
+
sections.push(["## Your Recent Proposals (Last 3 Passes)", "", bundle.recentPasses].join("\n"));
|
|
26
|
+
}
|
|
27
|
+
if (signal)
|
|
28
|
+
sections.push(formatSignal(signal));
|
|
29
|
+
return sections.join("\n\n");
|
|
30
|
+
}
|
|
31
|
+
export async function buildHeartbeatPrompt(proposalsList) {
|
|
32
|
+
const template = await readFile(join(__dirname, "prompts", "heartbeat-prompt.md"), "utf-8");
|
|
33
|
+
return template.replace("[PROPOSALS LIST]", proposalsList);
|
|
34
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFile, writeFile, unlink, mkdir, access } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { buildContext, getLastThreePasses } from "./context-builder.js";
|
|
6
|
+
import { buildPrompt } from "./prompt-builder.js";
|
|
7
|
+
import { parseProposals, ParseError } from "./output-parser.js";
|
|
8
|
+
import { appendPass, appendError, appendSkipped, appendStructuredProposals } from "./log-writer.js";
|
|
9
|
+
import { loadOutcomes, saveOutcomes, addProposals, expireOldProposals } from "./outcome-tracker.js";
|
|
10
|
+
import { computeSignal } from "./signal-analyzer.js";
|
|
11
|
+
import { maybeDeliver } from "./delivery.js";
|
|
12
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
13
|
+
import { resolveDataPath } from "./utils.js";
|
|
14
|
+
function isProcessAlive(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function acquireLock(lockDir, lockFile) {
|
|
24
|
+
await mkdir(lockDir, { recursive: true });
|
|
25
|
+
try {
|
|
26
|
+
const lock = JSON.parse(await readFile(lockFile, "utf-8"));
|
|
27
|
+
const ageHours = (Date.now() - new Date(lock.started_at).getTime()) / (1000 * 60 * 60);
|
|
28
|
+
if (isProcessAlive(lock.pid)) {
|
|
29
|
+
if (ageHours < 2)
|
|
30
|
+
return false;
|
|
31
|
+
// > 2 hours old with live PID: stuck process; kill and take the lock
|
|
32
|
+
try {
|
|
33
|
+
process.kill(lock.pid, "SIGTERM");
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
37
|
+
if (isProcessAlive(lock.pid)) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(lock.pid, "SIGKILL");
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch { /* no lock file */ }
|
|
46
|
+
await writeFile(lockFile, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }), "utf-8");
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
async function releaseLock(lockFile) {
|
|
50
|
+
try {
|
|
51
|
+
await unlink(lockFile);
|
|
52
|
+
}
|
|
53
|
+
catch { }
|
|
54
|
+
}
|
|
55
|
+
function isWithinActiveHours(config) {
|
|
56
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
57
|
+
timeZone: config.activeHours.timezone,
|
|
58
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
59
|
+
});
|
|
60
|
+
const [hours, minutes] = formatter.format(new Date()).split(":").map(Number);
|
|
61
|
+
const now = (hours ?? 0) * 60 + (minutes ?? 0);
|
|
62
|
+
const [sh, sm] = config.activeHours.start.split(":").map(Number);
|
|
63
|
+
const [eh, em] = config.activeHours.end.split(":").map(Number);
|
|
64
|
+
return now >= (sh ?? 0) * 60 + (sm ?? 0) && now <= (eh ?? 0) * 60 + (em ?? 0);
|
|
65
|
+
}
|
|
66
|
+
function mergeConfig(raw, workspaceDir) {
|
|
67
|
+
return {
|
|
68
|
+
...DEFAULT_CONFIG,
|
|
69
|
+
...raw,
|
|
70
|
+
activeHours: { ...DEFAULT_CONFIG.activeHours, ...(raw.activeHours ?? {}) },
|
|
71
|
+
context: { ...DEFAULT_CONFIG.context, ...(raw.context ?? {}) },
|
|
72
|
+
output: {
|
|
73
|
+
...DEFAULT_CONFIG.output,
|
|
74
|
+
...(raw.output ?? {}),
|
|
75
|
+
logPath: resolveDataPath(raw.output?.logPath, workspaceDir, DEFAULT_CONFIG.output.logPath),
|
|
76
|
+
trackerPath: resolveDataPath(raw.output?.trackerPath, workspaceDir, DEFAULT_CONFIG.output.trackerPath),
|
|
77
|
+
},
|
|
78
|
+
delivery: { ...DEFAULT_CONFIG.delivery, ...(raw.delivery ?? {}) },
|
|
79
|
+
learning: { ...DEFAULT_CONFIG.learning, ...(raw.learning ?? {}) },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export default definePluginEntry({
|
|
83
|
+
id: "sapience-thinking",
|
|
84
|
+
name: "Sapience Thinking",
|
|
85
|
+
description: "Periodic isolated thinking passes that produce structured proposals",
|
|
86
|
+
register(api) {
|
|
87
|
+
const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(api.pluginConfig);
|
|
88
|
+
const config = mergeConfig(api.pluginConfig, workspaceDir);
|
|
89
|
+
const lockDir = join(workspaceDir, "proactive-thinking");
|
|
90
|
+
const lockFile = join(lockDir, ".pass.lock");
|
|
91
|
+
const agentId = api.config?.agent?.id ?? "default";
|
|
92
|
+
api.registerTool({
|
|
93
|
+
name: "get_thinking_context",
|
|
94
|
+
description: "Fetch context bundle and thinking instructions. Call this first in every thinking pass.",
|
|
95
|
+
parameters: Type.Object({}),
|
|
96
|
+
async execute(_id, _params) {
|
|
97
|
+
if (!isWithinActiveHours(config)) {
|
|
98
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "skip", reason: "outside_active_hours" }) }] };
|
|
99
|
+
}
|
|
100
|
+
const acquired = await acquireLock(lockDir, lockFile);
|
|
101
|
+
if (!acquired) {
|
|
102
|
+
await appendSkipped("pass_already_running", config.output.logPath);
|
|
103
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "skip", reason: "pass_already_running" }) }] };
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const [bundle, recentPasses, outcomes] = await Promise.all([
|
|
107
|
+
buildContext(config, agentId),
|
|
108
|
+
getLastThreePasses(config.output.logPath),
|
|
109
|
+
loadOutcomes(config.output.trackerPath),
|
|
110
|
+
]);
|
|
111
|
+
bundle.recentPasses = recentPasses;
|
|
112
|
+
const signal = config.learning.adjustPromptBasedOnSignal ? computeSignal(outcomes, config) : null;
|
|
113
|
+
const prompt = await buildPrompt(bundle, signal);
|
|
114
|
+
return { content: [{ type: "text", text: prompt }] };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
await releaseLock(lockFile);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
api.registerTool({
|
|
123
|
+
name: "record_thinking_output",
|
|
124
|
+
description: "Record structured thinking proposals from this pass. Call after get_thinking_context.",
|
|
125
|
+
parameters: Type.Object({ proposals: Type.Unknown() }),
|
|
126
|
+
async execute(_id, params) {
|
|
127
|
+
try {
|
|
128
|
+
const proposals = parseProposals(params.proposals);
|
|
129
|
+
await appendPass(proposals, config.output.logPath);
|
|
130
|
+
await appendStructuredProposals(proposals, config.output.logPath);
|
|
131
|
+
if (config.learning.trackOutcomes) {
|
|
132
|
+
let outcomes = await loadOutcomes(config.output.trackerPath);
|
|
133
|
+
outcomes = addProposals(outcomes, proposals);
|
|
134
|
+
outcomes = expireOldProposals(outcomes);
|
|
135
|
+
await saveOutcomes(outcomes, config.output.trackerPath);
|
|
136
|
+
}
|
|
137
|
+
const sapienceActive = await access(join(workspaceDir, "sapience", ".present")).then(() => true, () => false);
|
|
138
|
+
if (!sapienceActive)
|
|
139
|
+
await maybeDeliver(proposals, api, config);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
const passId = params.proposals?.pass_id ?? "unknown";
|
|
143
|
+
await appendError(passId, err instanceof ParseError ? err.message : String(err), config.output.logPath);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await releaseLock(lockFile);
|
|
147
|
+
}
|
|
148
|
+
return { content: [{ type: "text", text: "SILENT_REPLY_TOKEN" }] };
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function computeSignal(outcomes, config) {
|
|
2
|
+
const records = Object.values(outcomes);
|
|
3
|
+
if (records.length === 0)
|
|
4
|
+
return null;
|
|
5
|
+
const earliest = records.reduce((min, r) => new Date(r.created_at) < new Date(min.created_at) ? r : min);
|
|
6
|
+
const ageDays = (Date.now() - new Date(earliest.created_at).getTime()) / (24 * 60 * 60 * 1000);
|
|
7
|
+
if (ageDays < config.learning.bootstrapDays)
|
|
8
|
+
return null;
|
|
9
|
+
const byType = (type) => records.filter((r) => r.proposal_type === type);
|
|
10
|
+
const resolved = (recs) => recs.filter((r) => r.state !== "pending" && r.state !== "expired");
|
|
11
|
+
const actedOn = (recs) => recs.filter((r) => r.state === "acted_on" || r.state === "accepted");
|
|
12
|
+
const obss = byType("observation");
|
|
13
|
+
const actions = byType("action");
|
|
14
|
+
const audits = byType("audit");
|
|
15
|
+
const questions = byType("question");
|
|
16
|
+
return {
|
|
17
|
+
observations: { total: obss.length, reviewed: resolved(obss).length, acted_on: actedOn(obss).length },
|
|
18
|
+
actions: {
|
|
19
|
+
total: actions.length,
|
|
20
|
+
acted_on: actedOn(actions).length,
|
|
21
|
+
rejected: actions.filter((r) => r.state === "rejected").length,
|
|
22
|
+
},
|
|
23
|
+
audits: { total: audits.length, accepted: actedOn(audits).length },
|
|
24
|
+
questions: {
|
|
25
|
+
total: questions.length,
|
|
26
|
+
answered: questions.filter((r) => r.state === "acknowledged" || r.state === "acted_on" || r.state === "accepted").length,
|
|
27
|
+
},
|
|
28
|
+
computed_at: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
const PrioritySchema = Type.Union([
|
|
3
|
+
Type.Literal(1), Type.Literal(2), Type.Literal(3), Type.Literal(4), Type.Literal(5),
|
|
4
|
+
]);
|
|
5
|
+
export const ObservationSchema = Type.Object({
|
|
6
|
+
id: Type.String(),
|
|
7
|
+
text: Type.String(),
|
|
8
|
+
evidence: Type.String(),
|
|
9
|
+
priority: PrioritySchema,
|
|
10
|
+
});
|
|
11
|
+
export const ProposedActionSchema = Type.Object({
|
|
12
|
+
id: Type.String(),
|
|
13
|
+
text: Type.String(),
|
|
14
|
+
rationale: Type.String(),
|
|
15
|
+
estimated_effort: Type.Union([Type.Literal("small"), Type.Literal("medium"), Type.Literal("large")]),
|
|
16
|
+
priority: PrioritySchema,
|
|
17
|
+
});
|
|
18
|
+
export const ProposedAuditSchema = Type.Object({
|
|
19
|
+
id: Type.String(),
|
|
20
|
+
domain: Type.String(),
|
|
21
|
+
rationale: Type.String(),
|
|
22
|
+
priority: PrioritySchema,
|
|
23
|
+
});
|
|
24
|
+
export const OpenQuestionSchema = Type.Object({
|
|
25
|
+
id: Type.String(),
|
|
26
|
+
text: Type.String(),
|
|
27
|
+
blocking_what: Type.String(),
|
|
28
|
+
});
|
|
29
|
+
export const ProposalSetSchema = Type.Object({
|
|
30
|
+
pass_id: Type.String(),
|
|
31
|
+
timestamp: Type.String(),
|
|
32
|
+
observations: Type.Array(ObservationSchema),
|
|
33
|
+
proposed_actions: Type.Array(ProposedActionSchema),
|
|
34
|
+
proposed_audits: Type.Array(ProposedAuditSchema),
|
|
35
|
+
open_questions: Type.Array(OpenQuestionSchema),
|
|
36
|
+
nothing_to_report: Type.Boolean(),
|
|
37
|
+
summary: Type.String(),
|
|
38
|
+
});
|
|
39
|
+
export const DEFAULT_CONFIG = {
|
|
40
|
+
schedule: "*/15 * * * *",
|
|
41
|
+
activeHours: { start: "08:00", end: "20:00", timezone: "America/Los_Angeles" },
|
|
42
|
+
context: { lookbackHours: 2, maxContextTokens: 8000 },
|
|
43
|
+
output: {
|
|
44
|
+
logPath: "proactive-thinking/log.md",
|
|
45
|
+
trackerPath: "proactive-thinking/outcomes.json",
|
|
46
|
+
},
|
|
47
|
+
delivery: { heartbeatTrigger: true, priorityThreshold: 4, maxProposalsPerHeartbeat: 3 },
|
|
48
|
+
learning: { trackOutcomes: true, adjustPromptBasedOnSignal: true, bootstrapDays: 14 },
|
|
49
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
// Rough estimate: ~4 chars per token on average; intentionally imprecise
|
|
14
|
+
export function estimateTokens(text) {
|
|
15
|
+
return Math.ceil(text.length / 4);
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "sapience-thinking",
|
|
3
|
+
"name": "Sapience Thinking",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Periodic isolated thinking passes that produce structured proposals",
|
|
6
|
+
"contracts": {
|
|
7
|
+
"tools": ["get_thinking_context", "record_thinking_output"]
|
|
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/sapience-thinking",
|
|
3
|
+
"version": "0.1.4",
|
|
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
|
+
}
|