Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion context/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
- Tool call summary (`src/agent/services/friday-tool-call-summary.ts`) captures privacy-safe tool execution metadata (arg keys only, no values) for observability and world model training data.
- Warn-once pattern is used across 7+ modules (hub-bootstrap, auth, memory, system, http-server, workspace-context, unix-socket-bridge) to deduplicate runtime warnings without losing critical signals.
- OpenAI Responses API (`openai-responses`) streaming is now supported alongside `openai-completions` in the agent LLM client.
- Database migration count: 91 (latest: v091-preference-fact-metadata).
- Database migration count: 92 (latest: v092-reflex-secure-fact-candidates).
3 changes: 2 additions & 1 deletion src/agent/tools/friday-agent-reflex-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const CANDIDATE_STATUSES = new Set<FridayReflexCandidateStatus>([

const CANDIDATE_KINDS = new Set<FridayReflexCandidateKind>([
"memory",
"secure_fact",
"learned_fact",
"preference",
"recipe",
Expand Down Expand Up @@ -213,7 +214,7 @@ function evaluateAgentReflexApproval(candidate: FridayReflexCandidate): {

return {
allowed: false,
reason: "Skill, workflow, fix, recipe, and test-policy candidates require Review Center confirmation.",
reason: "Secure-fact, learned-fact, skill, workflow, fix, recipe, and test-policy candidates require Review Center confirmation.",
};
}

Expand Down
1 change: 1 addition & 0 deletions src/api/http/routes/friday-reflex-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const CANDIDATE_STATUSES = new Set<FridayReflexCandidateStatus>([

const CANDIDATE_KINDS = new Set<FridayReflexCandidateKind>([
"memory",
"secure_fact",
"learned_fact",
"preference",
"recipe",
Expand Down
23 changes: 23 additions & 0 deletions src/hub/friday-hub-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
createFridayProviderCostCalculator,
createFridayProviderPricingCatalog,
createFridayProviderService,
createFridaySecretAdminService,
createFridaySecretRepository,
decryptSecret,
getFridayProviderPreset,
Expand Down Expand Up @@ -5977,6 +5978,11 @@ export async function createFridayHub(

const reflexCandidateRepository = createFridayReflexCandidateRepository();
const reflexOnboardingRepository = createFridayReflexOnboardingRepository();
const reflexSecretAdminService = createFridaySecretAdminService({
db: stateRuntime.sqlite,
idGenerator,
nowIso,
});
reflexService = createFridayReflexService({
db: stateRuntime.sqlite,
candidateRepo: reflexCandidateRepository,
Expand Down Expand Up @@ -6033,6 +6039,23 @@ export async function createFridayHub(
lastConfirmedAt: fact.lastConfirmedAt,
};
},
secureFactStager: (input) => {
const secret = reflexSecretAdminService.createSecret({
scope: "learned_fact",
refKey: `${input.userId}:${input.candidateId}`,
value: input.value,
});
return {
secretId: secret.id,
scope: secret.scope,
refKey: secret.refKey,
createdAt: secret.createdAt,
updatedAt: secret.updatedAt,
};
},
secureFactRejecter: (input) => ({
deleted: reflexSecretAdminService.deleteSecret(input.secretId),
}),
skillGenerator,
workflowGenerator,
learningEventWriter,
Expand Down
1 change: 1 addition & 0 deletions src/reflex/model/friday-reflex.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { JsonValue } from "../../uix/model/friday-uix.types.js";

export type FridayReflexCandidateKind =
| "memory"
| "secure_fact"
| "learned_fact"
| "preference"
| "recipe"
Expand Down
212 changes: 206 additions & 6 deletions src/reflex/services/friday-reflex-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,27 @@ export interface FridayReflexLearnedFactApprovalResult {
lastConfirmedAt: string;
}

export interface FridayReflexSecureFactStageResult {
secretId: string;
scope: string;
refKey: string;
createdAt?: string;
updatedAt?: string;
}

export interface FridayReflexSecureFactStagerInput {
userId: string;
key: string;
subject: string;
value: string;
candidateId: string;
origin: FridayReflexCandidate["origin"];
sourceRunId?: string;
sessionKey?: string;
nowIso: string;
evidence: Record<string, JsonValue>;
}

export interface CreateFridayReflexServiceDeps {
db: FridaySqliteLayer;
candidateRepo: FridayReflexCandidateRepository;
Expand All @@ -190,6 +211,15 @@ export interface CreateFridayReflexServiceDeps {
nowIso: string;
evidence: Record<string, JsonValue>;
}) => FridayReflexLearnedFactApprovalResult;
secureFactStager?: (input: FridayReflexSecureFactStagerInput) => FridayReflexSecureFactStageResult;
secureFactRejecter?: (input: {
userId: string;
candidateId: string;
secretId: string;
scope: string;
refKey: string;
nowIso: string;
}) => { deleted: boolean };
skillGenerator?: FridaySkillGeneratorService;
workflowGenerator?: FridayWorkflowGeneratorService;
learningEventWriter?: (events: FridayLearningEventAppendInput[]) => void;
Expand Down Expand Up @@ -261,7 +291,7 @@ function learnedFactSubjectSlug(subject: string): string | null {

function extractLearnedFactFromRunTask(task: string | undefined): {
key: string;
value: JsonValue;
value: string;
statement: string;
subject: string;
} | null {
Expand All @@ -288,6 +318,32 @@ function extractLearnedFactFromRunTask(task: string | undefined): {
};
}

function extractSecureFactFromRunTask(task: string | undefined): {
key: string;
value: string;
subject: string;
} | null {
const text = task?.trim().slice(0, LEARNED_FACT_TASK_MAX_LENGTH) ?? "";
if (!text) return null;
if (/(?:do not|don't|dont|never|不要|别|不用|不许).{0,40}(?:remember|learn|record|store|记住|学习|保存)/iu.test(text)) {
return null;
}

const english = /(?:remember|learn|note|record|for future reference)[,:]?\s+(?:that\s+)?((?:my|our|the|this|a|an)\s+[^.!?;\n:=]{2,80}?)\s+(?:is|are|=|:)\s+([^.!?;\n]{1,160})/iu.exec(text);
const chinese = /(?:请记住|记住|学习|以后记得|以后请记得)[::]?\s*((?:我的|我们的|这个|本项目|项目)?[^,。!?\n:=:]{2,50}?)(?:是|为|=|:|:)\s*([^,。!?\n]{1,120})/u.exec(text);
const subject = (english?.[1] ?? chinese?.[1] ?? "").trim();
const value = cleanLearnedFactValue(english?.[2] ?? chinese?.[2] ?? "");
if (!subject || !value) return null;
if (!isFridaySensitiveLearningCandidate(text) && !isFridaySensitiveLearningCandidate(subject, value)) return null;
const slug = learnedFactSubjectSlug(subject);
if (!slug) return null;
return {
key: `secure.${slug}`,
value,
subject,
};
}

function buildCandidateContent(candidate: FridayReflexCandidate): string {
const content = readPayloadString(candidate.payload, "content")
?? readPayloadString(candidate.payload, "text")
Expand Down Expand Up @@ -611,6 +667,20 @@ export function createFridayReflexService(
) ?? null;
}

function findPendingSecureFactCandidate(db: FridayReflexDb, input: {
userId: string;
key: string;
}): FridayReflexCandidate | null {
return deps.candidateRepo.list(db, {
userId: input.userId,
kind: "secure_fact",
limit: 200,
}).find((candidate) =>
(candidate.status === "proposed" || candidate.status === "ready_for_review" || candidate.status === "testing")
&& candidate.payload.key === input.key,
) ?? null;
}

function createLearnedFactCandidateFromRunTask(input: {
userId: string;
runId?: string;
Expand Down Expand Up @@ -659,6 +729,88 @@ export function createFridayReflexService(
});
}

function createSecureFactCandidateFromRunTask(input: {
userId: string;
runId?: string;
sessionKey?: string;
channelKind?: string;
channelUserId?: string;
task?: string;
}): FridayReflexCandidate | null {
if (!deps.secureFactStager) return null;
const extracted = extractSecureFactFromRunTask(input.task);
if (!extracted) return null;
const existing = deps.db.withReadConnection((db) =>
findPendingSecureFactCandidate(db, {
userId: input.userId,
key: extracted.key,
}));
if (existing) return existing;

const candidateId = deps.idGenerator();
const now = deps.nowIso();
const origin: FridayReflexCandidate["origin"] = input.channelKind ? "channel" : "post_run";
const redactedEvidence: Record<string, JsonValue> = {
requiresExplicitConfirmation: true,
source: "post_run_task_text",
extractionMode: "explicit_sensitive_fact_pattern",
statement: `${extracted.subject} = [encrypted secret pending review]`,
valueRedacted: true,
safetyBoundary: "encrypted_secret_staged_pending_review",
};
let staged: FridayReflexSecureFactStageResult;
try {
staged = deps.secureFactStager({
userId: input.userId,
key: extracted.key,
subject: extracted.subject,
value: extracted.value,
candidateId,
origin,
...(input.runId ? { sourceRunId: input.runId } : {}),
...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
nowIso: now,
evidence: redactedEvidence,
});
} catch {
return null;
}

return deps.db.withWriteTransaction((db) =>
deps.candidateRepo.insert(db, {
id: candidateId,
nowIso: now,
userId: input.userId,
kind: "secure_fact",
origin,
status: "ready_for_review",
sourceRunId: input.runId,
sessionKey: input.sessionKey,
channelKind: input.channelKind,
channelUserId: input.channelUserId,
title: `Review encrypted fact: ${extracted.subject.slice(0, 80)}`,
summary: "Friday detected an explicit sensitive fact request. The value was staged in encrypted secret storage and stays redacted until you review it.",
payload: {
key: extracted.key,
valueRedacted: true,
confidence: 0.84,
secretId: staged.secretId,
secretScope: staged.scope,
secretRefKey: staged.refKey,
},
evidence: {
...redactedEvidence,
secretId: staged.secretId,
secretScope: staged.scope,
secretRefKey: staged.refKey,
stagedAt: now,
stagedSecretCreatedAt: staged.createdAt ?? null,
},
confidence: 0.84,
riskTier: 3,
}));
}

function createPreferenceConfirmationCandidate(db: FridayReflexDb, input: {
userId: string;
category: FridayUserPreferenceCategory;
Expand Down Expand Up @@ -1300,6 +1452,28 @@ export function createFridayReflexService(
learnedFactLastConfirmedAt: result.lastConfirmedAt,
...(result.factId ? { learnedFactId: result.factId } : {}),
};
} else if (candidate.kind === "secure_fact") {
const key = readPayloadString(candidate.payload, "key");
const secretId = readPayloadString(candidate.payload, "secretId");
const secretScope = readPayloadString(candidate.payload, "secretScope");
const secretRefKey = readPayloadString(candidate.payload, "secretRefKey");
if (!key || !secretId || !secretScope || !secretRefKey) {
throw new FridayDomainError(
"REFLEX_SECURE_FACT_INVALID",
"Secure-fact candidate payload requires key and encrypted secret reference metadata.",
{ httpStatus: 400 },
);
}
evidence = {
...evidence,
secureFactConfirmed: true,
secureFactConfirmedAt: deps.nowIso(),
secureFactKey: key,
secretId,
secretScope,
secretRefKey,
valueRedacted: true,
};
} else if (candidate.kind === "preference") {
const category = readPayloadString(candidate.payload, "category") as FridayUserPreferenceCategory | undefined;
const key = readPayloadString(candidate.payload, "key");
Expand Down Expand Up @@ -1361,15 +1535,39 @@ export function createFridayReflexService(
},

rejectCandidate(input) {
const candidate = this.getCandidate({ userId: input.userId, candidateId: input.candidateId });
const evidence: Record<string, JsonValue> = {
rejectedBy: input.userId,
rejectedAt: deps.nowIso(),
...(input.reason ? { reason: input.reason } : {}),
};
if (candidate.kind === "secure_fact") {
const secretId = readPayloadString(candidate.payload, "secretId");
const secretScope = readPayloadString(candidate.payload, "secretScope");
const secretRefKey = readPayloadString(candidate.payload, "secretRefKey");
if (secretId && secretScope && secretRefKey && deps.secureFactRejecter) {
const result = deps.secureFactRejecter({
userId: input.userId,
candidateId: input.candidateId,
secretId,
scope: secretScope,
refKey: secretRefKey,
nowIso: deps.nowIso(),
});
evidence.stagedSecretDeleted = result.deleted;
evidence.secretId = secretId;
evidence.secretScope = secretScope;
evidence.secretRefKey = secretRefKey;
} else {
evidence.stagedSecretDeleted = false;
evidence.secureFactRejectionCleanup = "unavailable_or_missing_secret_ref";
}
}
return updateCandidateStatus({
userId: input.userId,
candidateId: input.candidateId,
status: "rejected",
evidence: {
rejectedBy: input.userId,
rejectedAt: deps.nowIso(),
...(input.reason ? { reason: input.reason } : {}),
},
evidence,
});
},

Expand Down Expand Up @@ -1488,6 +1686,8 @@ export function createFridayReflexService(
const created: FridayReflexCandidate[] = [];
const toolSequence = input.toolSequence ?? [];
if (input.outcome === "success") {
const secureFactCandidate = createSecureFactCandidateFromRunTask(input);
if (secureFactCandidate) created.push(secureFactCandidate);
const learnedFactCandidate = createLearnedFactCandidateFromRunTask(input);
if (learnedFactCandidate) created.push(learnedFactCandidate);
}
Expand Down
3 changes: 3 additions & 0 deletions src/state/sqlite/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import { V088_AUTO_FIX_ROLLBACK_RECEIPT_MIGRATION } from "./v088-auto-fix-rollba
import { V089_WORKFLOW_COMPLETION_VERIFICATION_MIGRATION } from "./v089-workflow-completion-verification.js";
import { V090_REFLEX_LEARNED_FACT_CANDIDATES_MIGRATION } from "./v090-reflex-learned-fact-candidates.js";
import { V091_PREFERENCE_FACT_METADATA_MIGRATION } from "./v091-preference-fact-metadata.js";
import { V092_REFLEX_SECURE_FACT_CANDIDATES_MIGRATION } from "./v092-reflex-secure-fact-candidates.js";

/**
* Ordered migration list, always ascending by version.
Expand Down Expand Up @@ -186,6 +187,7 @@ export const FRIDAY_SQLITE_MIGRATIONS: readonly FridaySqliteMigration[] = [
V089_WORKFLOW_COMPLETION_VERIFICATION_MIGRATION,
V090_REFLEX_LEARNED_FACT_CANDIDATES_MIGRATION,
V091_PREFERENCE_FACT_METADATA_MIGRATION,
V092_REFLEX_SECURE_FACT_CANDIDATES_MIGRATION,
];

export { V003_PROVIDER_USAGE_COST_ROUTING_MIGRATION };
Expand Down Expand Up @@ -276,3 +278,4 @@ export { V087_ROLLBACK_MATRIX_CLOSEOUT_RECEIPT_MIGRATION };
export { V088_AUTO_FIX_ROLLBACK_RECEIPT_MIGRATION };
export { V090_REFLEX_LEARNED_FACT_CANDIDATES_MIGRATION };
export { V091_PREFERENCE_FACT_METADATA_MIGRATION };
export { V092_REFLEX_SECURE_FACT_CANDIDATES_MIGRATION };
Loading
Loading