@aitne/daemon 0.1.9 → 0.1.10

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.
Files changed (65) hide show
  1. package/dist/api/env-writer.d.ts +1 -0
  2. package/dist/api/env-writer.js +9 -2
  3. package/dist/api/routes/agent-schedule.js +5 -1
  4. package/dist/api/routes/apple-calendar.js +4 -1
  5. package/dist/api/routes/calendar.js +12 -2
  6. package/dist/api/routes/context/path-resolve.js +6 -1
  7. package/dist/api/routes/context/permissions.js +9 -0
  8. package/dist/api/routes/dashboard/config.js +10 -0
  9. package/dist/api/routes/dashboard/oauth-google.js +5 -3
  10. package/dist/api/routes/feedback.d.ts +3 -0
  11. package/dist/api/routes/feedback.js +349 -0
  12. package/dist/api/routes/git.js +10 -3
  13. package/dist/api/routes/github.js +5 -1
  14. package/dist/api/routes/mcp.js +65 -13
  15. package/dist/api/server.js +3 -0
  16. package/dist/bootstrap/event-pipeline.js +1 -1
  17. package/dist/config.js +6 -0
  18. package/dist/core/backends/gemini-cli-core.js +13 -0
  19. package/dist/core/backends/plan-presets.js +8 -3
  20. package/dist/core/context-builder.js +149 -3
  21. package/dist/core/context-paths.d.ts +10 -0
  22. package/dist/core/context-paths.js +16 -0
  23. package/dist/core/daemon-api-cli.js +1 -1
  24. package/dist/core/dispatcher-message-handler.js +7 -0
  25. package/dist/core/dispatcher-scheduled-tasks.d.ts +41 -0
  26. package/dist/core/dispatcher-scheduled-tasks.js +267 -2
  27. package/dist/core/dispatcher.js +13 -1
  28. package/dist/core/feedback/consolidation-prep.d.ts +94 -0
  29. package/dist/core/feedback/consolidation-prep.js +242 -0
  30. package/dist/core/feedback/eviction-scorer.d.ts +81 -0
  31. package/dist/core/feedback/eviction-scorer.js +132 -0
  32. package/dist/core/feedback/lesson-format.d.ts +79 -0
  33. package/dist/core/feedback/lesson-format.js +194 -0
  34. package/dist/core/feedback/lesson-injection.d.ts +98 -0
  35. package/dist/core/feedback/lesson-injection.js +159 -0
  36. package/dist/core/feedback/lesson-merge.d.ts +51 -0
  37. package/dist/core/feedback/lesson-merge.js +88 -0
  38. package/dist/core/feedback/lesson-store-overview.d.ts +42 -0
  39. package/dist/core/feedback/lesson-store-overview.js +38 -0
  40. package/dist/core/feedback/promotion-gate.d.ts +69 -0
  41. package/dist/core/feedback/promotion-gate.js +117 -0
  42. package/dist/core/feedback/regeneralization-prep.d.ts +87 -0
  43. package/dist/core/feedback/regeneralization-prep.js +139 -0
  44. package/dist/core/feedback/scope-parser.d.ts +86 -0
  45. package/dist/core/feedback/scope-parser.js +141 -0
  46. package/dist/core/injection-policy.d.ts +82 -0
  47. package/dist/core/injection-policy.js +58 -0
  48. package/dist/core/signal-detector.d.ts +39 -1
  49. package/dist/core/signal-detector.js +277 -24
  50. package/dist/core/today-direct-writer.d.ts +59 -13
  51. package/dist/core/today-direct-writer.js +90 -13
  52. package/dist/core/wiki/wiki-fts.js +13 -6
  53. package/dist/db/feedback-signals-store.d.ts +77 -0
  54. package/dist/db/feedback-signals-store.js +144 -0
  55. package/dist/db/migrations.js +50 -0
  56. package/dist/db/schema.js +43 -6
  57. package/dist/safety/always-disallowed.d.ts +1 -1
  58. package/dist/safety/always-disallowed.js +39 -0
  59. package/dist/safety/risk-classifier.js +22 -7
  60. package/dist/services/browser-history/automation/egress-denylist.js +18 -2
  61. package/dist/services/browser-history/lifecycle/platform.js +44 -2
  62. package/dist/services/mcp/probe.js +30 -8
  63. package/dist/settings/runtime-settings.d.ts +8 -2
  64. package/dist/settings/runtime-settings.js +12 -0
  65. package/package.json +2 -2
@@ -105,3 +105,85 @@ export interface InjectionPolicy {
105
105
  * not a third boolean on this struct.
106
106
  */
107
107
  export declare function getInjectionPolicy(eventOrProcessKey: string): InjectionPolicy;
108
+ /**
109
+ * FEEDBACK_LEARNING_LOOP_DESIGN.md §5 — the Stage-3 *opt-in* resolver for the
110
+ * feedback learning-loop's `<agent_lessons>` blocks.
111
+ *
112
+ * Co-located with `getInjectionPolicy` on purpose: this module is the single
113
+ * source of truth for "which surface sees which always-/sometimes-injected
114
+ * block", and the design explicitly rejects scattering an
115
+ * `isMessageEvent(event) || isNotifyDecidingRoutine(event)` check across
116
+ * `context-builder.ts` (it would re-introduce the fragmentation V20
117
+ * consolidated away). `<agent_lessons>` is **default-off** — only the handful
118
+ * of surfaces below want it — so it is an *opt-in* resolver, not a member of
119
+ * the `alwaysBlocks` *opt-out* set (which is for default-*on* heavy blocks a
120
+ * narrow routine sheds; a positive opt-out member would be the wrong polarity).
121
+ *
122
+ * Three fields, matching the design's documented shape:
123
+ *
124
+ * - `global` — inject `policies/agent-lessons.md ## Lessons` (scope `agent`:
125
+ * global agent-operating behaviour — notification discipline, filter
126
+ * quality). Phase 3 consumer: `ContextBuilder`.
127
+ * - `slim` — use the hard-2048-byte, top-N-by-score variant on the hourly
128
+ * notify turn (§6). Only `routine.hourly_check` sets it. Implies `global`.
129
+ * - `self` — eligible for the per-agent `policies/agents/<slug>/lessons.md`
130
+ * block (scope `agent:<slug>`). **Phase 4 consumer.** The builder reads it
131
+ * next to `<agent_identity>` and gates it on a resolved, path-safe slug
132
+ * stamped onto `event.data.agentId` at the dispatch site — `self === true`
133
+ * here means "this surface *may* carry self lessons"; an actual injection
134
+ * additionally requires the run to be bound to an Agent. `hourly_check`
135
+ * keeps `self: false` so the slim notify turn never carries a second block.
136
+ *
137
+ * **Surface keying is grounded in the real event-type strings build() sees,
138
+ * not the design's prose shorthand:**
139
+ * - DM / dashboard messages arrive as `message.*` (dashboard DMs included —
140
+ * they are `message.*` with `platform="dashboard"`).
141
+ * - The morning routine's *notify-deciding* stage builds context as
142
+ * `routine.morning_routine_today` (Stage A). The umbrella
143
+ * `routine.morning_routine` never reaches `build()` (the orchestrator
144
+ * decomposes it into the two stage events), and Stage B
145
+ * (`routine.morning_routine_journal`) is a lite journal author that decides
146
+ * no notifications — injecting lessons there would be wasted bytes against
147
+ * the §0 cost constraint. So Stage A is keyed, the umbrella and Stage B are
148
+ * not.
149
+ * - `routine.hourly_check` is the escalated Stage-3 LLM/notify turn (gate
150
+ * Layers 1–3 are code and build no prompt), so the slim block bites exactly
151
+ * where the notify decision is made. The `.triage` lite classification is
152
+ * intentionally excluded.
153
+ * - `scheduled.task` is **binding-aware** (Phase 4). A *bare* scheduled.task
154
+ * (generic close-the-loop task, observer-emitted cron, roadmap refresh — no
155
+ * resolved Agent) gets nothing, preserving the §5 last-row opt-out. A
156
+ * scheduled.task that *resolves to an Agent* (`agentBound`, a user-defined
157
+ * task-output Agent — `report-writer` et al.) is the §5 "Defined-agent
158
+ * execution" row: it gets global + self so feedback on the Agent's output
159
+ * reaches that Agent (requirement #3). The builder supplies the binding fact
160
+ * via `opts.agentBound` so the decision still lives in this one module rather
161
+ * than fragmenting a `resolveAgentId() != null` check into `context-builder.ts`.
162
+ * - Everything else — observers, `fetch_window`, `today_refresh`, and any
163
+ * unlisted key — gets nothing (the §5 "this surface gets almost nothing"
164
+ * row, mirroring `buildFetchWindowContext`).
165
+ */
166
+ export interface AgentLessonsInjection {
167
+ /** Inject the global `policies/agent-lessons.md ## Lessons` block. */
168
+ readonly global: boolean;
169
+ /**
170
+ * Eligible for the per-agent `policies/agents/<slug>/lessons.md` block.
171
+ * Phase 4 consumer — gated additionally on a resolved slug at the build site.
172
+ */
173
+ readonly self: boolean;
174
+ /** Use the slim, hard-2048-byte, top-N-by-score hourly notify variant. */
175
+ readonly slim: boolean;
176
+ }
177
+ /**
178
+ * Resolve which `<agent_lessons>` block(s) a surface receives. See
179
+ * {@link AgentLessonsInjection} for the field/keying rationale.
180
+ *
181
+ * `opts.agentBound` (Phase 4) tells the resolver whether this firing resolved
182
+ * to an Agent (`resolveAgentId() != null`, surfaced by the builder as a stamped
183
+ * `event.data.agentId`). It only changes the binding-aware `scheduled.task`
184
+ * surface — every other key returns the same shape regardless — so a call with
185
+ * no opts is identical to the Phase-3 behaviour.
186
+ */
187
+ export declare function getAgentLessonsInjection(eventOrProcessKey: string, opts?: {
188
+ agentBound?: boolean;
189
+ }): AgentLessonsInjection;
@@ -115,3 +115,61 @@ export function getInjectionPolicy(eventOrProcessKey) {
115
115
  }
116
116
  return DEFAULT_POLICY;
117
117
  }
118
+ /**
119
+ * Pre-allocated frozen shapes — `getAgentLessonsInjection` returns shared
120
+ * instances so equality comparisons are stable and allocation-free, mirroring
121
+ * the `ALL_BLOCKS` / `NO_BLOCKS` pattern above.
122
+ */
123
+ const LESSONS_DM_REVIEW = Object.freeze({
124
+ global: true,
125
+ self: true,
126
+ slim: false,
127
+ });
128
+ const LESSONS_HOURLY = Object.freeze({
129
+ global: true,
130
+ self: false,
131
+ slim: true,
132
+ });
133
+ const LESSONS_NONE = Object.freeze({
134
+ global: false,
135
+ self: false,
136
+ slim: false,
137
+ });
138
+ /**
139
+ * Resolve which `<agent_lessons>` block(s) a surface receives. See
140
+ * {@link AgentLessonsInjection} for the field/keying rationale.
141
+ *
142
+ * `opts.agentBound` (Phase 4) tells the resolver whether this firing resolved
143
+ * to an Agent (`resolveAgentId() != null`, surfaced by the builder as a stamped
144
+ * `event.data.agentId`). It only changes the binding-aware `scheduled.task`
145
+ * surface — every other key returns the same shape regardless — so a call with
146
+ * no opts is identical to the Phase-3 behaviour.
147
+ */
148
+ export function getAgentLessonsInjection(eventOrProcessKey, opts) {
149
+ // DM / dashboard messages — the primary surface lessons calibrate.
150
+ if (eventOrProcessKey.startsWith("message.")) {
151
+ return LESSONS_DM_REVIEW;
152
+ }
153
+ switch (eventOrProcessKey) {
154
+ // Scheduled DM tone session (morning briefing, meeting nudges, …) — same
155
+ // conversational posture as a live DM.
156
+ case "scheduled.dm":
157
+ // Notify-deciding routines: morning Stage A + the review cadences. Each
158
+ // owns a go/no-go `/api/notify` decision that lessons should calibrate.
159
+ case "routine.morning_routine_today":
160
+ case "routine.evening_review":
161
+ case "routine.weekly_review":
162
+ case "routine.monthly_review":
163
+ return LESSONS_DM_REVIEW;
164
+ // Hourly notify turn — slim, hard-capped notification-discipline variant.
165
+ case "routine.hourly_check":
166
+ return LESSONS_HOURLY;
167
+ // Defined-agent task execution (§5 "Defined-agent execution"). A bare
168
+ // scheduled.task stays NONE (the §5 opt-out); one that resolves to an Agent
169
+ // gets global + self so a generated Agent sees feedback on its own output.
170
+ case "scheduled.task":
171
+ return opts?.agentBound ? LESSONS_DM_REVIEW : LESSONS_NONE;
172
+ default:
173
+ return LESSONS_NONE;
174
+ }
175
+ }
@@ -1,3 +1,4 @@
1
+ import type Database from "better-sqlite3";
1
2
  import type { AgentConfig } from "../config.js";
2
3
  /**
3
4
  * Raw signal entry that gets appended to user/profile.md ## Raw Signals section.
@@ -31,6 +32,7 @@ interface RawSignal {
31
32
  */
32
33
  export declare class SignalDetector {
33
34
  private readonly config;
35
+ private readonly deps;
34
36
  /** Track pending notifications for ignore detection */
35
37
  private readonly pendingNotifications;
36
38
  /** Rolling dedup cache: signal key → expiry timestamp */
@@ -41,7 +43,9 @@ export declare class SignalDetector {
41
43
  private ignoreCheckInterval;
42
44
  /** API base URL for Context File API (self-referencing) */
43
45
  private readonly apiBaseUrl;
44
- constructor(config: AgentConfig);
46
+ constructor(config: AgentConfig, deps?: {
47
+ db?: Database.Database;
48
+ });
45
49
  /** Start the signal detector (periodic ignore check) */
46
50
  start(): void;
47
51
  /** Stop the signal detector */
@@ -67,9 +71,33 @@ export declare class SignalDetector {
67
71
  */
68
72
  onUserMessage(params: {
69
73
  platform: string;
74
+ channel?: string;
70
75
  content: string;
71
76
  responseToNotificationId?: string;
72
77
  }): void;
78
+ /**
79
+ * Record a positive action correlated with a notification. This is the
80
+ * narrow "acted" path: callers must have an actual task/action observation,
81
+ * never silence, before invoking it.
82
+ *
83
+ * NOTE — intentional Phase-1.5 seam, no production caller yet (by design).
84
+ * Firing `acted` deterministically needs the notification's subject
85
+ * `(source, ref)` in `pendingNotifications` to match a later observation,
86
+ * but that subject never reaches `trackNotification`: entity-related
87
+ * proactive DMs are re-authored by an agent turn and delivered via
88
+ * `POST /api/agent/notify`, which carries only message/platform/priority
89
+ * (no subject), and the direct `NotificationManager.send()` paths carry
90
+ * `data:{}`. So this stays correct + silence-safe but dormant — do NOT
91
+ * wire it from a silence- or substring-heuristic source (that re-opens the
92
+ * sign-inversion the promotion gate exists to kill). The loop is complete
93
+ * without it (`explicit` + `replied` drive promotion). See
94
+ * FEEDBACK_LEARNING_LOOP_DESIGN.md §11 v1.9#2 + v1.11.
95
+ */
96
+ onNotificationActed(params: {
97
+ notificationId: string;
98
+ actionRef?: string;
99
+ detail?: string;
100
+ }): void;
73
101
  /** Check for messages that have been ignored (no response within threshold) */
74
102
  private checkIgnoredMessages;
75
103
  /**
@@ -92,5 +120,15 @@ export declare class SignalDetector {
92
120
  static normalizeDedupKey(signal: RawSignal): string;
93
121
  /** Remove expired entries from the dedup cache */
94
122
  private pruneExpiredDedup;
123
+ private static isCorrectionContent;
124
+ private findPendingNotificationForReply;
125
+ private resolvePendingNotificationId;
126
+ private lookupNotificationMetadata;
127
+ private resolveAgentIdForNotification;
128
+ private recordNotificationOutcome;
129
+ private updateNotificationReaction;
130
+ private buildOutcomeSummary;
131
+ private sanitizeEvidence;
132
+ private static parseTrackedNotificationId;
95
133
  }
96
134
  export {};
@@ -1,5 +1,7 @@
1
- import { formatSqliteDatetime } from "@aitne/shared";
1
+ import { formatSqliteDatetime, redactSensitiveString } from "@aitne/shared";
2
+ import { hasFeedbackSignalForAction, recordFeedbackSignal, } from "../db/feedback-signals-store.js";
2
3
  import { createLogger } from "../logging.js";
4
+ import { resolveAgentId } from "./agents/agent-id-resolver.js";
3
5
  const logger = createLogger("signal-detector");
4
6
  /**
5
7
  * Maximum number of Raw Signal entries kept in user/profile.md. Prevents
@@ -37,6 +39,7 @@ const DEDUP_TTL_MS = 10 * 60 * 1000;
37
39
  */
38
40
  export class SignalDetector {
39
41
  config;
42
+ deps;
40
43
  /** Track pending notifications for ignore detection */
41
44
  pendingNotifications = new Map();
42
45
  /** Rolling dedup cache: signal key → expiry timestamp */
@@ -47,8 +50,9 @@ export class SignalDetector {
47
50
  ignoreCheckInterval = null;
48
51
  /** API base URL for Context File API (self-referencing) */
49
52
  apiBaseUrl;
50
- constructor(config) {
53
+ constructor(config, deps = {}) {
51
54
  this.config = config;
55
+ this.deps = deps;
52
56
  this.ignoreThresholdMs = 30 * 60 * 1000; // 30 minutes
53
57
  this.apiBaseUrl = `http://localhost:${config.apiPort}`;
54
58
  }
@@ -71,10 +75,17 @@ export class SignalDetector {
71
75
  * Called by NotificationManager after successful delivery.
72
76
  */
73
77
  trackNotification(notificationId, platform, content) {
78
+ const metadata = this.lookupNotificationMetadata(notificationId);
74
79
  this.pendingNotifications.set(notificationId, {
75
80
  sentAt: Date.now(),
76
- platform,
77
- content: content.slice(0, 100), // Truncate for signal log
81
+ // Single fallback to the delivered platform here, not redundantly inside
82
+ // the lookup the tracked id's suffix is the delivery platform anyway.
83
+ platform: metadata.platform ?? platform,
84
+ channel: metadata.channel,
85
+ content: (metadata.contentSummary ?? content).slice(0, 100), // Truncate for signal log
86
+ dispatchId: metadata.dispatchId,
87
+ notificationType: metadata.notificationType,
88
+ agentId: metadata.agentId,
78
89
  });
79
90
  }
80
91
  /**
@@ -85,12 +96,24 @@ export class SignalDetector {
85
96
  const { platform, emoji, responseTimeMs } = params;
86
97
  // Remove from pending (user responded)
87
98
  if (params.notificationId) {
88
- this.pendingNotifications.delete(params.notificationId);
99
+ const pendingId = this.resolvePendingNotificationId(params.notificationId);
100
+ this.pendingNotifications.delete(pendingId);
101
+ this.recordNotificationOutcome({
102
+ notificationId: pendingId,
103
+ reaction: "replied",
104
+ valence: "positive",
105
+ evidence: {
106
+ emoji,
107
+ responseTimeMs,
108
+ weight: 0.5,
109
+ },
110
+ });
89
111
  }
90
112
  const signal = {
91
113
  timestamp: formatSqliteDatetime(new Date()),
92
114
  type: "reaction",
93
- detail: `${emoji} on ${platform}${responseTimeMs ? ` (${Math.round(responseTimeMs / 1000)}s)` : ""}`,
115
+ detail: `${emoji} on ${platform}`
116
+ + `${responseTimeMs ? ` (${Math.round(responseTimeMs / 1000)}s)` : ""}`,
94
117
  };
95
118
  void this.appendSignal(signal);
96
119
  }
@@ -100,29 +123,68 @@ export class SignalDetector {
100
123
  */
101
124
  onUserMessage(params) {
102
125
  const { content, responseToNotificationId } = params;
126
+ const pendingNotificationId = responseToNotificationId
127
+ ? this.resolvePendingNotificationId(responseToNotificationId)
128
+ : this.findPendingNotificationForReply(params.platform, params.channel);
103
129
  // Remove from pending (user responded)
104
- if (responseToNotificationId) {
105
- this.pendingNotifications.delete(responseToNotificationId);
130
+ if (pendingNotificationId) {
131
+ this.pendingNotifications.delete(pendingNotificationId);
106
132
  }
107
133
  // Detect correction instructions
108
- const correctionPatterns = [
109
- /shorter|brief|concise/i,
110
- /more detail|elaborate|expand/i,
111
- /\bin (english|spanish|french|german|portuguese|italian|chinese|japanese|korean|arabic|hindi|russian)\b/i,
112
- /bullet points|bulleted list/i,
113
- ];
114
- for (const pattern of correctionPatterns) {
115
- if (pattern.test(content)) {
116
- const signal = {
117
- timestamp: formatSqliteDatetime(new Date()),
118
- type: "correction",
119
- detail: `"${content.slice(0, 60)}"`,
120
- };
121
- void this.appendSignal(signal);
122
- return; // Only log the first matching correction
123
- }
134
+ const isCorrection = SignalDetector.isCorrectionContent(content);
135
+ if (pendingNotificationId) {
136
+ this.recordNotificationOutcome({
137
+ notificationId: pendingNotificationId,
138
+ reaction: isCorrection ? "corrected" : "replied",
139
+ valence: isCorrection ? "correction" : "positive",
140
+ evidence: {
141
+ excerpt: content.slice(0, 160),
142
+ weight: isCorrection ? 1.0 : 0.5,
143
+ },
144
+ });
145
+ }
146
+ if (isCorrection) {
147
+ const signal = {
148
+ timestamp: formatSqliteDatetime(new Date()),
149
+ type: "correction",
150
+ detail: `"${content.slice(0, 60)}"`,
151
+ };
152
+ void this.appendSignal(signal);
153
+ return; // Only log the first matching correction
124
154
  }
125
155
  }
156
+ /**
157
+ * Record a positive action correlated with a notification. This is the
158
+ * narrow "acted" path: callers must have an actual task/action observation,
159
+ * never silence, before invoking it.
160
+ *
161
+ * NOTE — intentional Phase-1.5 seam, no production caller yet (by design).
162
+ * Firing `acted` deterministically needs the notification's subject
163
+ * `(source, ref)` in `pendingNotifications` to match a later observation,
164
+ * but that subject never reaches `trackNotification`: entity-related
165
+ * proactive DMs are re-authored by an agent turn and delivered via
166
+ * `POST /api/agent/notify`, which carries only message/platform/priority
167
+ * (no subject), and the direct `NotificationManager.send()` paths carry
168
+ * `data:{}`. So this stays correct + silence-safe but dormant — do NOT
169
+ * wire it from a silence- or substring-heuristic source (that re-opens the
170
+ * sign-inversion the promotion gate exists to kill). The loop is complete
171
+ * without it (`explicit` + `replied` drive promotion). See
172
+ * FEEDBACK_LEARNING_LOOP_DESIGN.md §11 v1.9#2 + v1.11.
173
+ */
174
+ onNotificationActed(params) {
175
+ const pendingId = this.resolvePendingNotificationId(params.notificationId);
176
+ this.pendingNotifications.delete(pendingId);
177
+ this.recordNotificationOutcome({
178
+ notificationId: pendingId,
179
+ reaction: "acted",
180
+ valence: "positive",
181
+ evidence: {
182
+ actionRef: params.actionRef,
183
+ detail: params.detail,
184
+ weight: 0.5,
185
+ },
186
+ });
187
+ }
126
188
  /** Check for messages that have been ignored (no response within threshold) */
127
189
  checkIgnoredMessages() {
128
190
  const now = Date.now();
@@ -136,6 +198,16 @@ export class SignalDetector {
136
198
  detail: `${info.platform}: "${info.content}" unread for ${Math.round(elapsed / 60000)}min`,
137
199
  };
138
200
  void this.appendSignal(signal);
201
+ this.recordNotificationOutcome({
202
+ notificationId: id,
203
+ reaction: "ignored",
204
+ valence: "neutral",
205
+ evidence: {
206
+ elapsedMs: elapsed,
207
+ weight: 0.25,
208
+ initiatesLesson: false,
209
+ },
210
+ });
139
211
  toRemove.push(id);
140
212
  }
141
213
  }
@@ -211,4 +283,185 @@ export class SignalDetector {
211
283
  }
212
284
  }
213
285
  }
286
+ static isCorrectionContent(content) {
287
+ const correctionPatterns = [
288
+ /shorter|brief|concise/i,
289
+ /more detail|elaborate|expand/i,
290
+ /\bin (english|spanish|french|german|portuguese|italian|chinese|japanese|korean|arabic|hindi|russian)\b/i,
291
+ /bullet points|bulleted list/i,
292
+ /\b(stop|no)\b.*\b(notify|message|remind|send|doing|do|again|that)\b/i,
293
+ /\b(do not|don't)\b/i,
294
+ ];
295
+ return correctionPatterns.some((pattern) => pattern.test(content));
296
+ }
297
+ findPendingNotificationForReply(platform, channel) {
298
+ const now = Date.now();
299
+ let candidate = null;
300
+ for (const [id, info] of this.pendingNotifications) {
301
+ if (info.platform !== platform)
302
+ continue;
303
+ if (channel && info.channel && info.channel !== channel)
304
+ continue;
305
+ if (now - info.sentAt > this.ignoreThresholdMs)
306
+ continue;
307
+ if (candidate === null || info.sentAt > candidate.sentAt) {
308
+ candidate = { id, sentAt: info.sentAt };
309
+ }
310
+ }
311
+ return candidate?.id ?? null;
312
+ }
313
+ resolvePendingNotificationId(notificationId) {
314
+ if (this.pendingNotifications.has(notificationId))
315
+ return notificationId;
316
+ for (const id of this.pendingNotifications.keys()) {
317
+ if (id.startsWith(`${notificationId}:`))
318
+ return id;
319
+ }
320
+ return notificationId;
321
+ }
322
+ lookupNotificationMetadata(notificationId) {
323
+ const parsed = SignalDetector.parseTrackedNotificationId(notificationId);
324
+ if (!this.deps.db || !parsed.dispatchId) {
325
+ return {
326
+ dispatchId: parsed.dispatchId,
327
+ platform: parsed.platform,
328
+ channel: null,
329
+ notificationType: null,
330
+ contentSummary: null,
331
+ agentId: null,
332
+ rowId: null,
333
+ };
334
+ }
335
+ const platformValue = parsed.platform;
336
+ const platformPredicate = platformValue ? "AND platform = ?" : "";
337
+ const values = platformValue
338
+ ? [parsed.dispatchId, platformValue]
339
+ : [parsed.dispatchId];
340
+ const row = this.deps.db
341
+ .prepare(`SELECT id, dispatch_id, platform, delivery_channel, notification_type, content_summary
342
+ FROM notification_log
343
+ WHERE dispatch_id = ? ${platformPredicate}
344
+ ORDER BY id DESC
345
+ LIMIT 1`)
346
+ .get(...values);
347
+ const notificationType = row?.notification_type ?? null;
348
+ return {
349
+ dispatchId: row?.dispatch_id ?? parsed.dispatchId,
350
+ platform: row?.platform ?? parsed.platform,
351
+ channel: row?.delivery_channel ?? null,
352
+ notificationType,
353
+ contentSummary: row?.content_summary ?? null,
354
+ agentId: this.resolveAgentIdForNotification(notificationType),
355
+ rowId: row?.id ?? null,
356
+ };
357
+ }
358
+ resolveAgentIdForNotification(notificationType) {
359
+ if (!this.deps.db || !notificationType?.startsWith("routine."))
360
+ return null;
361
+ // Runs only after a successful notification_log read on the same db, so a
362
+ // connection failure would already have surfaced upstream; the registry
363
+ // lookup + agents existence check are deterministic over a valid handle.
364
+ return resolveAgentId(this.deps.db, {
365
+ routine: notificationType.slice("routine.".length),
366
+ });
367
+ }
368
+ recordNotificationOutcome(params) {
369
+ const db = this.deps.db;
370
+ if (this.config.feedbackLearningEnabled === false || !db)
371
+ return;
372
+ // One guard for the whole behavioral-capture path: the reaction backfill,
373
+ // dedup probe, and signal insert all touch the same connection, so a single
374
+ // catch keeps a DB hiccup from crashing the background detector loop
375
+ // without leaving the reaction column write unguarded.
376
+ try {
377
+ const metadata = this.lookupNotificationMetadata(params.notificationId);
378
+ const dispatchId = metadata.dispatchId;
379
+ if (!dispatchId)
380
+ return;
381
+ this.updateNotificationReaction(db, dispatchId, metadata.platform, params.reaction);
382
+ if (hasFeedbackSignalForAction(db, {
383
+ source: "behavioral",
384
+ actionKind: "notification",
385
+ actionRef: dispatchId,
386
+ valence: params.valence,
387
+ userReaction: params.reaction,
388
+ })) {
389
+ return;
390
+ }
391
+ const scopeType = metadata.agentId ? "agent_slug" : "agent";
392
+ const summary = this.buildOutcomeSummary(params.reaction, metadata);
393
+ const evidence = this.sanitizeEvidence({
394
+ ...params.evidence,
395
+ userReaction: params.reaction,
396
+ notificationLogId: metadata.rowId,
397
+ notificationType: metadata.notificationType,
398
+ platform: metadata.platform,
399
+ contentSummary: metadata.contentSummary,
400
+ });
401
+ recordFeedbackSignal(db, {
402
+ source: "behavioral",
403
+ valence: params.valence,
404
+ scopeType,
405
+ scopeRef: metadata.agentId,
406
+ actionKind: "notification",
407
+ actionRef: dispatchId,
408
+ agentId: metadata.agentId,
409
+ summary,
410
+ evidence,
411
+ });
412
+ }
413
+ catch (err) {
414
+ logger.warn({ err, notificationId: params.notificationId, reaction: params.reaction }, "Failed to record notification outcome");
415
+ }
416
+ }
417
+ updateNotificationReaction(db, dispatchId, platform, reaction) {
418
+ if (platform) {
419
+ db.prepare(`UPDATE notification_log
420
+ SET user_reaction = ?, reacted_at = CURRENT_TIMESTAMP
421
+ WHERE dispatch_id = ? AND platform = ?`).run(reaction, dispatchId, platform);
422
+ return;
423
+ }
424
+ db.prepare(`UPDATE notification_log
425
+ SET user_reaction = ?, reacted_at = CURRENT_TIMESTAMP
426
+ WHERE dispatch_id = ?`).run(reaction, dispatchId);
427
+ }
428
+ buildOutcomeSummary(reaction, metadata) {
429
+ const content = metadata.contentSummary
430
+ ? ` "${metadata.contentSummary}"`
431
+ : "";
432
+ const notificationType = metadata.notificationType
433
+ ? ` (${metadata.notificationType})`
434
+ : "";
435
+ const raw = reaction === "ignored"
436
+ ? `Owner did not respond to notification${content}${notificationType}`
437
+ : reaction === "corrected"
438
+ ? `Owner corrected notification${content}${notificationType}`
439
+ : reaction === "acted"
440
+ ? `Owner acted on notification${content}${notificationType}`
441
+ : `Owner responded to notification${content}${notificationType}`;
442
+ return redactSensitiveString(raw.replace(/\s+/g, " ").trim()).slice(0, 280);
443
+ }
444
+ sanitizeEvidence(value) {
445
+ const out = {};
446
+ for (const [key, entry] of Object.entries(value)) {
447
+ if (entry === undefined)
448
+ continue;
449
+ if (typeof entry === "string") {
450
+ out[key] = redactSensitiveString(entry.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 500));
451
+ }
452
+ else {
453
+ out[key] = entry;
454
+ }
455
+ }
456
+ return out;
457
+ }
458
+ static parseTrackedNotificationId(notificationId) {
459
+ const idx = notificationId.indexOf(":");
460
+ if (idx <= 0)
461
+ return { dispatchId: notificationId || null, platform: null };
462
+ return {
463
+ dispatchId: notificationId.slice(0, idx),
464
+ platform: notificationId.slice(idx + 1) || null,
465
+ };
466
+ }
214
467
  }