@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.
- package/dist/api/env-writer.d.ts +1 -0
- package/dist/api/env-writer.js +9 -2
- package/dist/api/routes/agent-schedule.js +5 -1
- package/dist/api/routes/apple-calendar.js +4 -1
- package/dist/api/routes/calendar.js +12 -2
- package/dist/api/routes/context/path-resolve.js +6 -1
- package/dist/api/routes/context/permissions.js +9 -0
- package/dist/api/routes/dashboard/config.js +10 -0
- package/dist/api/routes/dashboard/oauth-google.js +5 -3
- package/dist/api/routes/feedback.d.ts +3 -0
- package/dist/api/routes/feedback.js +349 -0
- package/dist/api/routes/git.js +10 -3
- package/dist/api/routes/github.js +5 -1
- package/dist/api/routes/mcp.js +65 -13
- package/dist/api/server.js +3 -0
- package/dist/bootstrap/event-pipeline.js +1 -1
- package/dist/config.js +6 -0
- package/dist/core/backends/gemini-cli-core.js +13 -0
- package/dist/core/backends/plan-presets.js +8 -3
- package/dist/core/context-builder.js +149 -3
- package/dist/core/context-paths.d.ts +10 -0
- package/dist/core/context-paths.js +16 -0
- package/dist/core/daemon-api-cli.js +1 -1
- package/dist/core/dispatcher-message-handler.js +7 -0
- package/dist/core/dispatcher-scheduled-tasks.d.ts +41 -0
- package/dist/core/dispatcher-scheduled-tasks.js +267 -2
- package/dist/core/dispatcher.js +13 -1
- package/dist/core/feedback/consolidation-prep.d.ts +94 -0
- package/dist/core/feedback/consolidation-prep.js +242 -0
- package/dist/core/feedback/eviction-scorer.d.ts +81 -0
- package/dist/core/feedback/eviction-scorer.js +132 -0
- package/dist/core/feedback/lesson-format.d.ts +79 -0
- package/dist/core/feedback/lesson-format.js +194 -0
- package/dist/core/feedback/lesson-injection.d.ts +98 -0
- package/dist/core/feedback/lesson-injection.js +159 -0
- package/dist/core/feedback/lesson-merge.d.ts +51 -0
- package/dist/core/feedback/lesson-merge.js +88 -0
- package/dist/core/feedback/lesson-store-overview.d.ts +42 -0
- package/dist/core/feedback/lesson-store-overview.js +38 -0
- package/dist/core/feedback/promotion-gate.d.ts +69 -0
- package/dist/core/feedback/promotion-gate.js +117 -0
- package/dist/core/feedback/regeneralization-prep.d.ts +87 -0
- package/dist/core/feedback/regeneralization-prep.js +139 -0
- package/dist/core/feedback/scope-parser.d.ts +86 -0
- package/dist/core/feedback/scope-parser.js +141 -0
- package/dist/core/injection-policy.d.ts +82 -0
- package/dist/core/injection-policy.js +58 -0
- package/dist/core/signal-detector.d.ts +39 -1
- package/dist/core/signal-detector.js +277 -24
- package/dist/core/today-direct-writer.d.ts +59 -13
- package/dist/core/today-direct-writer.js +90 -13
- package/dist/core/wiki/wiki-fts.js +13 -6
- package/dist/db/feedback-signals-store.d.ts +77 -0
- package/dist/db/feedback-signals-store.js +144 -0
- package/dist/db/migrations.js +50 -0
- package/dist/db/schema.js +43 -6
- package/dist/safety/always-disallowed.d.ts +1 -1
- package/dist/safety/always-disallowed.js +39 -0
- package/dist/safety/risk-classifier.js +22 -7
- package/dist/services/browser-history/automation/egress-denylist.js +18 -2
- package/dist/services/browser-history/lifecycle/platform.js +44 -2
- package/dist/services/mcp/probe.js +30 -8
- package/dist/settings/runtime-settings.d.ts +8 -2
- package/dist/settings/runtime-settings.js +12 -0
- 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
|
-
|
|
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.
|
|
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}
|
|
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 (
|
|
105
|
-
this.pendingNotifications.delete(
|
|
130
|
+
if (pendingNotificationId) {
|
|
131
|
+
this.pendingNotifications.delete(pendingNotificationId);
|
|
106
132
|
}
|
|
107
133
|
// Detect correction instructions
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
}
|