@akalsey/sapience-feedback 0.1.1 → 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.
package/README.md CHANGED
@@ -32,7 +32,12 @@ openclaw plugins install npm:@akalsey/sapience-feedback
32
32
  "sapience-feedback": {
33
33
  "logPath": "~/.openclaw/sapience/feedback.md",
34
34
  "calibrationPath": "~/.openclaw/sapience/calibration.json",
35
- "memoryEnabled": true
35
+ "memoryEnabled": true,
36
+ "semanticDetection": {
37
+ "enabled": true,
38
+ "minLength": 8,
39
+ "minConfidence": 0.6
40
+ }
36
41
  }
37
42
  }
38
43
  }
@@ -40,62 +45,51 @@ openclaw plugins install npm:@akalsey/sapience-feedback
40
45
 
41
46
  All settings are optional — defaults are used if omitted.
42
47
 
48
+ **`semanticDetection`** controls the LLM-based classifier. When enabled (the default), every user message above `minLength` characters is classified by the agent's default inference provider. Set `enabled: false` to fall back to regex-only matching (useful if you want zero LLM cost on routine chat).
49
+
43
50
  ---
44
51
 
45
52
  ## What it detects
46
53
 
47
- The plugin scans every message you send for three types of signals:
54
+ Every user message is analyzed by the agent's default inference provider (using `api.runtime.llm.complete` — no separate provider configuration required). The classifier returns structured signals in one of three categories. No trigger words or special syntax — speak normally.
48
55
 
49
56
  ### Corrections
50
57
 
51
- Phrases that express "don't do that" or "do it differently":
52
-
53
- - `don't update OKRs for other teams without asking`
54
- - `stop doing that`
55
- - `never push to main without a PR`
56
- - `use the company template, not the default`
57
- - `you shouldn't have done that without checking`
58
+ Anything that tells the agent it did something wrong or should do it differently. The classifier picks up direct phrasing ("don't push to main"), rhetorical questions ("did you check the password manager first?"), and implicit critiques ("is there something wrong with the passwords you have?").
58
59
 
59
60
  **Effect:** Confidence on the matching domain/action-class drops by 0.3.
60
61
 
61
62
  ### Confirmations
62
63
 
63
- Phrases that express "yes, that was right":
64
-
65
- - `yes exactly`
66
- - `good call`
67
- - `perfect, keep doing that`
68
- - `that's exactly right`
64
+ Anything that reinforces what the agent just did — agreement, praise, "keep doing that".
69
65
 
70
66
  **Effect:** Confidence on the matching domain/action-class increases by 0.1.
71
67
 
72
68
  ### Tier adjustments
73
69
 
74
- Explicit instructions about how much autonomy you want:
75
-
76
- - `just do it` / `you don't need to ask` → bumps toward **Act**
77
- - `always ask me first` / `ask me before touching X` → bumps toward **Ask**
70
+ Instructions about how much autonomy the agent should have. "Just do it" or "stop asking" bumps toward **Act**. "Always check first" or "ask me before doing X" bumps toward **Ask**.
78
71
 
79
72
  **Effect:** Tier for matching domain/action-class is updated directly.
80
73
 
74
+ If the LLM is unavailable (no `api.runtime.llm` exposed, or the call fails), the plugin falls back to a regex matcher covering the common phrasings. The regex layer is intentionally conservative and misses paraphrases — semantic detection is the primary path.
75
+
81
76
  ---
82
77
 
83
- ## Domain detection
78
+ ## Explicit feedback: the `/feedback` command
84
79
 
85
- The plugin extracts domains from context in your message:
80
+ When you want to leave feedback without ambiguity, use the slash command:
86
81
 
87
- | Text contains | Domain |
88
- |---------------|--------|
89
- | `github`, `PR`, `push` | `github` |
90
- | `Salesforce` | `salesforce` |
91
- | `PostHog` | `posthog` |
92
- | `Slack` | `slack` |
93
- | `slides`, `deck` | `slides` |
94
- | `OKR` | `okr-system` |
95
- | `Linear` | `linear` |
96
- | (nothing matched) | `general` |
82
+ ```
83
+ /feedback always look at the password manager before asking me for credentials
84
+ ```
85
+
86
+ The command runs the same classifier and then records the result as a `manual` signal. If the classifier finds no clear signal, the message is still logged as a generic correction in the `general` domain — manual feedback is never discarded.
87
+
88
+ ---
89
+
90
+ ## Domain detection
97
91
 
98
- Domain detection is keyword-based, not semantic. Be explicit when giving feedback: "don't update Salesforce records" works better than "don't do that" (which routes to `general`).
92
+ The LLM extracts a domain slug from the content of your message: `github`, `credentials`, `okr-system`, `salesforce`, etc. When the LLM can't identify anything specific, it returns `general`. The regex fallback uses a fixed keyword table and is more likely to bucket things into `general`.
99
93
 
100
94
  ---
101
95
 
@@ -134,8 +128,8 @@ The plugin only scans messages you send (role: `user`), not the agent's response
134
128
  **Calibration not updating**
135
129
  Check that `calibration.json` exists and has an entry for the domain you're correcting. Feedback only updates *existing* entries — it doesn't create new ones. New domains are created by `sapience` when it first routes a proposal in that domain.
136
130
 
137
- **Domain matching to "general" when it shouldn't**
138
- Add more specific keywords to your feedback message. "Don't do that" "Don't update the Salesforce contact without asking."
131
+ **Feedback getting misclassified or missed**
132
+ Raise the bar with `semanticDetection.minConfidence` if the classifier is too noisy; lower it if real feedback is being dropped. To force a recording, use `/feedback <text>` manual entries bypass the confidence threshold.
139
133
 
140
- **Too many false positives in the feedback log**
141
- The pattern matching is intentionally broad. If casual phrases are being captured incorrectly, check the log and note which patterns are misfiring. You can't currently tune the patterns without modifying `src/feedback-parser.ts`.
134
+ **LLM cost concerns**
135
+ Every user message above `minLength` characters incurs one classifier call. To disable, set `semanticDetection.enabled: false` the plugin will fall back to the regex matcher with no LLM calls.
@@ -0,0 +1,46 @@
1
+ import { parseMessage } from "./feedback-parser.js";
2
+ import { classifyWithLlm } from "./llm-classifier.js";
3
+ import { appendFeedback } from "./log-writer.js";
4
+ import { applyFeedbackToProfile } from "./calibration-bridge.js";
5
+ import { generateId } from "./utils.js";
6
+ export function shouldClassify(text, config) {
7
+ const trimmed = text.trim();
8
+ if (trimmed.length < config.semanticDetection.minLength)
9
+ return false;
10
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~"))
11
+ return false;
12
+ return true;
13
+ }
14
+ export async function classifyMessage(text, config, llm) {
15
+ if (!shouldClassify(text, config))
16
+ return [];
17
+ if (config.semanticDetection.enabled && llm) {
18
+ const signals = await classifyWithLlm(text, llm, {
19
+ minConfidence: config.semanticDetection.minConfidence,
20
+ });
21
+ if (signals.length > 0)
22
+ return signals;
23
+ }
24
+ return parseMessage(text).map(s => ({ ...s, source: "regex" }));
25
+ }
26
+ export function buildMetaPointer(signal) {
27
+ return `Before working on ${signal.domain} / ${signal.action_class}: check feedback log — correction recorded: "${signal.raw_text.slice(0, 80)}"`;
28
+ }
29
+ export async function persistSignal(signal, ctx) {
30
+ const metaPointer = signal.type === "correction" ? buildMetaPointer(signal) : undefined;
31
+ const entry = {
32
+ id: generateId(),
33
+ detected_at: new Date().toISOString(),
34
+ signal,
35
+ meta_pointer: metaPointer,
36
+ };
37
+ await appendFeedback(entry, ctx.config.logPath);
38
+ await applyFeedbackToProfile(signal, ctx.config.calibrationPath);
39
+ if (metaPointer && ctx.config.memoryEnabled && ctx.memoryAdd) {
40
+ await ctx.memoryAdd({
41
+ content: metaPointer,
42
+ metadata: { tags: ["feedback", "behavioral-correction", signal.domain], source: "feedback" },
43
+ });
44
+ }
45
+ return entry;
46
+ }
@@ -0,0 +1,134 @@
1
+ const VALID_TYPES = new Set(["correction", "confirmation", "tier_adjustment"]);
2
+ const VALID_TIERS = new Set(["act", "propose", "ask", "explore"]);
3
+ const SYSTEM_PROMPT = `You analyze ONE chat message a developer sent to their AI coding agent. Decide whether it contains behavioral feedback the agent should learn from.
4
+
5
+ Three signal types:
6
+
7
+ 1. "correction" — the user is correcting the agent, pointing out a mistake, redirecting an approach, expressing frustration with a recent action, or telling it to use a different tool/source. This includes leading questions like "did you check X first?" or "is there something wrong with X?" — these are corrections phrased as rhetorical questions.
8
+
9
+ 2. "confirmation" — the user is reinforcing what the agent just did: praising, agreeing, asking it to keep behaving that way.
10
+
11
+ 3. "tier_adjustment" — the user is changing the agent's autonomy level:
12
+ - suggested_tier "act": user wants less asking ("just do it", "stop asking", "go ahead without me")
13
+ - suggested_tier "ask": user wants more checking ("always ask first", "never do that without checking", "check X before doing Y")
14
+
15
+ A message is NOT feedback if it is: a fresh task request, a technical question about code or systems, a code snippet, conversational filler, or status unrelated to the agent's behavior.
16
+
17
+ When unsure, prefer empty output — false positives are worse than misses.
18
+
19
+ For each detected signal extract:
20
+ - "type": one of "correction" | "confirmation" | "tier_adjustment"
21
+ - "domain": short kebab-case slug for the subject area. Use what fits: "github", "credentials", "okr-system", "salesforce", "slack", "slides", "linear", "posthog", or invent a clear one. Use "general" only if nothing specific applies.
22
+ - "action_class": short slug; "general" if nothing more specific
23
+ - "suggested_tier": "act" | "propose" | "ask" | "explore" — only set for tier_adjustment, otherwise null
24
+ - "confidence": 0..1, how confident you are this is genuine feedback
25
+
26
+ Respond with ONLY a single JSON object, no prose, no code fences:
27
+
28
+ {"signals":[{"type":"...","domain":"...","action_class":"general","suggested_tier":null,"confidence":0.0}]}
29
+
30
+ If no feedback: {"signals":[]}
31
+
32
+ Examples:
33
+
34
+ Message: "did you look in the password manager before asking me for credentials"
35
+ Response: {"signals":[{"type":"correction","domain":"credentials","action_class":"general","suggested_tier":null,"confidence":0.9}]}
36
+
37
+ Message: "you need to always look at your password manager before asking me for credentials"
38
+ Response: {"signals":[{"type":"tier_adjustment","domain":"credentials","action_class":"general","suggested_tier":"ask","confidence":0.92}]}
39
+
40
+ Message: "is there something wrong with the passwords you have"
41
+ Response: {"signals":[{"type":"correction","domain":"credentials","action_class":"general","suggested_tier":null,"confidence":0.8}]}
42
+
43
+ Message: "you have credentials in the password manager"
44
+ Response: {"signals":[{"type":"correction","domain":"credentials","action_class":"general","suggested_tier":null,"confidence":0.75}]}
45
+
46
+ Message: "yes that's exactly what I wanted, keep doing that"
47
+ Response: {"signals":[{"type":"confirmation","domain":"general","action_class":"general","suggested_tier":null,"confidence":0.95}]}
48
+
49
+ Message: "what does this regex match"
50
+ Response: {"signals":[]}
51
+
52
+ Message: "write a function that reverses a linked list"
53
+ Response: {"signals":[]}`;
54
+ export function buildClassifierMessages(text) {
55
+ return [
56
+ { role: "system", content: SYSTEM_PROMPT },
57
+ { role: "user", content: `Message: ${JSON.stringify(text)}\nResponse:` },
58
+ ];
59
+ }
60
+ function stripFences(raw) {
61
+ const trimmed = raw.trim();
62
+ const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
63
+ if (fence)
64
+ return fence[1].trim();
65
+ return trimmed;
66
+ }
67
+ function extractJsonObject(raw) {
68
+ const stripped = stripFences(raw);
69
+ try {
70
+ return JSON.parse(stripped);
71
+ }
72
+ catch {
73
+ const first = stripped.indexOf("{");
74
+ const last = stripped.lastIndexOf("}");
75
+ if (first === -1 || last <= first)
76
+ return null;
77
+ try {
78
+ return JSON.parse(stripped.slice(first, last + 1));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ }
85
+ function normalizeSignal(raw, originalText) {
86
+ if (typeof raw.type !== "string" || !VALID_TYPES.has(raw.type))
87
+ return null;
88
+ const type = raw.type;
89
+ const domain = typeof raw.domain === "string" && raw.domain.length > 0 ? raw.domain : "general";
90
+ const action_class = typeof raw.action_class === "string" && raw.action_class.length > 0 ? raw.action_class : "general";
91
+ const confidence = typeof raw.confidence === "number" ? raw.confidence : undefined;
92
+ const signal = {
93
+ type,
94
+ domain,
95
+ action_class,
96
+ message: originalText,
97
+ raw_text: originalText,
98
+ source: "llm",
99
+ };
100
+ if (confidence !== undefined)
101
+ signal.confidence = confidence;
102
+ if (type === "tier_adjustment" && typeof raw.suggested_tier === "string" && VALID_TIERS.has(raw.suggested_tier)) {
103
+ signal.suggested_tier = raw.suggested_tier;
104
+ }
105
+ return signal;
106
+ }
107
+ export async function classifyWithLlm(text, client, options = {}) {
108
+ const minConfidence = options.minConfidence ?? 0;
109
+ let result;
110
+ try {
111
+ result = await client.complete({
112
+ messages: buildClassifierMessages(text),
113
+ maxTokens: options.maxTokens ?? 256,
114
+ temperature: options.temperature ?? 0.1,
115
+ purpose: options.purpose ?? "sapience-feedback.classify",
116
+ });
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ const parsed = extractJsonObject(result.text);
122
+ if (!parsed || !Array.isArray(parsed.signals))
123
+ return [];
124
+ const signals = [];
125
+ for (const raw of parsed.signals) {
126
+ const normalized = normalizeSignal(raw, text);
127
+ if (!normalized)
128
+ continue;
129
+ if (normalized.confidence !== undefined && normalized.confidence < minConfidence)
130
+ continue;
131
+ signals.push(normalized);
132
+ }
133
+ return signals;
134
+ }
@@ -1,20 +1,23 @@
1
1
  // src/service.ts
2
2
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3
3
  import { DEFAULT_CONFIG } from "./types.js";
4
- import { resolveDataPath, generateId } from "./utils.js";
5
- import { parseMessage } from "./feedback-parser.js";
6
- import { appendFeedback } from "./log-writer.js";
7
- import { applyFeedbackToProfile } from "./calibration-bridge.js";
4
+ import { resolveDataPath } from "./utils.js";
5
+ import { classifyMessage, persistSignal } from "./feedback-handler.js";
8
6
  function mergeConfig(raw, workspaceDir) {
7
+ const rawSemantic = raw.semanticDetection ?? {};
9
8
  return {
10
9
  ...DEFAULT_CONFIG,
11
10
  ...raw,
12
11
  logPath: resolveDataPath(raw.logPath, workspaceDir, DEFAULT_CONFIG.logPath),
13
12
  calibrationPath: resolveDataPath(raw.calibrationPath, workspaceDir, DEFAULT_CONFIG.calibrationPath),
13
+ semanticDetection: { ...DEFAULT_CONFIG.semanticDetection, ...rawSemantic },
14
14
  };
15
15
  }
16
- function buildMetaPointer(signal) {
17
- return `Before working on ${signal.domain} / ${signal.action_class}: check feedback log — correction recorded: "${signal.raw_text.slice(0, 80)}"`;
16
+ function getLlmClient(api) {
17
+ const llm = api?.runtime?.llm;
18
+ if (!llm || typeof llm.complete !== "function")
19
+ return null;
20
+ return { complete: (params) => llm.complete(params) };
18
21
  }
19
22
  export default definePluginEntry({
20
23
  id: "sapience-feedback",
@@ -23,28 +26,16 @@ export default definePluginEntry({
23
26
  register(api) {
24
27
  const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(api.pluginConfig);
25
28
  const config = mergeConfig(api.pluginConfig, workspaceDir);
29
+ const llm = getLlmClient(api);
30
+ const memoryAdd = api.memory?.add ? (params) => api.memory.add(params) : undefined;
26
31
  if (api.session?.onMessage) {
27
32
  api.session.onMessage(async (message) => {
28
33
  if (message.role !== "user")
29
34
  return;
30
35
  try {
31
- const signals = parseMessage(message.content);
36
+ const signals = await classifyMessage(message.content, config, llm);
32
37
  for (const signal of signals) {
33
- const metaPointer = signal.type === "correction" ? buildMetaPointer(signal) : undefined;
34
- const entry = {
35
- id: generateId(),
36
- detected_at: new Date().toISOString(),
37
- signal,
38
- meta_pointer: metaPointer,
39
- };
40
- await appendFeedback(entry, config.logPath);
41
- await applyFeedbackToProfile(signal, config.calibrationPath);
42
- if (metaPointer && config.memoryEnabled) {
43
- await api.memory?.add({
44
- content: metaPointer,
45
- metadata: { tags: ["feedback", "behavioral-correction", signal.domain], source: "feedback" },
46
- });
47
- }
38
+ await persistSignal(signal, { config, memoryAdd });
48
39
  }
49
40
  }
50
41
  catch {
@@ -52,5 +43,44 @@ export default definePluginEntry({
52
43
  }
53
44
  });
54
45
  }
46
+ if (typeof api.registerCommand === "function") {
47
+ api.registerCommand({
48
+ name: "feedback",
49
+ description: "Record explicit feedback for the agent to learn from. Usage: /feedback <your feedback>",
50
+ acceptsArgs: true,
51
+ handler: async (ctx) => {
52
+ const text = (ctx.args ?? "").trim();
53
+ if (!text) {
54
+ return { text: "Usage: /feedback <your feedback>\n\nExample: /feedback always check the password manager before asking me for credentials" };
55
+ }
56
+ try {
57
+ let signals = await classifyMessage(text, config, llm);
58
+ if (signals.length === 0) {
59
+ signals = [{
60
+ type: "correction",
61
+ domain: "general",
62
+ action_class: "general",
63
+ message: text,
64
+ raw_text: text,
65
+ source: "manual",
66
+ }];
67
+ }
68
+ else {
69
+ signals = signals.map(s => ({ ...s, source: "manual" }));
70
+ }
71
+ for (const signal of signals) {
72
+ await persistSignal(signal, { config, memoryAdd });
73
+ }
74
+ const summary = signals.map(s => s.type === "tier_adjustment" && s.suggested_tier
75
+ ? `${s.type} → ${s.suggested_tier} (${s.domain})`
76
+ : `${s.type} (${s.domain})`).join(", ");
77
+ return { text: `Recorded ${signals.length} feedback signal(s): ${summary}` };
78
+ }
79
+ catch (err) {
80
+ return { text: `Failed to record feedback: ${err instanceof Error ? err.message : String(err)}` };
81
+ }
82
+ },
83
+ });
84
+ }
55
85
  },
56
86
  });
package/dist/src/types.js CHANGED
@@ -2,4 +2,9 @@ export const DEFAULT_CONFIG = {
2
2
  logPath: "sapience/feedback.md",
3
3
  calibrationPath: "sapience/calibration.json",
4
4
  memoryEnabled: true,
5
+ semanticDetection: {
6
+ enabled: true,
7
+ minLength: 8,
8
+ minConfidence: 0.6,
9
+ },
5
10
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "sapience-feedback",
3
3
  "name": "Sapience Feedback",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Persists behavioral corrections and confirmations from session messages into the sapience calibration profile",
6
6
  "activation": { "onStartup": true },
7
7
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akalsey/sapience-feedback",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {