@akalsey/sapience 0.1.3 → 0.2.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.
@@ -0,0 +1,201 @@
1
+ import { readFile, writeFile, mkdir, stat, rename } from "fs/promises";
2
+ import { dirname, join } from "path";
3
+ import { resolvePath } from "./utils.js";
4
+ import { loadProfile } from "./calibration.js";
5
+ const SPARK = "▁▂▃▄▅▆▇█";
6
+ function safe(s) {
7
+ return String(s).replace(/\|/g, "\\|");
8
+ }
9
+ const MAX_EVENTS_BYTES = 5 * 1024 * 1024;
10
+ const DAY_MS = 24 * 60 * 60 * 1000;
11
+ const SKIP_TYPES = new Set(["pass_skipped", "routing_skipped", "check_skipped"]);
12
+ export function parseEvents(raw) {
13
+ const events = [];
14
+ let malformed = 0;
15
+ for (const line of raw.split("\n")) {
16
+ if (!line.trim())
17
+ continue;
18
+ try {
19
+ const ev = JSON.parse(line);
20
+ if (typeof ev.ts === "string" && typeof ev.type === "string")
21
+ events.push(ev);
22
+ else
23
+ malformed++;
24
+ }
25
+ catch {
26
+ malformed++;
27
+ }
28
+ }
29
+ return { events, malformed };
30
+ }
31
+ export function sparkline(values) {
32
+ return values
33
+ .map(v => SPARK[Math.min(7, Math.max(0, Math.round(v * 7)))])
34
+ .join("");
35
+ }
36
+ export function confidenceTrend(events, domain, actionClass, currentConfidence, now) {
37
+ const cutoff = now.getTime() - 7 * DAY_MS;
38
+ const changes = events
39
+ .filter(e => e.type === "calibration_change" && e.domain === domain && e.action_class === actionClass)
40
+ .filter(e => new Date(e.ts).getTime() >= cutoff)
41
+ .sort((a, b) => (a.ts < b.ts ? -1 : 1));
42
+ if (changes.length === 0)
43
+ return "(no history yet)";
44
+ const series = changes
45
+ .map(e => Number(e.new_confidence))
46
+ .filter(n => !Number.isNaN(n));
47
+ if (series.length === 0)
48
+ return "(no history yet)";
49
+ const baselineRaw = changes[0].old_confidence;
50
+ const baseline = typeof baselineRaw === "number" ? baselineRaw : (series[0] ?? currentConfidence);
51
+ const delta = currentConfidence - baseline;
52
+ const arrow = delta > 0.001 ? "↑" : delta < -0.001 ? "↓" : "→";
53
+ const sign = delta >= 0 ? "+" : "";
54
+ return `${sparkline(series.slice(-10))} ${arrow} ${sign}${delta.toFixed(2)}`;
55
+ }
56
+ function within(e, now, ms) {
57
+ const t = new Date(e.ts).getTime();
58
+ return !Number.isNaN(t) && now.getTime() - t <= ms && t <= now.getTime() + 60_000;
59
+ }
60
+ function fmtTime(ts) {
61
+ return ts.slice(0, 16).replace("T", " ");
62
+ }
63
+ function expectedRuns(activeHours) {
64
+ const [sh, sm] = activeHours.start.split(":").map(Number);
65
+ const [eh, em] = activeHours.end.split(":").map(Number);
66
+ const minutes = ((eh ?? 0) * 60 + (em ?? 0)) - ((sh ?? 0) * 60 + (sm ?? 0));
67
+ if (Number.isNaN(minutes))
68
+ return 0;
69
+ return Math.max(0, Math.floor(minutes / 15) + 1);
70
+ }
71
+ function skipSummary(skips) {
72
+ const byReason = new Map();
73
+ for (const e of skips) {
74
+ const r = String(e.reason ?? "unknown");
75
+ byReason.set(r, (byReason.get(r) ?? 0) + 1);
76
+ }
77
+ if (byReason.size === 0)
78
+ return "0";
79
+ return [...byReason.entries()].map(([r, n]) => `${n} ${r}`).join(", ");
80
+ }
81
+ function lastActivity(events) {
82
+ if (events.length === 0)
83
+ return "—";
84
+ return fmtTime(events.map(e => e.ts).sort().at(-1));
85
+ }
86
+ function describeEvent(e) {
87
+ switch (e.type) {
88
+ case "pass_completed":
89
+ return e.nothing_to_report
90
+ ? `thinking pass ${safe(e.pass_id)}: nothing to report`
91
+ : `thinking pass ${safe(e.pass_id)}: ${e.observations} obs, ${e.actions} actions, ${e.audits} audits, ${e.questions} questions`;
92
+ case "routing_completed":
93
+ return `routed ${e.items} item(s) from ${e.passes} pass(es)`;
94
+ case "calibration_change":
95
+ return `calibration ${safe(e.domain)}/${safe(e.action_class)}: ${e.old_tier ?? "new"}→${e.new_tier} conf ${e.new_confidence}`;
96
+ case "action_logged":
97
+ return `autonomous action (${safe(e.domain)}/${safe(e.action_class)})`;
98
+ case "digest_delivered":
99
+ return "weekly digest delivered";
100
+ case "signal_detected":
101
+ return `${safe(e.signal_type)} captured (${safe(e.domain)}, ${safe(e.source)})`;
102
+ case "signal_orphaned":
103
+ return `${safe(e.signal_type)} matched no calibration entry (${safe(e.domain)})`;
104
+ case "goal_created":
105
+ return `goal created (${safe(e.goal_id)})`;
106
+ case "status_delivered":
107
+ return `goal status delivered (${safe(e.goal_id)})`;
108
+ default:
109
+ return String(e.type);
110
+ }
111
+ }
112
+ export function buildDashboard(input) {
113
+ const { events, malformed, profile, goals, activeHours, now } = input;
114
+ const d30 = events.filter(e => within(e, now, 30 * DAY_MS));
115
+ const d7 = d30.filter(e => within(e, now, 7 * DAY_MS));
116
+ const d24 = d30.filter(e => within(e, now, DAY_MS));
117
+ const lines = [];
118
+ lines.push("# Sapience Dashboard", "");
119
+ lines.push(`Generated: ${fmtTime(now.toISOString())} UTC · Period: last 30 days`, "");
120
+ lines.push("## Autonomy progression", "");
121
+ if (profile.length === 0) {
122
+ lines.push("No calibration entries yet.", "");
123
+ }
124
+ else {
125
+ lines.push("| Domain / class | Tier | Confidence | 7d trend | Confirmed | Corrected |");
126
+ lines.push("| --- | --- | --- | --- | --- | --- |");
127
+ for (const e of [...profile].sort((a, b) => b.confidence - a.confidence)) {
128
+ lines.push(`| ${safe(e.domain)} / ${safe(e.action_class)} | ${e.tier} | ${e.confidence.toFixed(2)} | ` +
129
+ `${confidenceTrend(d7, e.domain, e.action_class, e.confidence, now)} | ` +
130
+ `${e.confirmed_count} | ${e.corrected_count} |`);
131
+ }
132
+ lines.push("");
133
+ }
134
+ const tierChanges = d30.filter(e => e.type === "calibration_change" && e.old_tier && e.new_tier && e.old_tier !== e.new_tier);
135
+ lines.push(`**Tier changes (30d):** ${tierChanges.length === 0 ? "none" : tierChanges
136
+ .map(e => `${e.domain}/${e.action_class} ${e.old_tier}→${e.new_tier} (${String(e.ts).slice(0, 10)})`)
137
+ .join(" · ")}`);
138
+ const acted7 = d7.filter(e => e.type === "action_logged").length;
139
+ const acted30 = d30.filter(e => e.type === "action_logged").length;
140
+ lines.push(`**Autonomous actions:** ${acted7} in last 7d · ${acted30} in last 30d`, "");
141
+ lines.push("## Heartbeat", "");
142
+ const exp = expectedRuns(activeHours);
143
+ const of = (plugin, types) => d24.filter(e => e.plugin === plugin && types.includes(String(e.type)));
144
+ lines.push("| Plugin | Runs (24h) | Expected | Skips (24h) | Last activity |");
145
+ lines.push("| --- | --- | --- | --- | --- |");
146
+ lines.push(`| thinking | ${of("thinking", ["pass_completed"]).length} | ~${exp} | ${skipSummary(of("thinking", ["pass_skipped"]))} | ${lastActivity(events.filter(e => e.plugin === "thinking"))} |`);
147
+ lines.push(`| sapience | ${of("sapience", ["routing_completed"]).length} | ~${exp} | ${skipSummary(of("sapience", ["routing_skipped"]))} | ${lastActivity(events.filter(e => e.plugin === "sapience"))} |`);
148
+ lines.push(`| feedback | ${of("feedback", ["signal_detected", "signal_orphaned"]).length} signals | — | — | ${lastActivity(events.filter(e => e.plugin === "feedback"))} |`);
149
+ lines.push(`| goals | ${of("goals", ["goal_created", "status_delivered", "check_skipped"]).length} | ~${exp} | ${skipSummary(of("goals", ["check_skipped"]))} | ${lastActivity(events.filter(e => e.plugin === "goals"))} |`);
150
+ const active = goals.filter(g => g.status === "active").length;
151
+ const decomposing = goals.filter(g => g.status === "decomposing").length;
152
+ lines.push("", `**Goals:** ${active} active · ${decomposing} decomposing · ${goals.length} total`, "");
153
+ lines.push("## Recent activity", "");
154
+ const notable = d30
155
+ .filter(e => !SKIP_TYPES.has(String(e.type)))
156
+ .sort((a, b) => (a.ts < b.ts ? 1 : -1))
157
+ .slice(0, 15);
158
+ if (notable.length === 0)
159
+ lines.push("No event data yet.");
160
+ for (const e of notable)
161
+ lines.push(`- ${fmtTime(e.ts)} ${describeEvent(e)}`);
162
+ lines.push("");
163
+ if (malformed > 0)
164
+ lines.push(`_${malformed} malformed event line(s) skipped._`, "");
165
+ return lines.join("\n");
166
+ }
167
+ // Known benign race: a concurrent appendEvent between stat and rename can lose
168
+ // at most one event line. This is acceptable for observability data.
169
+ export async function rotateIfNeeded(eventsPath, now, maxBytes = MAX_EVENTS_BYTES) {
170
+ try {
171
+ const s = await stat(eventsPath);
172
+ if (s.size > maxBytes) {
173
+ const stamp = now.toISOString().slice(0, 19).replace(/[T:]/g, "-");
174
+ await rename(eventsPath, join(dirname(eventsPath), `events-archive-${stamp}.jsonl`));
175
+ }
176
+ }
177
+ catch {
178
+ // missing file: nothing to rotate
179
+ }
180
+ }
181
+ export async function generateDashboard(config, now = new Date()) {
182
+ const eventsPath = resolvePath(config.output.eventsPath);
183
+ await rotateIfNeeded(eventsPath, now);
184
+ let raw = "";
185
+ try {
186
+ raw = await readFile(eventsPath, "utf-8");
187
+ }
188
+ catch { /* no events yet */ }
189
+ const { events, malformed } = parseEvents(raw);
190
+ const profile = await loadProfile(config.output.calibrationPath);
191
+ let goals = [];
192
+ try {
193
+ goals = JSON.parse(await readFile(resolvePath(config.output.goalsPath), "utf-8"));
194
+ }
195
+ catch { /* no goals file */ }
196
+ const md = buildDashboard({ events, malformed, profile, goals, activeHours: config.activeHours, now });
197
+ const out = resolvePath(config.output.dashboardPath);
198
+ await mkdir(dirname(out), { recursive: true });
199
+ await writeFile(out + ".tmp", md, "utf-8");
200
+ await rename(out + ".tmp", out);
201
+ }
@@ -1,4 +1,5 @@
1
1
  import { appendAction } from "./action-log.js";
2
+ import { appendEvent } from "./events.js";
2
3
  export function buildTierPrompt(item) {
3
4
  switch (item.tier) {
4
5
  case "act":
@@ -45,6 +46,13 @@ export async function deliverItems(items, api, config) {
45
46
  for (const item of sorted) {
46
47
  if (item.tier === "act") {
47
48
  await appendAction(item, "Queued for immediate execution", config.output.actionLogPath);
49
+ await appendEvent(config.output.eventsPath, {
50
+ plugin: "sapience",
51
+ type: "action_logged",
52
+ domain: item.domain,
53
+ action_class: item.action_class,
54
+ confidence: item.confidence,
55
+ });
48
56
  }
49
57
  await api.session.workflow.enqueueNextTurnInjection({
50
58
  sessionTarget: "main",
@@ -0,0 +1,12 @@
1
+ import { appendFile, mkdir } from "fs/promises";
2
+ import { dirname } from "path";
3
+ export async function appendEvent(eventsPath, event) {
4
+ try {
5
+ const full = { ...event, ts: event.ts ?? new Date().toISOString() };
6
+ await mkdir(dirname(eventsPath), { recursive: true });
7
+ await appendFile(eventsPath, JSON.stringify(full) + "\n", "utf-8");
8
+ }
9
+ catch {
10
+ // Observability must never break the host plugin.
11
+ }
12
+ }
@@ -10,6 +10,8 @@ import { readUnprocessedPasses, proposalSetToItems } from "./proposal-adapter.js
10
10
  import { loadProcessedPasses, markPassProcessed, bootstrapProcessedPasses } from "./processed-passes.js";
11
11
  import { deliverItems } from "./delivery.js";
12
12
  import { isDigestDay, buildDigestPrompt } from "./weekly-digest.js";
13
+ import { appendEvent } from "./events.js";
14
+ import { generateDashboard } from "./dashboard.js";
13
15
  function mergeConfig(raw, workspaceDir) {
14
16
  return {
15
17
  ...DEFAULT_CONFIG,
@@ -29,6 +31,9 @@ function mergeConfig(raw, workspaceDir) {
29
31
  calibrationPath: resolveDataPath(raw.output?.calibrationPath, workspaceDir, DEFAULT_CONFIG.output.calibrationPath),
30
32
  actionLogPath: resolveDataPath(raw.output?.actionLogPath, workspaceDir, DEFAULT_CONFIG.output.actionLogPath),
31
33
  processedPassesPath: resolveDataPath(raw.output?.processedPassesPath, workspaceDir, DEFAULT_CONFIG.output.processedPassesPath),
34
+ eventsPath: resolveDataPath(raw.output?.eventsPath, workspaceDir, DEFAULT_CONFIG.output.eventsPath),
35
+ dashboardPath: resolveDataPath(raw.output?.dashboardPath, workspaceDir, DEFAULT_CONFIG.output.dashboardPath),
36
+ goalsPath: resolveDataPath(raw.output?.goalsPath, workspaceDir, DEFAULT_CONFIG.output.goalsPath),
32
37
  },
33
38
  };
34
39
  }
@@ -50,6 +55,8 @@ export default definePluginEntry({
50
55
  async execute(_id, _params) {
51
56
  try {
52
57
  if (!isWithinActiveHours(config)) {
58
+ await appendEvent(config.output.eventsPath, { plugin: "sapience", type: "routing_skipped", reason: "outside_hours" });
59
+ await generateDashboard(config).catch(() => { });
53
60
  return { content: [{ type: "text", text: "SILENT_REPLY_TOKEN" }] };
54
61
  }
55
62
  let processed = await loadProcessedPasses(config.output.processedPassesPath);
@@ -61,26 +68,55 @@ export default definePluginEntry({
61
68
  const newPasses = await readUnprocessedPasses(config.proactiveThinking.proposalsPath, processed);
62
69
  let updatedProcessed = processed;
63
70
  let updatedProfile = profile;
71
+ let totalItems = 0;
72
+ const byTier = {};
64
73
  for (const pass of newPasses) {
65
74
  const items = proposalSetToItems(pass);
66
75
  const routed = items.map(item => routeItem(item, updatedProfile, config));
67
76
  await deliverItems(routed, api, config);
68
77
  updatedProcessed = await markPassProcessed(pass.pass_id, config.output.processedPassesPath, updatedProcessed);
69
78
  for (const item of routed) {
79
+ totalItems++;
80
+ byTier[item.tier] = (byTier[item.tier] ?? 0) + 1;
70
81
  const exists = updatedProfile.find(e => e.domain === item.domain && e.action_class === item.action_class);
71
82
  if (!exists) {
72
83
  updatedProfile = upsertEntry(updatedProfile, item.domain, item.action_class, {
73
84
  tier: config.autonomy.defaultTier,
74
85
  confidence: 0,
75
86
  });
87
+ await appendEvent(config.output.eventsPath, {
88
+ plugin: "sapience",
89
+ type: "calibration_change",
90
+ domain: item.domain,
91
+ action_class: item.action_class,
92
+ old_confidence: null,
93
+ new_confidence: 0,
94
+ old_tier: null,
95
+ new_tier: config.autonomy.defaultTier,
96
+ source: "new_entry",
97
+ });
76
98
  }
77
99
  }
78
100
  }
79
101
  await saveProfile(updatedProfile, config.output.calibrationPath);
102
+ if (newPasses.length === 0) {
103
+ await appendEvent(config.output.eventsPath, { plugin: "sapience", type: "routing_skipped", reason: "no_new_passes" });
104
+ }
105
+ else {
106
+ await appendEvent(config.output.eventsPath, {
107
+ plugin: "sapience",
108
+ type: "routing_completed",
109
+ passes: newPasses.length,
110
+ items: totalItems,
111
+ by_tier: byTier,
112
+ });
113
+ }
80
114
  if (config.digest.enabled && isDigestDay(config)) {
81
115
  const prompt = await buildDigestPrompt(config);
82
116
  await api.session.workflow.enqueueNextTurnInjection({ sessionTarget: "main", text: prompt });
117
+ await appendEvent(config.output.eventsPath, { plugin: "sapience", type: "digest_delivered" });
83
118
  }
119
+ await generateDashboard(config).catch(() => { });
84
120
  return { content: [{ type: "text", text: "SILENT_REPLY_TOKEN" }] };
85
121
  }
86
122
  catch (err) {
package/dist/src/types.js CHANGED
@@ -18,5 +18,8 @@ export const DEFAULT_CONFIG = {
18
18
  calibrationPath: "sapience/calibration.json",
19
19
  actionLogPath: "sapience/action-log.md",
20
20
  processedPassesPath: "sapience/processed-passes.json",
21
+ eventsPath: "sapience/events.jsonl",
22
+ dashboardPath: "sapience/dashboard.md",
23
+ goalsPath: "goals/goals.json",
21
24
  },
22
25
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akalsey/sapience",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {