@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 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
+ }