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
310 changes: 310 additions & 0 deletions docs/superpowers/plans/2026-06-18-memoria-perfil-curado.md

Large diffs are not rendered by default.

375 changes: 375 additions & 0 deletions docs/superpowers/specs/2026-06-18-memoria-perfil-curado-design.md

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions scripts/migrations/0019_user_profile.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- 0018: perfil curado de memória por conta (Hermes/Open Second Brain).
-- Prosa NÃO-secreta, lida a cada montagem de sessão MCP. Fora do vault AES.
CREATE TABLE IF NOT EXISTS user_profile_facts (
id bigserial PRIMARY KEY,
account_id text NOT NULL,
category text NOT NULL,
content text NOT NULL,
status text NOT NULL DEFAULT 'signal',
applied_count int NOT NULL DEFAULT 0,
violated_count int NOT NULL DEFAULT 0,
confidence_value real NOT NULL DEFAULT 0,
confidence_band text NOT NULL DEFAULT 'low',
pinned boolean NOT NULL DEFAULT false,
source text NOT NULL DEFAULT 'manual',
content_hash text NOT NULL,
last_evidence_at timestamptz,
valid_from timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (account_id, content_hash)
);
CREATE INDEX IF NOT EXISTS idx_upf_account_status ON user_profile_facts (account_id, status);

-- trilha append-only de transições de estado da memória
CREATE TABLE IF NOT EXISTS memory_audit (
id bigserial PRIMARY KEY,
account_id text NOT NULL,
fact_id bigint,
from_state text,
to_state text,
trigger text NOT NULL,
evidence_ref text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_memaudit_account ON memory_audit (account_id, created_at DESC);
22 changes: 21 additions & 1 deletion src/__tests__/mcp-account-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// puro mcp-account-config.ts justamente para ser testável sem boot do servidor.
import { test } from "node:test";
import assert from "node:assert/strict";
import { OWNER_INSTRUCTIONS, FRIEND_INSTRUCTIONS } from "../mcp-account-config.js";
import { OWNER_INSTRUCTIONS, FRIEND_INSTRUCTIONS, composeInstructions } from "../mcp-account-config.js";

test("owner e friend instructions trazem a regra Zinom-first e os links", () => {
for (const s of [OWNER_INSTRUCTIONS, FRIEND_INSTRUCTIONS]) {
Expand All @@ -24,3 +24,23 @@ test("owner e friend instructions ensinam brain_get_document p/ conteúdo ínteg
assert.match(s, /NUNCA reconstrua um documento somando resultados de brain_search/);
}
});

test("composeInstructions: profileBlock vazio/null retorna a base inalterada", () => {
// INVARIANTE: o SDK só envia `instructions` se truthy; jamais devolver '' por
// ausência de bloco — devolver a base intacta.
assert.equal(composeInstructions(OWNER_INSTRUCTIONS, null), OWNER_INSTRUCTIONS);
assert.equal(composeInstructions(OWNER_INSTRUCTIONS, ""), OWNER_INSTRUCTIONS);
assert.equal(composeInstructions(OWNER_INSTRUCTIONS, " "), OWNER_INSTRUCTIONS); // só-espaços = vazio
});

test("composeInstructions: concatena base + bloco preservando ambos", () => {
const c = composeInstructions(OWNER_INSTRUCTIONS, "PERFIL: x");
assert.ok(c.includes(OWNER_INSTRUCTIONS), "deve conter a base íntegra");
assert.ok(c.includes("PERFIL: x"), "deve conter o bloco de perfil");
});

test("composeInstructions: nunca devolve '' e sempre contém a base não-vazia", () => {
const c = composeInstructions("base", "b");
assert.notEqual(c, "");
assert.ok(c.includes("base"));
});
132 changes: 132 additions & 0 deletions src/__tests__/profile-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// src/__tests__/profile-injection.test.ts
// T6 (CRÍTICO DE SEGURANÇA): resolveInstructions injeta o perfil curado nas
// `instructions` da sessão MCP de forma FAIL-CLOSED e gated por flag. Um bug aqui
// vazaria o perfil do owner para um friend. Estes testes são PUROS (sem Postgres,
// sem Express): o carregamento de fatos é injetado (loadFacts fake/spy), então
// um friend NUNCA pode tocar os fatos do owner.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
resolveInstructions,
isOwnerContext,
OWNER_INSTRUCTIONS,
FRIEND_INSTRUCTIONS,
} from "../mcp-account-config.js";
import type { ProfileFact } from "../rag/profile.js";

// Fato fixture COMPLETO (todos os campos do tipo ProfileFact). pinned=true para
// ser sempre elegível pelo renderProfile real, independentemente de confiança.
function makeFact(overrides: Partial<ProfileFact> = {}): ProfileFact {
return {
id: 1,
account_id: "bruno",
category: "preferencia",
content: "OWNER_SECRET_FACT_marcador_unico",
status: "confirmed",
applied_count: 3,
violated_count: 0,
confidence_value: 0.95,
confidence_band: "high",
pinned: true,
source: "manual",
content_hash: "hash-1",
last_evidence_at: null,
...overrides,
};
}

// Um spy de loadFacts que FALHA o teste se for chamado: prova que o owner=false
// (friend) curto-circuita ANTES de qualquer carregamento de fatos do owner.
function neverCalled(): (accountId: string) => Promise<ProfileFact[]> {
return async () => {
throw new Error("loadFacts NÃO deveria ter sido chamado (fail-closed violado)");
};
}

// Um spy que, se chamado, devolveria os fatos do OWNER — usado para provar que o
// resultado para um friend não contém nenhum byte desses fatos.
function spyThatReturnsOwnerFacts(): {
fn: (accountId: string) => Promise<ProfileFact[]>;
called: () => boolean;
} {
let was = false;
const fn = async () => {
was = true;
return [makeFact()];
};
return { fn, called: () => was };
}

// AC5 — flag off = baseline exato, e loadFacts NUNCA é chamado.
test("AC5: flag off → owner recebe OWNER_INSTRUCTIONS sem tocar loadFacts", async () => {
const out = await resolveInstructions({
owner: true,
injectEnabled: false,
defaultAccountId: "bruno",
loadFacts: neverCalled(),
});
assert.equal(out, OWNER_INSTRUCTIONS);
});

test("AC5: flag off → friend recebe FRIEND_INSTRUCTIONS sem tocar loadFacts", async () => {
const out = await resolveInstructions({
owner: false,
injectEnabled: false,
defaultAccountId: "bruno",
loadFacts: neverCalled(),
});
assert.equal(out, FRIEND_INSTRUCTIONS);
});

// AC4 — fail-closed.
test("AC4: isOwnerContext é false para OAuth friend com accountId", () => {
assert.equal(
isOwnerContext({ authType: "oauth", scopes: ["personal"], accountId: "friend:x" }),
false,
);
});

test("AC4: isOwnerContext é false para OAuth sem accountId e sem isOperator", () => {
assert.equal(
isOwnerContext({
authType: "oauth",
scopes: ["personal"],
accountId: undefined,
isOperator: undefined,
}),
false,
);
});

test("AC4: flag on + friend → NÃO contém fatos do owner E loadFacts NÃO é chamado", async () => {
const spy = spyThatReturnsOwnerFacts();
const out = await resolveInstructions({
owner: false,
injectEnabled: true,
defaultAccountId: "bruno",
loadFacts: spy.fn,
});
// owner=false curto-circuita: nunca carrega nem renderiza fatos do owner.
assert.equal(spy.called(), false, "loadFacts não pode ser chamado para friend");
assert.equal(out, FRIEND_INSTRUCTIONS, "friend recebe a base friend pura");
assert.ok(
!out.includes("OWNER_SECRET_FACT_marcador_unico"),
"resultado do friend não pode conter nenhum byte dos fatos do owner",
);
});

// owner on — perfil é injetado.
test("owner on: contém OWNER_INSTRUCTIONS E o conteúdo do fato pinned", async () => {
const pinned = makeFact({ content: "Bruno prefere respostas curtas em PT-BR." });
const out = await resolveInstructions({
owner: true,
injectEnabled: true,
defaultAccountId: "bruno",
loadFacts: async () => [pinned],
});
assert.ok(out.includes(OWNER_INSTRUCTIONS), "deve conter a base owner íntegra");
assert.ok(
out.includes("Bruno prefere respostas curtas em PT-BR."),
"deve conter o conteúdo do fato pinned",
);
});
12 changes: 11 additions & 1 deletion src/index-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import { runRevisitar } from "./classifier/revisitar.js";
import { syncGranolasToReunioes } from "./classifier/granola-to-reuniao.js";
import { runDailyBriefing } from "./briefing/daily-briefing.js";
import { recordRun } from "./rag/storage.js";
import { tickMemoryCuration } from "./rag/memory-curation-tick.js";
import { runResyncTick } from "./billing/resync-cron.js";
import { notify } from "./notify.js";

const CLASSIFIER_CRON = process.env.CLASSIFIER_CRON ?? "30 * * * *"; // half past every hour
const REVISITAR_CRON = process.env.REVISITAR_CRON ?? "0 7 * * *"; // 07:00 every day
const GRANOLA_REUNIAO_CRON = process.env.GRANOLA_REUNIAO_CRON ?? "*/15 * * * *"; // every 15min
const BRIEFING_CRON = process.env.BRIEFING_CRON ?? "0 7 * * *"; // 07:00 every day
const MEMORY_CURATION_CRON = process.env.MEMORY_CURATION_CRON ?? "15 4 * * *"; // 04:15 every day

async function tickClassifier(label: string): Promise<void> {
const start = Date.now();
Expand Down Expand Up @@ -84,7 +86,7 @@ async function tickBriefing(label: string): Promise<void> {
}

console.log(
`brain-classifier starting; classifier cron: ${CLASSIFIER_CRON}; revisitar cron: ${REVISITAR_CRON}; granola->reuniao cron: ${GRANOLA_REUNIAO_CRON}; briefing cron: ${BRIEFING_CRON}`,
`brain-classifier starting; classifier cron: ${CLASSIFIER_CRON}; revisitar cron: ${REVISITAR_CRON}; granola->reuniao cron: ${GRANOLA_REUNIAO_CRON}; briefing cron: ${BRIEFING_CRON}; memory-curation cron: ${MEMORY_CURATION_CRON}`,
);
console.log("running initial classifier tick...");
void tickClassifier("initial");
Expand Down Expand Up @@ -133,6 +135,14 @@ cron.schedule(CLASSIFIER_CRON, () => {
void tickEntities("cron");
});

// T7/T8 — agenda o curador determinístico OWNER-ONLY num horário noturno
// (default 04:15). O gate MEMORY_CURATION_ENABLED é checado dentro de
// tickMemoryCuration: o schedule existe sempre, mas o tick é no-op quando a flag
// não é 'true'. Lazy-import e recordRun(source='memory-curation') vivem no tick.
cron.schedule(MEMORY_CURATION_CRON, () => {
void tickMemoryCuration("cron");
});

// Fase 3 billing — per-account auto re-sync. Hourly tick; each account is
// re-indexed only when its plan's syncIntervalHours has elapsed (free skipped).
const RESYNC_CRON = process.env.RESYNC_CRON ?? "15 * * * *";
Expand Down
22 changes: 19 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ import { createRubrixRouter } from "./rubrix/routes.js";
import { registerRubrixTools } from "./rubrix/tools.js";
import { resolveBearer, accountWorkspaces } from "./account-bearer.js";
import { isAccountActive } from "./account-status.js";
import { requestContext, getContext, getAccountId, type RequestContext } from "./context.js";
import { isOwnerContext, isOperatorToken, OWNER_INSTRUCTIONS, FRIEND_INSTRUCTIONS } from "./mcp-account-config.js";
import { requestContext, getContext, getAccountId, DEFAULT_ACCOUNT_ID, type RequestContext } from "./context.js";
import { isOwnerContext, isOperatorToken, resolveInstructions } from "./mcp-account-config.js";
import { loadProfileFacts } from "./rag/profile-storage.js";
import { ALL_WORKSPACES } from "./clients.js";
import { getStatus } from "./rag/storage.js";
import { summarizeStatus, renderStatusHtml, escapeHtml } from "./rag/status.js";
Expand Down Expand Up @@ -131,6 +132,11 @@ app.use((req, _res, next) => {
// Auth middleware for /mcp — accepts static BEARER_TOKEN or OAuth access tokens
const BEARER_TOKEN = process.env.BEARER_TOKEN;

// T6 — gate (default OFF) para injetar o perfil curado do owner nas instructions
// da sessão. Fail-closed: o perfil só é carregado para o owner (ver
// resolveInstructions); um friend nunca toca os fatos do owner, flag ligada ou não.
const PROFILE_INJECT_ENABLED = process.env.PROFILE_INJECT_ENABLED === "true";

// OAuth routes (well-known, register, authorize, token, admin)
app.use(createOAuthRouter(BASE_URL, BEARER_TOKEN));

Expand Down Expand Up @@ -446,13 +452,23 @@ app.post("/mcp", async (req, res) => {
if (id) evictSession(id);
};

// T6 — instructions da sessão (owner/friend), opcionalmente enriquecidas com o
// perfil curado do owner quando PROFILE_INJECT_ENABLED. Fail-closed: friend nunca
// carrega os fatos do owner. Montado ANTES de instanciar o server (handler async).
const instructions = await resolveInstructions({
owner,
injectEnabled: PROFILE_INJECT_ENABLED,
defaultAccountId: DEFAULT_ACCOUNT_ID,
loadFacts: loadProfileFacts,
});

const server = new McpServer(
{
name: "zinom",
version: "1.0.0",
},
{
instructions: owner ? OWNER_INSTRUCTIONS : FRIEND_INSTRUCTIONS,
instructions,
}
);

Expand Down
56 changes: 56 additions & 0 deletions src/mcp-account-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// Security note: ownership is decided ONLY from the trusted request context's
// accountId (set by the auth layer), never from tool input.
import { DEFAULT_ACCOUNT_ID, type RequestContext } from "./context.js";
import { renderProfile, PROFILE_CHAR_BUDGET, type ProfileFact } from "./rag/profile.js";

/** True when the request is the operator/owner (full notion_* suite + full
* INSTRUCTIONS), false for an onboarded friend account (restricted, safe set).
Expand Down Expand Up @@ -43,6 +44,61 @@ export function isOperatorToken(
);
}

/** Pure concat of the base server instructions with an optional per-account
* profile block (e.g. a remembered preferences summary). Used to enrich the
* session `instructions` without mutating the base consts.
*
* INVARIANTE CRÍTICA: o SDK MCP só envia `instructions` quando truthy — uma
* string vazia derrubaria TODAS as instructions da sessão. Por isso:
* - `profileBlock` null / vazio / só-espaços → retorna `base` inalterada;
* - caso contrário → `base` + separador + `profileBlock`.
* Na prática `base` nunca é vazia em produção (são as consts OWNER/FRIEND
* já `.trim()`'d). Mesmo assim a função é segura: ela nunca destrói `base`,
* e quando `base` é não-vazia o retorno sempre a contém. Concat puro: NÃO muta
* OWNER_INSTRUCTIONS / FRIEND_INSTRUCTIONS. */
export function composeInstructions(base: string, profileBlock: string | null): string {
if (profileBlock === null || profileBlock.trim() === "") return base;
return `${base}\n\n${profileBlock}`;
}

/**
* T6 (CRÍTICO DE SEGURANÇA): resolve as `instructions` da sessão MCP, opcionalmente
* enriquecidas com o perfil curado do owner. FAIL-CLOSED por desenho:
*
* - `base` = owner ? OWNER_INSTRUCTIONS : FRIEND_INSTRUCTIONS.
* - O perfil só é carregado/renderizado quando `injectEnabled && owner` for true.
* Um friend (owner=false) NUNCA chama `loadFacts`: o perfil curado é do owner e
* carregá-lo no caminho de um friend já seria um vazamento (mesmo que o bloco
* fosse descartado depois). O curto-circuito `owner &&` garante isso.
* - O resultado SEMPRE contém `base` (composeInstructions nunca devolve '' nem a
* destrói): mesmo sem fatos elegíveis, a sessão mantém suas instructions.
*
* `loadFacts`/`render`/`budget` são injetados para manter a função testável sem
* Postgres nem Express. Em produção: loadFacts = loadProfileFacts, render =
* renderProfile, budget = PROFILE_CHAR_BUDGET.
*/
export async function resolveInstructions(args: {
owner: boolean;
injectEnabled: boolean;
defaultAccountId: string;
loadFacts: (accountId: string) => Promise<ProfileFact[]>;
render?: (facts: ProfileFact[], budget: number) => string | null;
budget?: number;
}): Promise<string> {
const base = args.owner ? OWNER_INSTRUCTIONS : FRIEND_INSTRUCTIONS;
const render = args.render ?? renderProfile;
const budget = args.budget ?? PROFILE_CHAR_BUDGET;

// FAIL-CLOSED: o perfil curado é do owner. Só carrega quando a flag está ligada
// E o request é do owner. Para um friend, NUNCA toca loadFacts.
const profileBlock =
args.injectEnabled && args.owner
? render(await args.loadFacts(args.defaultAccountId), budget)
: null;

return composeInstructions(base, profileBlock);
}

// The operator/owner server instructions (moved from index.ts so they are pure
// and unit-testable). They name the owner's three private workspaces and house
// rules — NEVER serve them to a friend account (see FRIEND_INSTRUCTIONS below).
Expand Down
Loading
Loading