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 +1 -1
- package/dist/client/cp-client.d.ts +14 -1
- package/dist/client/cp-client.js +36 -0
- package/dist/client/memory-client.d.ts +13 -0
- package/dist/client/memory-client.js +21 -0
- package/dist/index.js +133 -19
- package/dist/notifications.d.ts +44 -0
- package/dist/notifications.js +134 -0
- package/dist/tools/boot-state.d.ts +56 -0
- package/dist/tools/boot-state.js +115 -0
- package/dist/tools/definitions.js +168 -7
- package/dist/tools/handlers.js +302 -19
- package/dist/types/index.d.ts +48 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.js +9 -0
- package/manifest.json +2 -2
- package/package.json +1 -1
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.
|
|
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>;
|
package/dist/client/cp-client.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|