frinus-mcp 3.6.4 → 3.8.0

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/README.md CHANGED
@@ -11,7 +11,7 @@ It does **not** run on its own. It stores nothing locally: every tool call is fo
11
11
 
12
12
  With those in place, the server exposes **70 tools** spanning cognitive memory, working memory, sessions, agents, the L0–L3 knowledge hierarchy, orchestration tasks, and training pipelines. It speaks stdio and works with any MCP-aware client.
13
13
 
14
- - **Version:** 3.4.1
14
+ - **Version:** 3.8.0
15
15
  - **Tools:** 70 (see [Tools Reference](#tools-reference))
16
16
  - **Platform:** https://frinus.rdxsec.com.br (hosted SaaS — the backend this server talks to)
17
17
  - **npm package:** [`frinus-mcp`](https://www.npmjs.com/package/frinus-mcp)
@@ -1,4 +1,4 @@
1
- import type { CpClientInterface } from "../types/index.js";
1
+ import type { CpClientInterface, NotificationInbox, OwnerNotificationCreate } from "../types/index.js";
2
2
  export declare class CpClient implements CpClientInterface {
3
3
  private client;
4
4
  constructor(baseURL: string, apiKey: string);
@@ -13,6 +13,19 @@ export declare class CpClient implements CpClientInterface {
13
13
  description?: string;
14
14
  }): Promise<any>;
15
15
  deleteUniverse(orgId: string, universeId: string): Promise<void>;
16
+ getNotificationInbox(opts?: {
17
+ channel?: string;
18
+ limit?: number;
19
+ markDelivered?: boolean;
20
+ }): Promise<NotificationInbox>;
21
+ ackNotification(id: string): Promise<void>;
22
+ /**
23
+ * Publish an organization-scoped notice (owner only). The Control Plane forces
24
+ * scope='organization' and target_org_id to the caller's org; the owner only
25
+ * supplies the message and optional role/user targeting. Uses the normal 30s
26
+ * timeout: unlike the inbox poll, this is a deliberate user action.
27
+ */
28
+ createOrgNotification(data: OwnerNotificationCreate): Promise<any>;
16
29
  storeCredential(integrationRef: string, data: Record<string, unknown>, metadata?: Record<string, unknown>): Promise<any>;
17
30
  getCredential(integrationRef: string): Promise<any>;
18
31
  listCredentials(): Promise<any>;
@@ -4,6 +4,13 @@
4
4
  import axios from "axios";
5
5
  import { getResolvedTenantOrgId } from "./memory-client.js";
6
6
  import { getResolvedClient } from "./client-info.js";
7
+ /**
8
+ * Short timeout (ms) for the platform-notification calls. These are best-effort
9
+ * side channels that must never slow down bootstrap/heartbeat or hold the agent
10
+ * hostage on a slow network, so they use a much tighter deadline than the 30s
11
+ * default of the rest of the Control Plane client.
12
+ */
13
+ const NOTIFICATION_TIMEOUT_MS = 4000;
7
14
  export class CpClient {
8
15
  client;
9
16
  constructor(baseURL, apiKey) {
@@ -37,6 +44,35 @@ export class CpClient {
37
44
  await this.client.delete(`/api/v1/orgs/${orgId}/universes/${universeId}`);
38
45
  }
39
46
  // =========================================================================
47
+ // Platform Notifications (short timeout, best-effort)
48
+ // =========================================================================
49
+ async getNotificationInbox(opts = {}) {
50
+ const response = await this.client.get(`/api/v1/notifications/inbox`, {
51
+ params: {
52
+ channel: opts.channel ?? "mcp",
53
+ limit: opts.limit ?? 3,
54
+ mark_delivered: opts.markDelivered ?? false,
55
+ },
56
+ timeout: NOTIFICATION_TIMEOUT_MS,
57
+ });
58
+ return response.data;
59
+ }
60
+ async ackNotification(id) {
61
+ await this.client.post(`/api/v1/notifications/${id}/ack`, undefined, {
62
+ timeout: NOTIFICATION_TIMEOUT_MS,
63
+ });
64
+ }
65
+ /**
66
+ * Publish an organization-scoped notice (owner only). The Control Plane forces
67
+ * scope='organization' and target_org_id to the caller's org; the owner only
68
+ * supplies the message and optional role/user targeting. Uses the normal 30s
69
+ * timeout: unlike the inbox poll, this is a deliberate user action.
70
+ */
71
+ async createOrgNotification(data) {
72
+ const response = await this.client.post(`/api/v1/notifications/manage`, data);
73
+ return response.data;
74
+ }
75
+ // =========================================================================
40
76
  // Credential Vault
41
77
  // =========================================================================
42
78
  async storeCredential(integrationRef, data, metadata) {
@@ -38,9 +38,22 @@ export declare class MemoryClient implements MemoryClientInterface {
38
38
  created_by_user_id?: string;
39
39
  metadata?: Record<string, unknown>;
40
40
  context_id?: string;
41
+ context_path?: string;
41
42
  credential_ref?: string;
42
43
  universe_id?: string;
43
44
  }): Promise<any>;
45
+ /**
46
+ * hierarchy_find — read-only preview of the hierarchy nodes that best match a
47
+ * free-text query. Reuses the write-time classifier's node index (never
48
+ * writes). The agent calls it before memory_store (to copy a context_path) and
49
+ * before a tree-specific search. Universe permission is enforced server-side;
50
+ * an explicit universe_id the caller cannot read returns 403.
51
+ */
52
+ findHierarchy(data: {
53
+ query_text: string;
54
+ universe_id?: string;
55
+ limit?: number;
56
+ }): Promise<any>;
44
57
  searchMemories(data: {
45
58
  query_text: string;
46
59
  agent_id?: string;
@@ -98,6 +98,11 @@ export class MemoryClient {
98
98
  payload.created_by_user_id = data.created_by_user_id;
99
99
  if (data.context_id)
100
100
  payload.context_id = data.context_id;
101
+ // Anchor by NAME (Onda 3): the backend resolves the breadcrumb to a node and
102
+ // uses it as the anchor target / classifier hint. Precedence is enforced by
103
+ // the backend: context_id (UUID) > context_path (name) > universe_id.
104
+ if (data.context_path)
105
+ payload.context_path = data.context_path;
101
106
  if (data.credential_ref)
102
107
  payload.credential_ref = data.credential_ref;
103
108
  if (data.universe_id)
@@ -105,6 +110,22 @@ export class MemoryClient {
105
110
  const response = await this.client.post("/memories", payload);
106
111
  return response.data;
107
112
  }
113
+ /**
114
+ * hierarchy_find — read-only preview of the hierarchy nodes that best match a
115
+ * free-text query. Reuses the write-time classifier's node index (never
116
+ * writes). The agent calls it before memory_store (to copy a context_path) and
117
+ * before a tree-specific search. Universe permission is enforced server-side;
118
+ * an explicit universe_id the caller cannot read returns 403.
119
+ */
120
+ async findHierarchy(data) {
121
+ const payload = { query_text: data.query_text };
122
+ if (data.universe_id)
123
+ payload.universe_id = data.universe_id;
124
+ if (typeof data.limit === "number")
125
+ payload.limit = data.limit;
126
+ const response = await this.client.post("/graph/classify-preview", payload);
127
+ return response.data;
128
+ }
108
129
  async searchMemories(data) {
109
130
  const response = await this.client.post("/memories/search", {
110
131
  query_text: data.query_text,
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * tools/handlers.ts - Tool handlers (dispatch map)
12
12
  * types/index.ts - Shared interfaces and types
13
13
  */
14
+ import net from "node:net";
14
15
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
17
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
@@ -21,11 +22,34 @@ import { getResolvedClient, setResolvedClient } from "./client/client-info.js";
21
22
  import { InteractionCapture } from "./capture/interaction-capture.js";
22
23
  import { TOOLS } from "./tools/definitions.js";
23
24
  import { dispatchTool } from "./tools/handlers.js";
25
+ import { getSessionId, noteToolCall, reorientBanner, setSessionId } from "./tools/boot-state.js";
26
+ import { MCP_VERSION } from "./version.js";
27
+ // Irreducible boot rules injected into the client's system prompt via the MCP
28
+ // `initialize` handshake (Claude Code places server `instructions` there). These
29
+ // are the rules that MUST survive even if a single tool response is truncated or
30
+ // skimmed — the structural backstop behind the Boot Contract block.
31
+ const SERVER_INSTRUCTIONS = [
32
+ "Frinus MCP — regras de boot que nunca podem cair:",
33
+ "1. No início da sessão, chame agent_bootstrap(agent_id=...) e adote o \"Contrato de Boot\" no TOPO da resposta: identidade, ORGANIZAÇÃO (grave memórias só nela), usuário, session_id e a cadência de reorient. O system prompt longo vem depois do contrato.",
34
+ "2. Quando QUALQUER resposta de tool trouxer \"⚠️ REORIENT DEVIDO\", chame agent_reorient(agent_id=...) antes de continuar. O MCP conta as tool calls por você.",
35
+ "3. Confirme a organização do Contrato de Boot ANTES da primeira escrita de memória — nunca grave na org errada.",
36
+ "4. A sessão é gerenciada pelo MCP (iniciada no agent_bootstrap, encerrada no fim do processo). Não precisa fechá-la à mão.",
37
+ "Se o MCP/bootstrap estiver indisponível, avise \"MCP Frinus offline — capacidades reduzidas.\" e opere com cautela.",
38
+ ].join("\n");
24
39
  // Defaults point at the Frinus SaaS so `npx -y frinus-mcp` works with only an
25
40
  // API key. Local development overrides these via env (MEMORY_SERVICE_URL, etc.).
26
41
  const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "https://frinus-memory.rdxsec.com.br";
27
42
  const CP_URL = process.env.FRINUS_CP_URL || "https://frinus-api.rdxsec.com.br";
28
43
  const AGENT_SERVICE_URL = process.env.AGENT_SERVICE_URL || "https://frinus-agents.rdxsec.com.br";
44
+ // Give Happy Eyeballs (RFC 8305) a realistic deadline before giving up on an
45
+ // address family. Node's default auto-select-family attempt timeout is 250ms,
46
+ // which on a slow network (2026-10-27 incident) made the API-key validation
47
+ // fail with "Failed to validate API key: AggregateError" before the TCP
48
+ // handshake could complete. 5s per attempt matches how long a cold mobile/VPN
49
+ // link realistically needs. Guarded with typeof for older Node runtimes.
50
+ if (typeof net.setDefaultAutoSelectFamilyAttemptTimeout === "function") {
51
+ net.setDefaultAutoSelectFamilyAttemptTimeout(5000);
52
+ }
29
53
  // Main server
30
54
  async function main() {
31
55
  // Require API key (FRINUS_API_KEY preferred, FRINUS_MEMORY_API_KEY for backward compat)
@@ -40,35 +64,105 @@ async function main() {
40
64
  const cpClient = new CpClient(CP_URL, apiKey);
41
65
  const capture = new InteractionCapture(memoryClient);
42
66
  console.error(`[InteractionCapture] Session: ${capture.getSessionId()}`);
43
- // Validate API key and resolve tenant identity at startup
44
- try {
45
- const me = await memoryClient.getAuthMe();
46
- setResolvedUserEmail(me.email || me.user?.email || null);
47
- setResolvedUserId(me.id || me.user?.id || me.user_id || null);
48
- // Resolve tenant org ID from /auth/me response (env var takes precedence)
49
- const meOrgId = me.organization_id || me.org_id || me.user?.organization_id || null;
50
- if (!getResolvedTenantOrgId() && meOrgId) {
51
- setResolvedTenantOrgId(meOrgId);
52
- }
53
- console.error(`[Auth] API key validated. User: ${getResolvedUserEmail()}, Tenant: ${getResolvedTenantOrgId() || 'none'}`);
54
- }
55
- catch (err) {
56
- const status = err?.response?.status;
57
- if (status === 401 || status === 403) {
58
- console.error(`[FATAL] API key rejected (HTTP ${status}). Check FRINUS_API_KEY.`);
67
+ // Validate API key and resolve tenant identity at startup.
68
+ //
69
+ // Retry ONLY transient network failures (no HTTP response: DNS, connection
70
+ // refused/reset, timeout, AggregateError from Happy Eyeballs). A 401/403 is a
71
+ // real rejection and must fail fast — retrying a bad key is pointless. Backoff
72
+ // 1s then 3s between the 3 attempts. This, together with the auto-select-family
73
+ // timeout above, is the fix for the 2026-10-27 "Failed to validate API key:
74
+ // AggregateError" crashes on slow links.
75
+ const MAX_AUTH_ATTEMPTS = 3;
76
+ const AUTH_BACKOFFS_MS = [1000, 3000];
77
+ let me;
78
+ for (let attempt = 1;; attempt++) {
79
+ try {
80
+ me = await memoryClient.getAuthMe();
81
+ break;
59
82
  }
60
- else {
83
+ catch (err) {
84
+ const status = err?.response?.status;
85
+ if (status === 401 || status === 403) {
86
+ console.error(`[FATAL] API key rejected (HTTP ${status}). Check FRINUS_API_KEY.`);
87
+ process.exit(1);
88
+ }
89
+ // No HTTP response == network/transport error -> retryable.
90
+ const isNetworkError = status === undefined;
91
+ if (isNetworkError && attempt < MAX_AUTH_ATTEMPTS) {
92
+ const waitMs = AUTH_BACKOFFS_MS[attempt - 1];
93
+ console.error(`[Auth] Network error validating API key (attempt ${attempt}/${MAX_AUTH_ATTEMPTS}): ` +
94
+ `${err?.message || err}. Retrying in ${waitMs}ms...`);
95
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
96
+ continue;
97
+ }
61
98
  console.error(`[FATAL] Failed to validate API key: ${err?.message || err}`);
99
+ process.exit(1);
62
100
  }
63
- process.exit(1);
64
101
  }
102
+ setResolvedUserEmail(me.email || me.user?.email || null);
103
+ setResolvedUserId(me.id || me.user?.id || me.user_id || null);
104
+ // Resolve tenant org ID from /auth/me response (env var takes precedence)
105
+ const meOrgId = me.organization_id || me.org_id || me.user?.organization_id || null;
106
+ if (!getResolvedTenantOrgId() && meOrgId) {
107
+ setResolvedTenantOrgId(meOrgId);
108
+ }
109
+ console.error(`[Auth] API key validated. User: ${getResolvedUserEmail()}, Tenant: ${getResolvedTenantOrgId() || 'none'}`);
65
110
  const server = new Server({
66
111
  name: "frinus",
67
- version: "3.6.4",
112
+ version: MCP_VERSION,
68
113
  }, {
69
114
  capabilities: {
70
115
  tools: {},
71
116
  },
117
+ instructions: SERVER_INSTRUCTIONS,
118
+ });
119
+ // --- Session lifecycle: close on process exit ---------------------------
120
+ // The session is OPENED by agent_bootstrap (the MCP owns it, not the model).
121
+ // Here we guarantee it is CLOSED when the process goes away: Claude Code
122
+ // closing stdin, a SIGTERM/SIGINT, or the event loop draining. We run
123
+ // session_summary then session_end under a short timeout so a slow/unreachable
124
+ // Memory Engine can never hang shutdown. Idempotent via the `closing` guard.
125
+ let closing = false;
126
+ const SHUTDOWN_TIMEOUT_MS = 3000;
127
+ const closeSession = async (reason) => {
128
+ if (closing)
129
+ return;
130
+ closing = true;
131
+ const sid = getSessionId();
132
+ if (!sid)
133
+ return;
134
+ console.error(`[Session] Closing ${sid} (${reason})`);
135
+ const work = (async () => {
136
+ try {
137
+ await memoryClient.getSessionSummary(sid);
138
+ }
139
+ catch (err) {
140
+ console.error(`[Session] summary failed: ${err?.message || err}`);
141
+ }
142
+ try {
143
+ await memoryClient.endSession(sid);
144
+ }
145
+ catch (err) {
146
+ console.error(`[Session] end failed: ${err?.message || err}`);
147
+ }
148
+ })();
149
+ await Promise.race([
150
+ work,
151
+ new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
152
+ ]);
153
+ setSessionId(null);
154
+ };
155
+ const shutdownAndExit = (reason) => {
156
+ void closeSession(reason).finally(() => process.exit(0));
157
+ };
158
+ process.once("SIGTERM", () => shutdownAndExit("SIGTERM"));
159
+ process.once("SIGINT", () => shutdownAndExit("SIGINT"));
160
+ // stdin close = the MCP client (Claude Code) disconnected.
161
+ process.stdin.on("close", () => shutdownAndExit("stdin-close"));
162
+ // Last-resort backstop when the loop drains without a signal. Does not force
163
+ // exit (lets the natural exit proceed after a best-effort close).
164
+ process.once("beforeExit", () => {
165
+ void closeSession("beforeExit");
72
166
  });
73
167
  // List tools handler (all tools always visible; backend enforces auth)
74
168
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -94,6 +188,26 @@ async function main() {
94
188
  catch (err) {
95
189
  console.error('[MCP] Capture failed:', err.message);
96
190
  }
191
+ // Deterministic reorient cadence (see tools/boot-state.ts): count this call,
192
+ // then, if the agent is overdue, append the REORIENT DUE banner to the result
193
+ // so the model cannot miss it. Repeats on every call until agent_reorient
194
+ // resets the counter. Never throws into the response path.
195
+ try {
196
+ noteToolCall(name);
197
+ const banner = reorientBanner();
198
+ if (banner) {
199
+ const last = toolResult.content?.[toolResult.content.length - 1];
200
+ if (last && last.type === "text") {
201
+ last.text += banner;
202
+ }
203
+ else {
204
+ toolResult.content = [...(toolResult.content || []), { type: "text", text: banner.trimStart() }];
205
+ }
206
+ }
207
+ }
208
+ catch (err) {
209
+ console.error('[MCP] Reorient cadence check failed:', err?.message || err);
210
+ }
97
211
  return toolResult;
98
212
  });
99
213
  // Resolve the connecting MCP client's identity for the X-Client header.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Platform notifications delivered through the MCP channel.
3
+ *
4
+ * Clients rarely come back to the web dashboard after the initial integration,
5
+ * so the MCP session itself becomes the way to reach them. This module fetches
6
+ * the Control Plane inbox and renders a compact markdown block that is appended
7
+ * to `agent_bootstrap` (once, forced) and `heartbeat_tick` (throttled).
8
+ *
9
+ * Design rules:
10
+ * - NEVER throws. A 404, timeout or any network error yields '' plus a stderr
11
+ * log. The agent's real work must never be blocked by the news channel.
12
+ * - In-process session state, per MCP process (= per session): WHEN each item
13
+ * was last shown, and when we last hit the inbox. Dedupe is a sliding WINDOW,
14
+ * not "forever": a long-lived process re-presents an un-acked item once the
15
+ * reprompt window elapses, so an ignored notice resurfaces on a later
16
+ * heartbeat instead of vanishing for the whole session. The server still caps
17
+ * total deliveries (counted at most once/day per user), so this cannot spam.
18
+ * - Env toggles: FRINUS_NOTIFICATIONS=off disables everything;
19
+ * FRINUS_NOTIFICATIONS_INTERVAL_MIN overrides the FETCH throttle (default 60);
20
+ * FRINUS_NOTIFICATIONS_REPROMPT_MIN overrides how long an already-shown item
21
+ * stays suppressed before it may be re-presented (default 1440 = 24h).
22
+ */
23
+ import type { CpClientInterface, NotificationItem } from "./types/index.js";
24
+ /** Reset module state. Test-only; not part of the runtime surface. */
25
+ export declare function __resetNotificationsState(): void;
26
+ export interface FetchBlockOptions {
27
+ /** Bypass the throttle window (used by agent_bootstrap). */
28
+ force?: boolean;
29
+ }
30
+ /**
31
+ * Fetch the inbox and render the "Novidades do Frinus" block, or '' when there
32
+ * is nothing new to show (disabled, throttled, empty, already-shown, or any
33
+ * failure). Guaranteed not to throw.
34
+ */
35
+ export declare function fetchNotificationsBlock(cpClient: CpClientInterface, opts?: FetchBlockOptions): Promise<string>;
36
+ /**
37
+ * Render the markdown block the agent relays to the user. Exported so another
38
+ * frente can insert it at the TOP of the boot contract (the preferred place —
39
+ * appended at the end, a ~15k-token bootstrap buries it and the model skips it).
40
+ *
41
+ * The block is self-contained: it tells the agent to relay briefly, to ack on
42
+ * the user's confirmation, and — for `critical` items — to relay IMMEDIATELY.
43
+ */
44
+ export declare function renderBlock(items: NotificationItem[]): string;
@@ -0,0 +1,134 @@
1
+ /** Max characters of a notification body rendered inline. */
2
+ const BODY_LIMIT = 600;
3
+ /** Default FETCH throttle window for non-forced checks (minutes). */
4
+ const DEFAULT_INTERVAL_MIN = 60;
5
+ /** Default reprompt window: how long a shown item stays suppressed (minutes). */
6
+ const DEFAULT_REPROMPT_MIN = 1440; // 24h — aligns with the server's per-day delivery count.
7
+ // ---------------------------------------------------------------------------
8
+ // In-process session state
9
+ // ---------------------------------------------------------------------------
10
+ /**
11
+ * Epoch ms each notification id was last SHOWN in this session. Used for a
12
+ * windowed dedupe: an item is re-presentable once `now - shownAt > reprompt`.
13
+ */
14
+ const shownAt = new Map();
15
+ /** Epoch ms of the last inbox check (forced or throttled). 0 = never. */
16
+ let lastCheckAt = 0;
17
+ /** Reset module state. Test-only; not part of the runtime surface. */
18
+ export function __resetNotificationsState() {
19
+ shownAt.clear();
20
+ lastCheckAt = 0;
21
+ }
22
+ function isDisabled() {
23
+ return (process.env.FRINUS_NOTIFICATIONS || "").trim().toLowerCase() === "off";
24
+ }
25
+ function throttleMs() {
26
+ const raw = process.env.FRINUS_NOTIFICATIONS_INTERVAL_MIN;
27
+ const parsed = raw !== undefined ? Number.parseInt(raw, 10) : NaN;
28
+ const minutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_INTERVAL_MIN;
29
+ return minutes * 60_000;
30
+ }
31
+ function repromptMs() {
32
+ const raw = process.env.FRINUS_NOTIFICATIONS_REPROMPT_MIN;
33
+ const parsed = raw !== undefined ? Number.parseInt(raw, 10) : NaN;
34
+ const minutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_REPROMPT_MIN;
35
+ return minutes * 60_000;
36
+ }
37
+ /**
38
+ * Fetch the inbox and render the "Novidades do Frinus" block, or '' when there
39
+ * is nothing new to show (disabled, throttled, empty, already-shown, or any
40
+ * failure). Guaranteed not to throw.
41
+ */
42
+ export async function fetchNotificationsBlock(cpClient, opts = {}) {
43
+ if (isDisabled())
44
+ return "";
45
+ const now = Date.now();
46
+ if (!opts.force && now - lastCheckAt < throttleMs()) {
47
+ return "";
48
+ }
49
+ lastCheckAt = now;
50
+ let inbox;
51
+ try {
52
+ inbox = await cpClient.getNotificationInbox({
53
+ channel: "mcp",
54
+ limit: 3,
55
+ markDelivered: true,
56
+ });
57
+ }
58
+ catch (err) {
59
+ // 404 (CP not yet deployed), timeout, DNS, connection refused — all benign
60
+ // here. Swallow and keep working; log to stderr for operators.
61
+ console.error(`[Notifications] inbox fetch failed: ${err?.message || err}`);
62
+ return "";
63
+ }
64
+ const now2 = Date.now();
65
+ const window = repromptMs();
66
+ const fresh = (inbox?.items || []).filter((it) => {
67
+ if (!it || typeof it.id !== "string")
68
+ return false;
69
+ const last = shownAt.get(it.id);
70
+ // Unseen this session, or last shown long enough ago to re-present.
71
+ return last === undefined || now2 - last >= window;
72
+ });
73
+ if (fresh.length === 0)
74
+ return "";
75
+ for (const it of fresh)
76
+ shownAt.set(it.id, now2);
77
+ return renderBlock(fresh);
78
+ }
79
+ /** Human label for a notification scope, used in the rendered header. */
80
+ function scopeLabel(scope) {
81
+ if (scope === "organization")
82
+ return "Organização";
83
+ if (scope === "system")
84
+ return "Sistema";
85
+ return "Sistema"; // default/unknown: treat as a platform notice.
86
+ }
87
+ /**
88
+ * Render the markdown block the agent relays to the user. Exported so another
89
+ * frente can insert it at the TOP of the boot contract (the preferred place —
90
+ * appended at the end, a ~15k-token bootstrap buries it and the model skips it).
91
+ *
92
+ * The block is self-contained: it tells the agent to relay briefly, to ack on
93
+ * the user's confirmation, and — for `critical` items — to relay IMMEDIATELY.
94
+ */
95
+ export function renderBlock(items) {
96
+ const hasCritical = items.some((it) => it.severity === "critical");
97
+ let out = `## 📣 Novidades do Frinus\n\n`;
98
+ for (const it of items) {
99
+ const label = scopeLabel(it.scope);
100
+ const sev = it.severity ? ` · _${it.severity}_` : "";
101
+ out += `### [${label}] ${it.title}${sev}\n`;
102
+ if (it.severity === "critical") {
103
+ out += `> ⚠️ CRÍTICO — repasse ao usuário IMEDIATAMENTE, antes de seguir com a tarefa.\n`;
104
+ }
105
+ let body = (it.body || "").trim();
106
+ if (body.length > BODY_LIMIT) {
107
+ body = body.slice(0, BODY_LIMIT).trimEnd() + "…";
108
+ }
109
+ if (body)
110
+ out += `${body}\n`;
111
+ if (it.cta_label && it.cta_url) {
112
+ out += `\n[${it.cta_label}](${it.cta_url})\n`;
113
+ }
114
+ else if (it.cta_url) {
115
+ out += `\n${it.cta_url}\n`;
116
+ }
117
+ out += `\n_id: ${it.id}_\n\n`;
118
+ }
119
+ out += `---\n`;
120
+ if (hasCritical) {
121
+ out +=
122
+ `Há um aviso CRÍTICO acima: repasse-o ao usuário IMEDIATAMENTE, no início ` +
123
+ `da sua próxima resposta. `;
124
+ }
125
+ else {
126
+ out += `Avise o usuário destas novidades de forma breve (1–3 linhas cada) no ` +
127
+ `início da sua próxima resposta, sem interromper a tarefa. `;
128
+ }
129
+ out +=
130
+ `Avisos marcados [Sistema] vêm do Frinus; [Organização] vêm da própria ` +
131
+ `organização do usuário. Quando ele confirmar ciência ou pedir para não ` +
132
+ `ver mais, chame notification_ack(id).\n`;
133
+ return out;
134
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Boot-contract state for the MCP process (one instance per stdio session).
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * The thin-client boot protocol used to depend entirely on the model
7
+ * remembering to call `agent_reorient` "every N answers". Models forget. This
8
+ * module makes the reorient cadence DETERMINISTIC and server-side (here, inside
9
+ * the MCP process) instead of a hope:
10
+ *
11
+ * - `agent_bootstrap` calls `markBooted()` with the agent's reorient config
12
+ * (read from the authoritative Control Plane row via the bootstrap payload).
13
+ * - Every subsequent tool call increments a counter (`noteToolCall`).
14
+ * - Once the counter reaches `reorientEveryN`, `reorientBanner()` returns a
15
+ * "REORIENT DUE" notice that `index.ts` appends to EVERY tool result until
16
+ * `agent_reorient` is actually called, which resets the counter.
17
+ *
18
+ * The context-percentage trigger (`reorientOnContextPct`) cannot be enforced
19
+ * here — the MCP has no visibility into the model's token window — so it stays a
20
+ * model-side heuristic documented in the Boot Contract and the server
21
+ * `instructions`. The N-count is the half we CAN guarantee, and we guarantee it.
22
+ *
23
+ * State is per-process (= per session). Nothing here is persisted.
24
+ */
25
+ /** Reorientation cadence knobs, mirrored from `org_agents` via the bootstrap. */
26
+ export interface ReorientConfig {
27
+ reorientEveryN: number;
28
+ reorientOnContextPct: number;
29
+ }
30
+ /** Mark the process as booted and (re)seed the reorient cadence + session id. */
31
+ export declare function markBooted(opts: {
32
+ agentId: string;
33
+ sessionId?: string | null;
34
+ config?: Partial<ReorientConfig>;
35
+ }): void;
36
+ /** Reset the cadence counter (called when the agent actually reorients). */
37
+ export declare function resetReorientCounter(): void;
38
+ /**
39
+ * Record a tool call. Exempt tools do not increment; `agent_reorient` resets.
40
+ * No-op until the process has booted, so pre-boot probing never arms the banner.
41
+ */
42
+ export declare function noteToolCall(name: string): void;
43
+ /** True once at least `reorientEveryN` counted tool calls have elapsed. */
44
+ export declare function reorientDue(): boolean;
45
+ /**
46
+ * The "REORIENT DUE" banner, or null when not due. Deliberately imperative and
47
+ * self-explaining so it survives even if the model ignored the Boot Contract.
48
+ */
49
+ export declare function reorientBanner(): string | null;
50
+ export declare function getBootAgentId(): string | null;
51
+ export declare function getSessionId(): string | null;
52
+ export declare function setSessionId(id: string | null): void;
53
+ export declare function isBooted(): boolean;
54
+ export declare function getReorientConfig(): ReorientConfig;
55
+ /** Test-only: reset all module state. Not part of the runtime surface. */
56
+ export declare function __resetBootState(): void;