@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
@@ -2,6 +2,7 @@ import type Database from "better-sqlite3";
2
2
  import { type AgentConfig } from "../config.js";
3
3
  import type { SettingsStore } from "../settings/settings-store.js";
4
4
  export declare function ensureEnvFilePermissions(envPath: string): void;
5
+ export declare function serializeForEnv(value: unknown): string;
5
6
  export declare function getEnvFilePath(): string;
6
7
  export interface ConfigUpdateResult {
7
8
  updated: string[];
@@ -199,11 +199,18 @@ function expandHomePreservingRelative(p) {
199
199
  }
200
200
  return p;
201
201
  }
202
- function serializeForEnv(value) {
202
+ export function serializeForEnv(value) {
203
203
  if (Array.isArray(value) || (typeof value === "object" && value !== null)) {
204
+ // JSON.stringify already escapes \r / \n inside strings, so the
205
+ // serialized form is single-line and safe to write as one env value.
204
206
  return JSON.stringify(value);
205
207
  }
206
- return String(value ?? "");
208
+ // Strip CR/LF from scalar values before they reach `updateEnvFile`. A raw
209
+ // newline in a string config value (e.g. a path or display name) would be
210
+ // written verbatim as `KEY=foo\nBAR=...`, and the injected `BAR=...` line
211
+ // would be parsed by dotenv as a separate variable on next load — silently
212
+ // corrupting `.env`. No legitimate bootstrap value contains a newline.
213
+ return String(value ?? "").replace(/[\r\n]+/g, " ");
207
214
  }
208
215
  export function getEnvFilePath() {
209
216
  return resolve(process.cwd(), ".env");
@@ -678,7 +678,11 @@ export function registerAgentScheduleRoutes(app, deps) {
678
678
  if (isNaN(parsedDate.getTime())) {
679
679
  return respondWithAgentError(c, 400, [composeIssue("agent.invalid_time", { field: "time", received: parsed.data.time })], { legacyFields: { details: "Cannot parse time as a valid date" } });
680
680
  }
681
- // Reject past times for dm type (consistent with POST /schedule/dm)
681
+ // Reject past times for dm type (consistent with POST /schedule/dm).
682
+ // Non-dm rows (wake/check) intentionally allow a past `time` on PATCH so
683
+ // a schedule can be rescheduled for immediate catch-up execution — see
684
+ // the "no past-time rejection" test in agent.test.ts. A late DM, by
685
+ // contrast, is pointless, so only dm rows are rejected here.
682
686
  if (row.task_type === "dm" && parsedDate.getTime() < Date.now() - 60_000) {
683
687
  return respondWithAgentError(c, 400, [composeIssue("agent.invalid_time", { field: "time", received: parsed.data.time })], { legacyFields: { details: "Scheduled time is in the past" } });
684
688
  }
@@ -214,7 +214,10 @@ export function createAppleCalendarRoutes(deps) {
214
214
  let date = c.req.query("date") ?? localDateStr(new Date());
215
215
  if (date === "today")
216
216
  date = localDateStr(new Date());
217
- const days = Math.min(Number(c.req.query("days") ?? "1"), 90);
217
+ // Guard non-finite `days` (e.g. `?days=abc` NaN) so it can't propagate
218
+ // into `new Date(startMs + NaN).toISOString()` (RangeError → opaque 500).
219
+ const daysRaw = Number(c.req.query("days") ?? "1");
220
+ const days = Number.isFinite(daysRaw) ? Math.min(Math.max(daysRaw, 1), 90) : 1;
218
221
  const startMs = new Date(`${date}T00:00:00Z`).getTime();
219
222
  if (Number.isNaN(startMs)) {
220
223
  return respondWithAgentError(c, 400, [composeIssue("apple_calendar.invalid_date", { field: "date", received: date })], {
@@ -215,7 +215,12 @@ export function createCalendarRoutes(deps) {
215
215
  if (date === "today") {
216
216
  date = localDateStr(new Date());
217
217
  }
218
- const days = Math.min(Number(c.req.query("days") ?? "1"), 90);
218
+ // Guard non-finite `days` (e.g. `?days=abc` NaN) — without it the
219
+ // NaN propagates into `new Date(startMs + NaN).toISOString()`, which
220
+ // throws RangeError and surfaces as an opaque 500 instead of a sane
221
+ // bounded window. Clamp finite values to [1, 90]; fall back to 1.
222
+ const daysRaw = Number(c.req.query("days") ?? "1");
223
+ const days = Number.isFinite(daysRaw) ? Math.min(Math.max(daysRaw, 1), 90) : 1;
219
224
  // Append Z to ensure UTC parsing regardless of OS timezone
220
225
  const startMs = new Date(`${date}T00:00:00Z`).getTime();
221
226
  if (Number.isNaN(startMs)) {
@@ -594,7 +599,12 @@ export function createCalendarRoutes(deps) {
594
599
  if (date === "today") {
595
600
  date = localDateStr(new Date());
596
601
  }
597
- const days = Math.min(Number(c.req.query("days") ?? "1"), 90);
602
+ // Guard non-finite `days` (e.g. `?days=abc` NaN) — without it the
603
+ // NaN propagates into `new Date(startMs + NaN).toISOString()`, which
604
+ // throws RangeError and surfaces as an opaque 500 instead of a sane
605
+ // bounded window. Clamp finite values to [1, 90]; fall back to 1.
606
+ const daysRaw = Number(c.req.query("days") ?? "1");
607
+ const days = Number.isFinite(daysRaw) ? Math.min(Math.max(daysRaw, 1), 90) : 1;
598
608
  const startMs = new Date(`${date}T00:00:00Z`).getTime();
599
609
  if (Number.isNaN(startMs)) {
600
610
  return respondWithAgentError(c, 400, [
@@ -50,8 +50,13 @@ export function normalizeContextPath(userPath) {
50
50
  return resolveContextTarget(userPath).base;
51
51
  }
52
52
  export function isDeniedPath(relativePath) {
53
+ // path.relative emits OS-native separators (backslash on Windows); the
54
+ // DENIED_SUBPATH_ROOTS prefixes are forward-slash. Normalize before the
55
+ // compare so nested `.git\config`, `.obsidian\workspace.json`, etc. are
56
+ // caught on Windows. POSIX paths contain no backslashes -> no-op there.
57
+ const normalized = relativePath.replace(/\\/g, "/");
53
58
  for (const root of DENIED_SUBPATH_ROOTS) {
54
- if (relativePath === root || relativePath.startsWith(`${root}/`)) {
59
+ if (normalized === root || normalized.startsWith(`${root}/`)) {
55
60
  return true;
56
61
  }
57
62
  }
@@ -34,6 +34,15 @@ export const CONTEXT_WRITE_PERMISSIONS = {
34
34
  // the captured history (origin DM, why, linked routine) survives.
35
35
  "policies/_index": ["PUT", "PATCH"],
36
36
  "policies/management": ["PUT", "PATCH"],
37
+ // Feedback Learning Loop (FEEDBACK_LEARNING_LOOP_DESIGN.md §4 Phase 2).
38
+ // Global agent-scope lessons folded nightly by routine.evening_review.
39
+ // Without this row the consolidation PATCH/PUT 403s — the per-agent
40
+ // scope already rides the `policies/agents/{slug}/{file}` wildcard, but
41
+ // the global file matches no rule (no blanket `policies/*`). Like every
42
+ // other `policies/` write it trips `shouldRefreshPromptContext`, so a
43
+ // nightly lesson write invalidates the owner-session prompt cache —
44
+ // desirable (new lessons take effect next turn) and it fires at night.
45
+ "policies/agent-lessons": ["PUT", "PATCH"],
37
46
  "policies/mcp": ["PUT", "PATCH"],
38
47
  "policies/redaction": ["PUT", "PATCH"],
39
48
  "policies/journal-format": ["PUT", "PATCH"],
@@ -94,6 +94,16 @@ const PUBLIC_CONFIG_RUNTIME_KEYS = [
94
94
  "delegatedProbeIntervalMinutes",
95
95
  "autonomousDailyCostCapUsd",
96
96
  "autonomousMonthlyCostCapUsd",
97
+ // Feedback Learning Loop (FEEDBACK_LEARNING_LOOP_DESIGN.md §9 Phase 5) —
98
+ // surfaced so the Lessons settings page can read + tune the master toggle,
99
+ // promotion threshold, per-scope byte caps, staleness horizon, and signal
100
+ // retention via the deferred-save EditableField flow.
101
+ "feedbackLearningEnabled",
102
+ "feedbackPromotionThreshold",
103
+ "feedbackLessonMaxBytesGlobal",
104
+ "feedbackLessonMaxBytesPerAgent",
105
+ "feedbackLessonStaleDays",
106
+ "feedbackSignalRetentionDays",
97
107
  "primaryLanguage",
98
108
  "vaultMode",
99
109
  ];
@@ -168,8 +168,9 @@ export function registerOauthGoogleRoutes(app, deps) {
168
168
  catch {
169
169
  return c.json({ error: "googleapis package not installed" }, 500);
170
170
  }
171
- // Use daemon's own port for the OAuth callback
172
- const redirectUri = `http://localhost:${config.apiPort}/api/config/google-auth/callback`;
171
+ // Use daemon's own port for the OAuth callback.
172
+ // Use 127.0.0.1, not localhost: daemon binds IPv4 loopback only; on Windows localhost resolves to ::1 first and the callback would ECONNREFUSED. Google special-cases loopback redirects for installed-app clients.
173
+ const redirectUri = `http://127.0.0.1:${config.apiPort}/api/config/google-auth/callback`;
173
174
  const oauth2Client = new google.auth.OAuth2(clientConfig.client_id, clientConfig.client_secret, redirectUri);
174
175
  // Request scopes for Calendar + Gmail
175
176
  const scopes = [
@@ -255,7 +256,8 @@ export function registerOauthGoogleRoutes(app, deps) {
255
256
  throw new Error("Invalid credentials");
256
257
  const mod = await import("googleapis");
257
258
  const google = mod.google;
258
- const redirectUri = `http://localhost:${config.apiPort}/api/config/google-auth/callback`;
259
+ // Use 127.0.0.1, not localhost: must byte-match the auth-start redirect_uri (Google re-validates an exact string at getToken); daemon binds IPv4 loopback only, and on Windows localhost resolves to ::1 first.
260
+ const redirectUri = `http://127.0.0.1:${config.apiPort}/api/config/google-auth/callback`;
259
261
  const oauth2Client = new google.auth.OAuth2(clientConfig.client_id, clientConfig.client_secret, redirectUri);
260
262
  // Exchange authorization code for tokens
261
263
  const { tokens } = await oauth2Client.getToken(code);
@@ -0,0 +1,3 @@
1
+ import { Hono } from "hono";
2
+ import type { ApiDependencies } from "../server.js";
3
+ export declare function createFeedbackRoutes(deps: ApiDependencies): Hono;
@@ -0,0 +1,349 @@
1
+ import { Hono } from "hono";
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { redactSensitiveString } from "@aitne/shared";
5
+ import { readJsonBody } from "../json-body.js";
6
+ import { getAgent } from "../../db/agents-store.js";
7
+ import { consumeFeedbackSignals, countPendingFeedbackSignals, findRecentFeedbackSignal, recordFeedbackSignal, } from "../../db/feedback-signals-store.js";
8
+ import { getContextDir } from "../../config.js";
9
+ import { CONTEXT_RELATIVE_PATHS, agentLessonsPath, } from "../../core/context-paths.js";
10
+ import { GLOBAL_LESSON_ENTRY_CAP, PER_AGENT_LESSON_ENTRY_CAP, } from "../../core/feedback/consolidation-prep.js";
11
+ import { summarizeLessonStore } from "../../core/feedback/lesson-store-overview.js";
12
+ import { isSafeAgentSlug } from "../../core/feedback/scope-parser.js";
13
+ import { createLogger } from "../../logging.js";
14
+ const logger = createLogger("feedback-api");
15
+ const DEDUP_TTL_SECONDS = 10 * 60;
16
+ const MAX_SUMMARY_CHARS = 280;
17
+ const MAX_SCOPE_REF_CHARS = 120;
18
+ const MAX_ACTION_REF_CHARS = 160;
19
+ const MAX_EVIDENCE_STRING_CHARS = 500;
20
+ const ALLOWED_SOURCES = new Set([
21
+ "explicit",
22
+ "self_critique",
23
+ ]);
24
+ const ALL_SOURCES = new Set([
25
+ "behavioral",
26
+ "explicit",
27
+ "self_critique",
28
+ ]);
29
+ const VALENCES = new Set([
30
+ "positive",
31
+ "negative",
32
+ "neutral",
33
+ "correction",
34
+ ]);
35
+ const API_SCOPE_TYPES = new Set([
36
+ "user",
37
+ "agent",
38
+ "agent_slug",
39
+ ]);
40
+ const ACTION_KINDS = new Set([
41
+ "notification",
42
+ "agent_execution",
43
+ "vault_write",
44
+ "dm_reply",
45
+ ]);
46
+ const KINDS = new Set([
47
+ "preference",
48
+ "correction",
49
+ "do-more",
50
+ "do-less",
51
+ "constraint",
52
+ ]);
53
+ function isRecord(value) {
54
+ return value !== null && typeof value === "object" && !Array.isArray(value);
55
+ }
56
+ function truncate(value, maxChars) {
57
+ return value.length <= maxChars ? value : value.slice(0, maxChars);
58
+ }
59
+ function sanitizeString(value, maxChars = MAX_EVIDENCE_STRING_CHARS) {
60
+ return redactSensitiveString(truncate(value.replace(/[\u0000-\u001f\u007f]/g, " "), maxChars)).trim();
61
+ }
62
+ function sanitizeEvidence(value, depth = 0) {
63
+ if (depth > 4)
64
+ return "[truncated]";
65
+ if (typeof value === "string")
66
+ return sanitizeString(value);
67
+ if (typeof value === "number" || typeof value === "boolean" || value === null)
68
+ return value;
69
+ if (Array.isArray(value)) {
70
+ return value.slice(0, 20).map((entry) => sanitizeEvidence(entry, depth + 1));
71
+ }
72
+ if (!isRecord(value))
73
+ return null;
74
+ const out = {};
75
+ for (const [key, entry] of Object.entries(value).slice(0, 50)) {
76
+ out[sanitizeString(key, 80)] = sanitizeEvidence(entry, depth + 1);
77
+ }
78
+ return out;
79
+ }
80
+ function describeType(value) {
81
+ if (value === undefined)
82
+ return "missing";
83
+ if (value === null)
84
+ return "null";
85
+ if (Array.isArray(value))
86
+ return "array";
87
+ return typeof value;
88
+ }
89
+ function buildLessonStoreEntry(contextDir, scope, relPath, caps) {
90
+ const full = join(contextDir, relPath);
91
+ if (!existsSync(full)) {
92
+ return {
93
+ scope,
94
+ path: relPath,
95
+ exists: false,
96
+ lastModified: null,
97
+ bytes: 0,
98
+ capBytes: caps.capBytes,
99
+ entries: 0,
100
+ maxEntries: caps.maxEntries,
101
+ active: 0,
102
+ provisional: 0,
103
+ overCap: false,
104
+ };
105
+ }
106
+ const summary = summarizeLessonStore(readFileSync(full, "utf-8"), caps);
107
+ return {
108
+ scope,
109
+ path: relPath,
110
+ exists: true,
111
+ lastModified: statSync(full).mtime.toISOString(),
112
+ ...summary,
113
+ };
114
+ }
115
+ export function createFeedbackRoutes(deps) {
116
+ const app = new Hono();
117
+ const { db, config } = deps;
118
+ /**
119
+ * GET /feedback/lessons — read-only overview of the consolidated lesson
120
+ * stores for the dashboard "view/edit lessons and tune caps/threshold"
121
+ * surface (FEEDBACK_LEARNING_LOOP_DESIGN.md §9 Phase 5). Lists the global
122
+ * `agent` store (always, so its cap shows even before first write) plus every
123
+ * per-agent `agent:<slug>` store that exists on disk, each with cap-utilisation
124
+ * metrics. The file bodies are read/edited through the existing
125
+ * `GET/PUT /api/context/<path>` chokepoint — this endpoint only enumerates +
126
+ * summarises. `RiskTier.Autonomous` (read-only, no secrets — lesson prose was
127
+ * redaction-scrubbed at capture).
128
+ */
129
+ app.get("/feedback/lessons", (c) => {
130
+ const contextDir = getContextDir(config, db);
131
+ const globalCaps = {
132
+ capBytes: config.feedbackLessonMaxBytesGlobal,
133
+ maxEntries: GLOBAL_LESSON_ENTRY_CAP,
134
+ };
135
+ const perAgentCaps = {
136
+ capBytes: config.feedbackLessonMaxBytesPerAgent,
137
+ maxEntries: PER_AGENT_LESSON_ENTRY_CAP,
138
+ };
139
+ const stores = [
140
+ buildLessonStoreEntry(contextDir, "agent", CONTEXT_RELATIVE_PATHS.agentLessons, globalCaps),
141
+ ];
142
+ const agentsDir = join(contextDir, "policies", "agents");
143
+ if (existsSync(agentsDir)) {
144
+ const slugs = readdirSync(agentsDir, { withFileTypes: true })
145
+ .filter((entry) => entry.isDirectory() && isSafeAgentSlug(entry.name))
146
+ .map((entry) => entry.name)
147
+ .sort();
148
+ for (const slug of slugs) {
149
+ const rel = agentLessonsPath(slug);
150
+ if (!existsSync(join(contextDir, rel)))
151
+ continue;
152
+ stores.push(buildLessonStoreEntry(contextDir, `agent:${slug}`, rel, perAgentCaps));
153
+ }
154
+ }
155
+ return c.json({
156
+ enabled: config.feedbackLearningEnabled !== false,
157
+ promotionThreshold: config.feedbackPromotionThreshold,
158
+ pendingSignals: countPendingFeedbackSignals(db),
159
+ stores,
160
+ });
161
+ });
162
+ app.post("/feedback", async (c) => {
163
+ // Master kill-switch (FEEDBACK_LEARNING_LOOP_DESIGN.md §7). When the loop is
164
+ // disabled the daemon-side behavioral sink already short-circuits
165
+ // (`SignalDetector`); mirror that here so explicit / self_critique captures
166
+ // from the always-included DM + review task-flows are dropped too. Otherwise
167
+ // unconsumed rows would accumulate unbounded — the nightly consolidation that
168
+ // would consume them is gated off, and the retention sweep only deletes
169
+ // already-consumed rows. Returns 200 so the calling turn neither errors nor
170
+ // retries. `config` is optional in some test harnesses → default-on.
171
+ if (config?.feedbackLearningEnabled === false) {
172
+ return c.json({ disabled: true });
173
+ }
174
+ const parsedBody = await readJsonBody(c);
175
+ if (!parsedBody.ok)
176
+ return parsedBody.response;
177
+ const body = parsedBody.body;
178
+ if (!isRecord(body)) {
179
+ return c.json({
180
+ error: "validation_error",
181
+ message: "Body must be a JSON object",
182
+ }, 400);
183
+ }
184
+ const issues = [];
185
+ const rawSource = body.source;
186
+ const source = typeof rawSource === "string" ? rawSource : "";
187
+ if (!ALL_SOURCES.has(source)) {
188
+ issues.push({
189
+ field: "source",
190
+ expected: "'explicit' | 'self_critique'",
191
+ got: describeType(rawSource),
192
+ });
193
+ }
194
+ else if (!ALLOWED_SOURCES.has(source)) {
195
+ issues.push({
196
+ field: "source",
197
+ expected: "'explicit' | 'self_critique'",
198
+ got: "'behavioral'",
199
+ hint: "Behavioral feedback is daemon-only and is written by SignalDetector.",
200
+ });
201
+ }
202
+ const summary = typeof body.summary === "string"
203
+ ? sanitizeString(body.summary, MAX_SUMMARY_CHARS)
204
+ : "";
205
+ if (summary.length === 0) {
206
+ issues.push({
207
+ field: "summary",
208
+ expected: "non-empty string",
209
+ got: describeType(body.summary),
210
+ });
211
+ }
212
+ const rawValence = body.valence;
213
+ const valence = typeof rawValence === "string" ? rawValence : null;
214
+ if (valence === null || !VALENCES.has(valence)) {
215
+ issues.push({
216
+ field: "valence",
217
+ expected: "'positive' | 'negative' | 'neutral' | 'correction'",
218
+ got: describeType(rawValence),
219
+ });
220
+ }
221
+ const rawKind = body.kind;
222
+ const kind = typeof rawKind === "string" ? rawKind : null;
223
+ if (kind !== null && !KINDS.has(kind)) {
224
+ issues.push({
225
+ field: "kind",
226
+ expected: "'preference' | 'correction' | 'do-more' | 'do-less' | 'constraint' (optional)",
227
+ got: kind,
228
+ });
229
+ }
230
+ const rawScopeType = body.scope_type;
231
+ const scopeType = typeof rawScopeType === "string" ? rawScopeType : "";
232
+ if (!API_SCOPE_TYPES.has(scopeType)) {
233
+ issues.push({
234
+ field: "scope_type",
235
+ expected: "'user' | 'agent' | 'agent_slug'",
236
+ got: describeType(rawScopeType),
237
+ });
238
+ }
239
+ const scopeRef = typeof body.scope_ref === "string"
240
+ ? sanitizeString(body.scope_ref, MAX_SCOPE_REF_CHARS)
241
+ : null;
242
+ let agentId = null;
243
+ if (scopeType === "agent_slug") {
244
+ if (!scopeRef) {
245
+ issues.push({
246
+ field: "scope_ref",
247
+ expected: "existing agent slug when scope_type='agent_slug'",
248
+ got: describeType(body.scope_ref),
249
+ });
250
+ }
251
+ else if (!getAgent(db, scopeRef)) {
252
+ issues.push({
253
+ field: "scope_ref",
254
+ expected: "existing agent slug",
255
+ got: scopeRef,
256
+ });
257
+ }
258
+ else {
259
+ agentId = scopeRef;
260
+ }
261
+ }
262
+ const rawActionKind = body.action_kind;
263
+ const actionKind = typeof rawActionKind === "string" ? rawActionKind : null;
264
+ if (actionKind !== null
265
+ && !ACTION_KINDS.has(actionKind)) {
266
+ issues.push({
267
+ field: "action_kind",
268
+ expected: "'notification' | 'agent_execution' | 'vault_write' | 'dm_reply' (optional)",
269
+ got: actionKind,
270
+ });
271
+ }
272
+ const actionRef = typeof body.action_ref === "string"
273
+ ? sanitizeString(body.action_ref, MAX_ACTION_REF_CHARS)
274
+ : null;
275
+ if (issues.length > 0) {
276
+ return c.json({
277
+ error: "validation_error",
278
+ message: "Request body failed schema validation",
279
+ issues,
280
+ }, 400);
281
+ }
282
+ const normalizedScopeRef = scopeType === "agent_slug" ? scopeRef : null;
283
+ const deduped = findRecentFeedbackSignal(db, {
284
+ scopeType: scopeType,
285
+ scopeRef: normalizedScopeRef,
286
+ summary,
287
+ withinSeconds: DEDUP_TTL_SECONDS,
288
+ });
289
+ if (deduped) {
290
+ return c.json({ id: deduped.id, deduped: true });
291
+ }
292
+ const evidence = sanitizeEvidence(body.evidence);
293
+ const id = recordFeedbackSignal(db, {
294
+ source: source,
295
+ valence: valence,
296
+ scopeType: scopeType,
297
+ scopeRef: normalizedScopeRef,
298
+ actionKind: actionKind,
299
+ actionRef,
300
+ agentId,
301
+ summary,
302
+ evidence: {
303
+ ...(isRecord(evidence)
304
+ ? evidence
305
+ : evidence === null
306
+ ? {}
307
+ : { value: evidence }),
308
+ ...(kind ? { kind } : {}),
309
+ },
310
+ });
311
+ logger.info({ id, source, scopeType, scopeRef: normalizedScopeRef }, "Feedback signal recorded");
312
+ return c.json({ id });
313
+ });
314
+ app.post("/feedback/consume", async (c) => {
315
+ const parsedBody = await readJsonBody(c);
316
+ if (!parsedBody.ok)
317
+ return parsedBody.response;
318
+ const body = parsedBody.body;
319
+ if (!isRecord(body)) {
320
+ return c.json({
321
+ error: "validation_error",
322
+ message: "Body must be a JSON object",
323
+ expectedShape: '{"ids": number[], "lessonRef"?: string}',
324
+ }, 400);
325
+ }
326
+ if (!Array.isArray(body.ids)) {
327
+ return c.json({
328
+ error: "validation_error",
329
+ message: "'ids' must be an array of integer feedback signal ids",
330
+ expectedShape: '{"ids": number[], "lessonRef"?: string}',
331
+ }, 400);
332
+ }
333
+ const nonInt = body.ids.find((id) => typeof id !== "number" || !Number.isInteger(id));
334
+ if (nonInt !== undefined) {
335
+ return c.json({
336
+ error: "validation_error",
337
+ message: "'ids' must contain only integers",
338
+ got: JSON.stringify(nonInt),
339
+ }, 400);
340
+ }
341
+ const lessonRef = typeof body.lessonRef === "string"
342
+ ? sanitizeString(body.lessonRef, 240)
343
+ : null;
344
+ const result = consumeFeedbackSignals(db, body.ids, lessonRef);
345
+ logger.info({ consumed: result.consumed }, "Feedback signals consumed");
346
+ return c.json(result);
347
+ });
348
+ return app;
349
+ }
@@ -113,8 +113,12 @@ export function createGitRoutes(deps) {
113
113
  }
114
114
  // Use || (not ??) so explicit ?ref= (empty string) falls back to default
115
115
  const ref = c.req.query("ref") || "HEAD~1..HEAD";
116
- // Sanitize ref — reject shell metacharacters and flag-like arguments
117
- if (/[;&|`$]/.test(ref) || ref.startsWith("-")) {
116
+ // Sanitize ref — reject shell metacharacters and flag-like arguments.
117
+ // Also reject `:` — `git diff <rev>:<path>` is blob/tree syntax that reads
118
+ // arbitrary committed file content, beyond this endpoint's commit-diff
119
+ // purpose. No legitimate commit/range ref (`HEAD~1..HEAD`, `origin/main`)
120
+ // contains a colon. (execFile already prevents shell injection.)
121
+ if (/[;&|`$]/.test(ref) || ref.startsWith("-") || ref.includes(":")) {
118
122
  return respondWithAgentError(c, 400, [
119
123
  composeIssue("git.invalid_ref", {
120
124
  field: "ref",
@@ -149,7 +153,10 @@ export function createGitRoutes(deps) {
149
153
  ], { legacyErrorCode: "invalid or missing repo" });
150
154
  }
151
155
  const hash = c.req.query("hash") || "HEAD";
152
- if (/[;&|`$]/.test(hash) || hash.startsWith("-")) {
156
+ // Reject `:` for the same reason as /git/diff `git show <rev>:<path>`
157
+ // prints arbitrary committed file content, beyond this endpoint's
158
+ // commit-show purpose. A commit hash / ref never contains a colon.
159
+ if (/[;&|`$]/.test(hash) || hash.startsWith("-") || hash.includes(":")) {
153
160
  return respondWithAgentError(c, 400, [
154
161
  composeIssue("git.invalid_hash", {
155
162
  field: "hash",
@@ -224,7 +224,11 @@ export function createGitHubRoutes(deps) {
224
224
  }
225
225
  const event = parseGitHubEvent(eventType, payload, resolved.repositoryId);
226
226
  if (event) {
227
- eventBus.put(event);
227
+ // `EventBus.put` is async; awaiting it (like the repositories routes do)
228
+ // ensures the event is enqueued before the webhook returns `accepted`
229
+ // and surfaces any enqueue rejection instead of dropping it as an
230
+ // unhandled promise rejection.
231
+ await eventBus.put(event);
228
232
  logger.info({ eventType, action: payload.action, repositoryId: resolved.repositoryId }, "GitHub webhook event received");
229
233
  }
230
234
  // Notify GitWatcher that webhook is alive (adjusts polling frequency)