@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
package/dist/api/env-writer.d.ts
CHANGED
|
@@ -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[];
|
package/dist/api/env-writer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/api/routes/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|