frinus-mcp 3.7.0 → 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, NotificationInbox } 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);
@@ -19,6 +19,13 @@ export declare class CpClient implements CpClientInterface {
19
19
  markDelivered?: boolean;
20
20
  }): Promise<NotificationInbox>;
21
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>;
22
29
  storeCredential(integrationRef: string, data: Record<string, unknown>, metadata?: Record<string, unknown>): Promise<any>;
23
30
  getCredential(integrationRef: string): Promise<any>;
24
31
  listCredentials(): Promise<any>;
@@ -62,6 +62,16 @@ export class CpClient {
62
62
  timeout: NOTIFICATION_TIMEOUT_MS,
63
63
  });
64
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
+ }
65
75
  // =========================================================================
66
76
  // Credential Vault
67
77
  // =========================================================================
@@ -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
@@ -22,6 +22,20 @@ import { getResolvedClient, setResolvedClient } from "./client/client-info.js";
22
22
  import { InteractionCapture } from "./capture/interaction-capture.js";
23
23
  import { TOOLS } from "./tools/definitions.js";
24
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");
25
39
  // Defaults point at the Frinus SaaS so `npx -y frinus-mcp` works with only an
26
40
  // API key. Local development overrides these via env (MEMORY_SERVICE_URL, etc.).
27
41
  const MEMORY_SERVICE_URL = process.env.MEMORY_SERVICE_URL || "https://frinus-memory.rdxsec.com.br";
@@ -95,11 +109,60 @@ async function main() {
95
109
  console.error(`[Auth] API key validated. User: ${getResolvedUserEmail()}, Tenant: ${getResolvedTenantOrgId() || 'none'}`);
96
110
  const server = new Server({
97
111
  name: "frinus",
98
- version: "3.7.0",
112
+ version: MCP_VERSION,
99
113
  }, {
100
114
  capabilities: {
101
115
  tools: {},
102
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");
103
166
  });
104
167
  // List tools handler (all tools always visible; backend enforces auth)
105
168
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -125,6 +188,26 @@ async function main() {
125
188
  catch (err) {
126
189
  console.error('[MCP] Capture failed:', err.message);
127
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
+ }
128
211
  return toolResult;
129
212
  });
130
213
  // Resolve the connecting MCP client's identity for the X-Client header.
@@ -9,13 +9,18 @@
9
9
  * Design rules:
10
10
  * - NEVER throws. A 404, timeout or any network error yields '' plus a stderr
11
11
  * log. The agent's real work must never be blocked by the news channel.
12
- * - In-process state, per MCP process (= per session): which notification ids
13
- * were already shown, and when we last hit the inbox. A long-lived process
14
- * therefore shows each item at most once and polls at most once per window.
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.
15
18
  * - Env toggles: FRINUS_NOTIFICATIONS=off disables everything;
16
- * FRINUS_NOTIFICATIONS_INTERVAL_MIN overrides the throttle window (default 60).
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).
17
22
  */
18
- import type { CpClientInterface } from "./types/index.js";
23
+ import type { CpClientInterface, NotificationItem } from "./types/index.js";
19
24
  /** Reset module state. Test-only; not part of the runtime surface. */
20
25
  export declare function __resetNotificationsState(): void;
21
26
  export interface FetchBlockOptions {
@@ -28,3 +33,12 @@ export interface FetchBlockOptions {
28
33
  * failure). Guaranteed not to throw.
29
34
  */
30
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;
@@ -1,17 +1,22 @@
1
1
  /** Max characters of a notification body rendered inline. */
2
2
  const BODY_LIMIT = 600;
3
- /** Default throttle window for non-forced checks (minutes). */
3
+ /** Default FETCH throttle window for non-forced checks (minutes). */
4
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.
5
7
  // ---------------------------------------------------------------------------
6
8
  // In-process session state
7
9
  // ---------------------------------------------------------------------------
8
- /** Notification ids already shown in this session (dedupe across bootstrap + ticks). */
9
- const shownIds = new Set();
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();
10
15
  /** Epoch ms of the last inbox check (forced or throttled). 0 = never. */
11
16
  let lastCheckAt = 0;
12
17
  /** Reset module state. Test-only; not part of the runtime surface. */
13
18
  export function __resetNotificationsState() {
14
- shownIds.clear();
19
+ shownAt.clear();
15
20
  lastCheckAt = 0;
16
21
  }
17
22
  function isDisabled() {
@@ -23,6 +28,12 @@ function throttleMs() {
23
28
  const minutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_INTERVAL_MIN;
24
29
  return minutes * 60_000;
25
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
+ }
26
37
  /**
27
38
  * Fetch the inbox and render the "Novidades do Frinus" block, or '' when there
28
39
  * is nothing new to show (disabled, throttled, empty, already-shown, or any
@@ -50,19 +61,47 @@ export async function fetchNotificationsBlock(cpClient, opts = {}) {
50
61
  console.error(`[Notifications] inbox fetch failed: ${err?.message || err}`);
51
62
  return "";
52
63
  }
53
- const fresh = (inbox?.items || []).filter((it) => !!it && typeof it.id === "string" && !shownIds.has(it.id));
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
+ });
54
73
  if (fresh.length === 0)
55
74
  return "";
56
75
  for (const it of fresh)
57
- shownIds.add(it.id);
76
+ shownAt.set(it.id, now2);
58
77
  return renderBlock(fresh);
59
78
  }
60
- /** Render the markdown block the agent relays to the user. */
61
- function renderBlock(items) {
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");
62
97
  let out = `## 📣 Novidades do Frinus\n\n`;
63
98
  for (const it of items) {
64
- const sev = it.severity ? ` _(${it.severity})_` : "";
65
- out += `### ${it.title}${sev}\n`;
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
+ }
66
105
  let body = (it.body || "").trim();
67
106
  if (body.length > BODY_LIMIT) {
68
107
  body = body.slice(0, BODY_LIMIT).trimEnd() + "…";
@@ -78,9 +117,18 @@ function renderBlock(items) {
78
117
  out += `\n_id: ${it.id}_\n\n`;
79
118
  }
80
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
+ }
81
129
  out +=
82
- `Avise o usuário destas novidades de forma breve (1–3 linhas cada) no ` +
83
- `início da sua próxima resposta, sem interromper a tarefa. Quando ele ` +
84
- `confirmar ciência ou pedir para não ver mais, chame notification_ack(id).\n`;
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`;
85
133
  return out;
86
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;
@@ -0,0 +1,115 @@
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
+ const DEFAULT_CONFIG = {
26
+ reorientEveryN: 10,
27
+ reorientOnContextPct: 70,
28
+ };
29
+ let config = { ...DEFAULT_CONFIG };
30
+ let booted = false;
31
+ let bootAgentId = null;
32
+ let sessionId = null;
33
+ let toolCallsSinceReorient = 0;
34
+ /**
35
+ * Tools that must never count toward the cadence: `agent_bootstrap` seeds it and
36
+ * `agent_reorient` IS the reorientation (and resets the counter).
37
+ */
38
+ const EXEMPT_TOOLS = new Set(["agent_bootstrap", "agent_reorient"]);
39
+ /** Mark the process as booted and (re)seed the reorient cadence + session id. */
40
+ export function markBooted(opts) {
41
+ booted = true;
42
+ bootAgentId = opts.agentId;
43
+ if (opts.sessionId)
44
+ sessionId = opts.sessionId;
45
+ if (opts.config) {
46
+ const n = opts.config.reorientEveryN;
47
+ if (typeof n === "number" && Number.isFinite(n) && n >= 1) {
48
+ config.reorientEveryN = Math.floor(n);
49
+ }
50
+ const pct = opts.config.reorientOnContextPct;
51
+ if (typeof pct === "number" && Number.isFinite(pct) && pct >= 1 && pct <= 100) {
52
+ config.reorientOnContextPct = Math.floor(pct);
53
+ }
54
+ }
55
+ toolCallsSinceReorient = 0;
56
+ }
57
+ /** Reset the cadence counter (called when the agent actually reorients). */
58
+ export function resetReorientCounter() {
59
+ toolCallsSinceReorient = 0;
60
+ }
61
+ /**
62
+ * Record a tool call. Exempt tools do not increment; `agent_reorient` resets.
63
+ * No-op until the process has booted, so pre-boot probing never arms the banner.
64
+ */
65
+ export function noteToolCall(name) {
66
+ if (!booted)
67
+ return;
68
+ if (EXEMPT_TOOLS.has(name)) {
69
+ if (name === "agent_reorient")
70
+ resetReorientCounter();
71
+ return;
72
+ }
73
+ toolCallsSinceReorient += 1;
74
+ }
75
+ /** True once at least `reorientEveryN` counted tool calls have elapsed. */
76
+ export function reorientDue() {
77
+ return booted && toolCallsSinceReorient >= config.reorientEveryN;
78
+ }
79
+ /**
80
+ * The "REORIENT DUE" banner, or null when not due. Deliberately imperative and
81
+ * self-explaining so it survives even if the model ignored the Boot Contract.
82
+ */
83
+ export function reorientBanner() {
84
+ if (!reorientDue())
85
+ return null;
86
+ const id = bootAgentId ?? "<agent_id>";
87
+ return (`\n\n---\n` +
88
+ `⚠️ REORIENT DEVIDO: ${toolCallsSinceReorient} tool calls desde o último ` +
89
+ `boot/reorient (limite ${config.reorientEveryN}). Chame ` +
90
+ `\`agent_reorient(agent_id="${id}")\` agora para se re-fundamentar antes de ` +
91
+ `continuar. Este aviso repete a cada tool call até você reorientar.`);
92
+ }
93
+ export function getBootAgentId() {
94
+ return bootAgentId;
95
+ }
96
+ export function getSessionId() {
97
+ return sessionId;
98
+ }
99
+ export function setSessionId(id) {
100
+ sessionId = id;
101
+ }
102
+ export function isBooted() {
103
+ return booted;
104
+ }
105
+ export function getReorientConfig() {
106
+ return { ...config };
107
+ }
108
+ /** Test-only: reset all module state. Not part of the runtime surface. */
109
+ export function __resetBootState() {
110
+ config = { ...DEFAULT_CONFIG };
111
+ booted = false;
112
+ bootAgentId = null;
113
+ sessionId = null;
114
+ toolCallsSinceReorient = 0;
115
+ }
@@ -9,6 +9,19 @@ export const TOOLS = [
9
9
  Use this to record important information, learnings, decisions, or procedures
10
10
  that should be remembered for future reference.
11
11
 
12
+ ANCHORING (do this first): memory lives in a tree — Universe > Concept > Theme >
13
+ Topic > Point. Before storing, call hierarchy_find("<subject>") and copy the best
14
+ candidate's context_path (BY NAME, e.g. "Plataforma Frinus > Memory Engine") into
15
+ context_path below. The path raises the classifier's confidence and anchors the
16
+ memory on the right node. Never invent a UUID — you get names right, UUIDs wrong.
17
+
18
+ PRECEDENCE of the placement arguments (highest first):
19
+ context_id (UUID) > context_path (name) > universe_id > the agent's own
20
+ universe > auto-classification > the org's "Geral" (floor, never NULL).
21
+ A context_path that is ambiguous or whose segments don't all match never creates
22
+ a node and never fails the write: it anchors to the deepest segment that matched
23
+ and the response carries a non-fatal context_path_warning saying so.
24
+
12
25
  The backend classifies the memory by content at write time and proposes which
13
26
  universe/context node it belongs to. When the tenant has auto-classification
14
27
  enabled and the proposal's confidence meets the tenant threshold (the platform
@@ -17,11 +30,11 @@ is moved without review. Below the threshold (or with auto-classification off)
17
30
  the proposal stays a PENDING suggestion: review it with
18
31
  memory_classification_pending and apply with memory_classification_approve.
19
32
 
20
- Passing universe_id or context_id anchors the memory THERE directly (status
21
- "manual"): auto-classification is skipped entirely, so no suggestion is returned
22
- and nothing may later move it. The response always reports the destination the
23
- memory actually landed in — a suggestion that is still pending is labelled as
24
- such and has NOT been applied.
33
+ Passing context_path, universe_id or context_id anchors the memory accordingly
34
+ (status "manual" for UUIDs): auto-classification is skipped and nothing may later
35
+ move it. The response always reports the destination the memory actually landed
36
+ in — a suggestion that is still pending is labelled as such and has NOT been
37
+ applied.
25
38
 
26
39
  Memory types:
27
40
  - episodic: Specific experiences and events (what happened)
@@ -88,7 +101,11 @@ Scopes:
88
101
  },
89
102
  context_id: {
90
103
  type: "string",
91
- description: "Optional UUID of a hierarchy node (universe, concept, theme, topic, or point). When provided, the memory is automatically linked to this node and all its ancestors. Takes precedence over universe_id for hierarchy placement.",
104
+ description: "Optional UUID of a hierarchy node (universe, concept, theme, topic, or point). When provided, the memory is automatically linked to this node and all its ancestors. Highest-precedence placement argument (above context_path and universe_id).",
105
+ },
106
+ context_path: {
107
+ type: "string",
108
+ description: "Anchor the memory BY NAME with a breadcrumb like 'Universe > Concept > Theme' (separators /, > or › all work). Call hierarchy_find first and copy the best candidate's context_path here — this is the preferred way to place a memory. The backend resolves names to a node deterministically (never creates nodes). Takes precedence over universe_id, but not over context_id. If a segment is ambiguous or unknown the write still succeeds, anchors to the deepest matched segment, and returns a context_path_warning.",
92
109
  },
93
110
  credential_ref: {
94
111
  type: "string",
@@ -98,6 +115,43 @@ Scopes:
98
115
  required: ["agent_id", "content"],
99
116
  },
100
117
  },
118
+ {
119
+ name: "hierarchy_find",
120
+ description: `Find where something belongs in the memory tree (Universe >
121
+ Concept > Theme > Topic > Point).
122
+
123
+ Read-only: it never writes a memory or a node. It runs the same classifier the
124
+ backend uses at write time and returns the top candidate nodes for your query,
125
+ each with its full path BY NAME and a confidence in [0,1].
126
+
127
+ Call it in two moments:
128
+ - BEFORE memory_store — copy the best candidate's context_path (by name) into
129
+ memory_store(context_path=...) so the memory anchors on the right node and
130
+ clears the 0.60 auto-anchor threshold instead of landing in the review queue.
131
+ - BEFORE a tree-specific search — to confirm which branch to look in.
132
+
133
+ Copy the context_path (the names), never the context_id (UUID). Universe
134
+ permission is enforced: a restricted caller only sees candidates in universes it
135
+ may read, and an explicit universe_id it cannot read is rejected (403).`,
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: {
139
+ query_text: {
140
+ type: "string",
141
+ description: "What to situate in the tree — the subject of the memory you are about to store, or the search you are about to run.",
142
+ },
143
+ universe_id: {
144
+ type: "string",
145
+ description: "Optional UUID to restrict candidates to one universe (must be one you may read). Omit to search every universe you can access.",
146
+ },
147
+ limit: {
148
+ type: "integer",
149
+ description: "Max candidates to return (default 5, max 25).",
150
+ },
151
+ },
152
+ required: ["query_text"],
153
+ },
154
+ },
101
155
  {
102
156
  name: "memory_search",
103
157
  description: `Search memories by semantic similarity.
@@ -860,7 +914,11 @@ Memories created by the agent are NOT deleted.`,
860
914
  name: "agent_bootstrap",
861
915
  description: `Bootstrap completo do agente — chame no início da sessão para carregar identidade, system prompt e contexto runtime. O agente thin-client chama isto como regra única de boot.
862
916
 
863
- Retorna identity, system_prompt, runtime (working_memory, recent_memories, skills, delegate_digest) e persona_version em uma única chamada.
917
+ A resposta começa por um "Contrato de Boot" no TOPO com o que não pode se perder: identidade (id), organização (grave memórias só nela), usuário, versão do MCP, session_id, persona_version e a cadência de reorient. O system prompt longo vem depois.
918
+
919
+ Efeitos colaterais: inicia a sessão de memória (session_id devolvido no contrato; o MCP a encerra no fim do processo) e arma a cadência de reorient — a partir daqui o MCP conta as tool calls e anexa um aviso "REORIENT DEVIDO" quando vencer.
920
+
921
+ Retorna identity, system_prompt, runtime (working_memory, recent_memories, skills, delegate_digest), config (reorient_every_n, reorient_on_context_pct, max_memories) e persona_version.
864
922
 
865
923
  O roster de especialistas NÃO vem no bootstrap — apenas um resumo (quantos e em quais universos). Use \`agent_list\` no momento da delegação para obter nomes e IDs.`,
866
924
  inputSchema: {
@@ -1856,15 +1914,16 @@ granted it and when. No secret data.`,
1856
1914
  },
1857
1915
  },
1858
1916
  // ==========================================================================
1859
- // Platform Notifications (2)
1917
+ // Platform Notifications (3)
1860
1918
  // ==========================================================================
1861
1919
  {
1862
1920
  name: "notifications_list",
1863
- description: `List pending Frinus platform notifications for the current user.
1921
+ description: `List pending Frinus notifications for the current user.
1864
1922
 
1865
- These are platform-authored announcements: new features, maintenance windows,
1866
- tips and alerts. This is a READ-ONLY peek — it does not mark anything as
1867
- delivered or acknowledged, so items stay in the inbox until the user sees them.
1923
+ Two scopes appear here: (Sistema) = platform-authored (new features, maintenance,
1924
+ tips, alerts), and (Organização) = authored by an owner of the user's own org.
1925
+ This is a READ-ONLY peek — it does not mark anything as delivered or acknowledged,
1926
+ so items stay in the inbox until the user sees them.
1868
1927
 
1869
1928
  Each item includes an \`id\` you can pass to notification_ack once the user has
1870
1929
  read it. Use this when the user asks "what's new" or to re-check news that was
@@ -1894,4 +1953,67 @@ Frinus" block or from notifications_list.`,
1894
1953
  required: ["id"],
1895
1954
  },
1896
1955
  },
1956
+ {
1957
+ name: "notification_create",
1958
+ description: `Publish an ORGANIZATION notification to the members of your org (OWNER only).
1959
+
1960
+ Use this when an org owner wants to broadcast something to their team through the
1961
+ Frinus channel: a process change, a reminder, a heads-up. The notice is delivered
1962
+ on each member's next agent_bootstrap / heartbeat and leaves their inbox when they
1963
+ acknowledge it (or after max_deliveries days of being shown-and-ignored).
1964
+
1965
+ Scope is forced server-side: the notice is bound to YOUR org and cannot target
1966
+ other orgs (that is a platform_admin "system" notice, created elsewhere). Optional
1967
+ targeting narrows WITHIN your org by role and/or specific users. Requires owner
1968
+ role — a 403 means the account is not an owner. Use severity "critical" only for
1969
+ things the member must see immediately (it is relayed before other work).`,
1970
+ inputSchema: {
1971
+ type: "object",
1972
+ properties: {
1973
+ title: { type: "string", description: "Short headline (required, 1–200 chars)" },
1974
+ body: {
1975
+ type: "string",
1976
+ description: "Message body, markdown allowed (optional, up to 2000 chars)",
1977
+ },
1978
+ kind: {
1979
+ type: "string",
1980
+ enum: ["feature", "announcement", "maintenance", "tip", "alert"],
1981
+ description: "Category (default 'announcement')",
1982
+ },
1983
+ severity: {
1984
+ type: "string",
1985
+ enum: ["info", "success", "warning", "critical"],
1986
+ description: "Visual/priority level (default 'info'; 'critical' is relayed immediately)",
1987
+ },
1988
+ target_roles: {
1989
+ type: "array",
1990
+ items: { type: "string" },
1991
+ description: "Optional: only members with one of these roles (e.g. ['trainer']). Empty = all members.",
1992
+ },
1993
+ target_user_keys: {
1994
+ type: "array",
1995
+ items: { type: "string" },
1996
+ description: "Optional: only these specific users (user id/sub or email). Empty = all members.",
1997
+ },
1998
+ cta_label: { type: "string", description: "Optional call-to-action label" },
1999
+ cta_url: {
2000
+ type: "string",
2001
+ description: "Optional call-to-action URL (must be https://)",
2002
+ },
2003
+ starts_at: {
2004
+ type: "string",
2005
+ description: "Optional ISO-8601 start time (default: now)",
2006
+ },
2007
+ expires_at: {
2008
+ type: "string",
2009
+ description: "Optional ISO-8601 expiry time (default: never)",
2010
+ },
2011
+ max_deliveries: {
2012
+ type: "number",
2013
+ description: "How many days an unseen notice is shown before it drops off (default 3, 1–100)",
2014
+ },
2015
+ },
2016
+ required: ["title"],
2017
+ },
2018
+ },
1897
2019
  ];
@@ -8,6 +8,8 @@ import axios from "axios";
8
8
  import { spawn } from "node:child_process";
9
9
  import { getResolvedTenantOrgId } from "../client/memory-client.js";
10
10
  import { fetchNotificationsBlock } from "../notifications.js";
11
+ import { MCP_VERSION } from "../version.js";
12
+ import { getSessionId, markBooted, resetReorientCounter } from "./boot-state.js";
11
13
  // ---------------------------------------------------------------------------
12
14
  // Credential delivery — server-side broker, never by value
13
15
  // ---------------------------------------------------------------------------
@@ -330,6 +332,12 @@ function renderStoreResult(result) {
330
332
  const id = result?.id;
331
333
  const status = result?.classification_status;
332
334
  const head = `✓ Salva`;
335
+ // Non-fatal note when a supplied context_path did not fully resolve by name
336
+ // (ambiguous or missing segment). The memory WAS stored; this is appended to
337
+ // whatever destination line we render below so the caller can fix the path.
338
+ const warning = typeof result?.context_path_warning === "string" && result.context_path_warning
339
+ ? `\n ⚠ context_path: ${result.context_path_warning}`
340
+ : "";
333
341
  const applied = appliedLabel(result);
334
342
  const pending = status === "proposed" || status === "universe_only";
335
343
  // Anchored: a real destination exists. Report it, and only then mention a
@@ -342,7 +350,7 @@ function renderStoreResult(result) {
342
350
  out += `\n ↳ sugestão pendente (NÃO aplicada): ${sug} — revise com memory_classification_pending`;
343
351
  }
344
352
  }
345
- return out;
353
+ return out + warning;
346
354
  }
347
355
  // Not anchored anywhere: the suggestion is all we have to offer.
348
356
  if (pending) {
@@ -350,12 +358,12 @@ function renderStoreResult(result) {
350
358
  const conf = confidenceLabel(result?.classification_confidence);
351
359
  if (sug) {
352
360
  const confTxt = conf ? `, confiança ${conf}` : "";
353
- return `${head} · sem destino aplicado · sugestão: ${sug} (auto${confTxt}) — aprove com memory_classification_approve`;
361
+ return `${head} · sem destino aplicado · sugestão: ${sug} (auto${confTxt}) — aprove com memory_classification_approve` + warning;
354
362
  }
355
- return `${head} · sem destino aplicado e sem sugestão confiável — use memory_classification_pending para revisar`;
363
+ return `${head} · sem destino aplicado e sem sugestão confiável — use memory_classification_pending para revisar` + warning;
356
364
  }
357
365
  // Defensive: older backend without classification fields, or no hierarchy at all.
358
- return `${head}\nID: ${id}\nType: ${result?.memory_type}\nScope: ${result?.scope}`;
366
+ return `${head}\nID: ${id}\nType: ${result?.memory_type}\nScope: ${result?.scope}` + warning;
359
367
  }
360
368
  const handlers = {
361
369
  // ==========================================================================
@@ -387,6 +395,7 @@ const handlers = {
387
395
  created_by_user_id: createdByUser,
388
396
  metadata: args.metadata,
389
397
  context_id: args.context_id,
398
+ context_path: args.context_path,
390
399
  credential_ref: args.credential_ref,
391
400
  universe_id: universeId,
392
401
  scope: args.scope,
@@ -400,6 +409,43 @@ const handlers = {
400
409
  throw error;
401
410
  }
402
411
  },
412
+ async hierarchy_find(args, { memoryClient }) {
413
+ let result;
414
+ try {
415
+ result = await memoryClient.findHierarchy({
416
+ query_text: args.query_text,
417
+ universe_id: args.universe_id,
418
+ limit: args.limit,
419
+ });
420
+ }
421
+ catch (error) {
422
+ const apiErr = handleApiError(error);
423
+ if (apiErr)
424
+ return apiErr;
425
+ throw error;
426
+ }
427
+ const candidates = Array.isArray(result?.candidates) ? result.candidates : [];
428
+ if (candidates.length === 0) {
429
+ return {
430
+ content: [{
431
+ type: "text",
432
+ text: "Nenhum nó candidato para esta consulta. Salve mesmo assim (o backend classifica ou cai no universo/Geral), ou refine a consulta.",
433
+ }],
434
+ };
435
+ }
436
+ let formatted = "Nós candidatos (copie o caminho POR NOME para memory_store(context_path=...)):\n\n";
437
+ candidates.forEach((c, i) => {
438
+ const path = typeof c?.context_path === "string" ? c.context_path : "(sem caminho)";
439
+ const conf = confidenceLabel(c?.confidence);
440
+ const confTxt = typeof c?.confidence === "number"
441
+ ? ` (confiança ${c.confidence.toFixed(2)}${conf ? ` · ${conf}` : ""})`
442
+ : "";
443
+ const level = c?.level ? ` [${c.level}]` : "";
444
+ formatted += `${i + 1}. ${path}${level}${confTxt}\n`;
445
+ });
446
+ formatted += "\nCopie o caminho (os nomes), não o context_id. Confiança ≥ 0,60 ancora sozinho; abaixo disso vai para a fila de revisão.";
447
+ return { content: [{ type: "text", text: formatted }] };
448
+ },
403
449
  // ==========================================================================
404
450
  // Auto-classification (human-in-the-loop)
405
451
  // ==========================================================================
@@ -1254,7 +1300,7 @@ const handlers = {
1254
1300
  }],
1255
1301
  };
1256
1302
  },
1257
- async agent_bootstrap(args, { agentClient, cpClient }) {
1303
+ async agent_bootstrap(args, { agentClient, cpClient, memoryClient, capture, resolvedUserEmail, resolvedUserId }) {
1258
1304
  // Kick off the notifications fetch concurrently with the bootstrap call so
1259
1305
  // it adds no latency on the critical path. fetchNotificationsBlock never
1260
1306
  // throws and self-limits to a 4s timeout, so bootstrap can neither fail nor
@@ -1264,20 +1310,69 @@ const handlers = {
1264
1310
  const result = await agentClient.bootstrapAgent(args.agent_id);
1265
1311
  const identity = result.identity || {};
1266
1312
  const runtime = result.runtime || {};
1313
+ const bootConfig = result.config || {};
1314
+ const agentIdStr = String(identity.id || args.agent_id || "");
1315
+ // --- Session lifecycle: the MCP owns it ------------------------------
1316
+ // The boot protocol must not depend on the model remembering to call
1317
+ // session_start / session_end. We start the formal session HERE (once per
1318
+ // process) and record it in the boot state; the process-exit hooks in
1319
+ // index.ts close it (session_summary + session_end). Best-effort: a
1320
+ // Memory Engine hiccup must never make bootstrap fail.
1321
+ let activeSessionId = getSessionId();
1322
+ if (!activeSessionId) {
1323
+ try {
1324
+ const s = await memoryClient.startSession({ agent_id: agentIdStr });
1325
+ activeSessionId = s?.session_id || null;
1326
+ if (activeSessionId)
1327
+ capture.sessionId = activeSessionId;
1328
+ }
1329
+ catch (err) {
1330
+ console.error("[MCP] bootstrap session_start failed:", err?.message || err);
1331
+ }
1332
+ }
1333
+ // Arm the deterministic reorient cadence from the authoritative config.
1334
+ const reorientEveryN = Number(bootConfig.reorient_every_n) || 10;
1335
+ const reorientPct = Number(bootConfig.reorient_on_context_pct) || 70;
1336
+ markBooted({
1337
+ agentId: agentIdStr,
1338
+ sessionId: activeSessionId,
1339
+ config: { reorientEveryN, reorientOnContextPct: reorientPct },
1340
+ });
1341
+ // --- Boot Contract (TOP: highest-attention, never-truncated-away) -----
1342
+ // Everything the agent cannot afford to lose goes here, ABOVE the long
1343
+ // system prompt (which the model tends to skim at the tail). Identity,
1344
+ // the org it may write to, the user, the session, and the reorient
1345
+ // cadence. The long persona/protocol text follows.
1346
+ const orgId = identity.org_id || getResolvedTenantOrgId() || "unknown";
1347
+ const orgName = identity.org_name ? `${identity.org_name} ` : "";
1348
+ const userEmail = resolvedUserEmail || "desconhecido";
1349
+ const userId = resolvedUserId ? ` \`${resolvedUserId}\`` : "";
1350
+ const roleTxt = identity.role || identity.specialization;
1351
+ // Notifications (system + org) go at the very TOP, above the contract:
1352
+ // a critical notice must be the first thing the agent reads. The fetch
1353
+ // ran concurrently with bootstrapAgent, so awaiting here adds no latency.
1354
+ const notif = await notifPromise;
1267
1355
  let formatted = `# Agent Bootstrap\n\n`;
1268
- formatted += `## Identity\n`;
1269
- if (identity.id)
1270
- formatted += `ID: ${identity.id}\n`;
1271
- if (identity.name)
1272
- formatted += `Name: ${identity.name}\n`;
1273
- if (identity.role || identity.specialization)
1274
- formatted += `Role: ${identity.role || identity.specialization}\n`;
1356
+ formatted += `<!-- ALERTS_SLOT -->\n`;
1357
+ if (notif)
1358
+ formatted += `${notif.trimEnd()}\n\n`;
1359
+ formatted += `## ⚙️ Contrato de Boot (CRÍTICO — leia e adote ANTES de tudo)\n`;
1360
+ formatted += `- Agente: ${identity.name || "?"} \`${agentIdStr}\`\n`;
1361
+ if (roleTxt)
1362
+ formatted += `- Papel: ${roleTxt}\n`;
1363
+ formatted += `- Organização: ${orgName}\`${orgId}\` — grave memórias SOMENTE nesta org (confira antes da 1ª escrita)\n`;
1364
+ formatted += `- Usuário: ${userEmail}${userId}\n`;
1365
+ formatted += `- MCP: frinus v${MCP_VERSION}`;
1366
+ if (activeSessionId)
1367
+ formatted += ` · Sessão: ${activeSessionId}`;
1368
+ if (result.persona_version !== undefined)
1369
+ formatted += ` · Persona: ${result.persona_version}`;
1370
+ formatted += `\n`;
1371
+ formatted += `- Reorient: a cada ${reorientEveryN} tool calls OU ~${reorientPct}% de contexto → \`agent_reorient(agent_id="${agentIdStr}")\`. O MCP conta as chamadas e anexa um aviso automático quando vencer — não dependa da sua memória.\n`;
1275
1372
  if (identity.universe_id)
1276
- formatted += `Universe: ${identity.universe_id}\n`;
1373
+ formatted += `- Universe: \`${identity.universe_id}\`\n`;
1277
1374
  if (identity.team_id)
1278
- formatted += `Team: ${identity.team_id}\n`;
1279
- if (result.persona_version !== undefined)
1280
- formatted += `Persona Version: ${result.persona_version}\n`;
1375
+ formatted += `- Team: \`${identity.team_id}\`\n`;
1281
1376
  formatted += `\n`;
1282
1377
  if (result.system_prompt) {
1283
1378
  formatted += `## System Prompt\n${result.system_prompt}\n\n`;
@@ -1295,7 +1390,14 @@ const handlers = {
1295
1390
  if (recentMemories.length > 0) {
1296
1391
  formatted += `\n### Recent Memories (${recentMemories.length})\n`;
1297
1392
  recentMemories.forEach((mem, i) => {
1298
- formatted += `${i + 1}. [${mem.memory_type || "memory"}] ${mem.content?.slice(0, 150)}\n`;
1393
+ // The server sends `type` (not `memory_type`); accept both so the
1394
+ // label is real instead of a constant "[memory]". Surface the id so
1395
+ // the agent can reinforce/weaken/get it, and the importance so it can
1396
+ // weight it. Content cut raised from 150 to 400 chars.
1397
+ const t = mem.type || mem.memory_type || "memory";
1398
+ const idTxt = mem.id ? ` (id: ${mem.id})` : "";
1399
+ const imp = typeof mem.importance === "number" ? ` (importance: ${mem.importance.toFixed(2)})` : "";
1400
+ formatted += `${i + 1}. [${t}]${idTxt}${imp} ${mem.content?.slice(0, 400)}\n`;
1299
1401
  });
1300
1402
  }
1301
1403
  const skills = runtime.skills || [];
@@ -1309,8 +1411,14 @@ const handlers = {
1309
1411
  skills.forEach((s) => {
1310
1412
  const name = s.skill_name || s.name || s.slug || s;
1311
1413
  const slug = s.slug ? ` \`${s.slug}\`` : "";
1414
+ // version/permission come from the server (skill_version / permission)
1415
+ // and were previously discarded. They tell the agent which revision it
1416
+ // holds and whether it may edit the playbook.
1417
+ const ver = s.version || s.skill_version;
1418
+ const verTxt = ver ? ` v${ver}` : "";
1419
+ const perm = s.permission ? ` [${s.permission}]` : "";
1312
1420
  const desc = s.description ? ` — ${s.description}` : "";
1313
- formatted += `- ${name}${slug}${desc}\n`;
1421
+ formatted += `- ${name}${slug}${verTxt}${perm}${desc}\n`;
1314
1422
  });
1315
1423
  formatted += `</skills_catalog>\n`;
1316
1424
  }
@@ -1323,9 +1431,19 @@ const handlers = {
1323
1431
  if (delegateDigest && delegateDigest.count > 0 && delegateDigest.summary) {
1324
1432
  formatted += `\n### Delegate Targets\n${delegateDigest.summary}\n`;
1325
1433
  }
1326
- const notif = await notifPromise;
1327
- if (notif)
1328
- formatted += `\n\n${notif}`;
1434
+ // Shallow tree map (universe → concept) for orientation. The lines are
1435
+ // already composed and truncated server-side; print them verbatim. Use
1436
+ // hierarchy_find(query) for the exact node, universe_hierarchy for the
1437
+ // full tree.
1438
+ const hierarchyMap = runtime.hierarchy_map || [];
1439
+ if (Array.isArray(hierarchyMap) && hierarchyMap.length > 0) {
1440
+ formatted += `\n### Árvore (navegação)\n`;
1441
+ formatted += `Mapa raso para orientação; use hierarchy_find(query) para achar o nó exato e universe_hierarchy para a árvore completa.\n`;
1442
+ hierarchyMap.forEach((line) => {
1443
+ if (typeof line === "string" && line)
1444
+ formatted += `- ${line}\n`;
1445
+ });
1446
+ }
1329
1447
  return { content: [{ type: "text", text: formatted }] };
1330
1448
  }
1331
1449
  catch (error) {
@@ -1338,6 +1456,10 @@ const handlers = {
1338
1456
  async agent_reorient(args, { memoryClient }) {
1339
1457
  try {
1340
1458
  const result = await memoryClient.reorientAgent(args.agent_id, args.focus);
1459
+ // Deterministic cadence: reorienting clears the "reorient due" counter so
1460
+ // the auto-appended banner stops until the next N tool calls elapse. Done
1461
+ // regardless of what the server returns.
1462
+ resetReorientCounter();
1341
1463
  const identity = result.identity || {};
1342
1464
  let formatted = `# Agent Reorient\n\n`;
1343
1465
  formatted += `## Identity\n`;
@@ -2352,7 +2474,8 @@ const handlers = {
2352
2474
  let text = `${items.length} pending Frinus notification${items.length === 1 ? "" : "s"}:\n\n`;
2353
2475
  items.forEach((it) => {
2354
2476
  const sev = it.severity ? `[${it.severity}] ` : "";
2355
- text += `- ${sev}${it.title} (id: ${it.id})\n`;
2477
+ const scope = it.scope === "organization" ? "(Organização) " : "(Sistema) ";
2478
+ text += `- ${scope}${sev}${it.title} (id: ${it.id})\n`;
2356
2479
  let body = (it.body || "").trim();
2357
2480
  if (body.length > 300)
2358
2481
  body = body.slice(0, 300).trimEnd() + "…";
@@ -2407,6 +2530,75 @@ const handlers = {
2407
2530
  throw error;
2408
2531
  }
2409
2532
  },
2533
+ async notification_create(args, { cpClient }) {
2534
+ const title = args.title?.trim() || "";
2535
+ if (!title) {
2536
+ return {
2537
+ content: [{ type: "text", text: "notification_create requires a non-empty 'title'." }],
2538
+ isError: true,
2539
+ };
2540
+ }
2541
+ // Owner scope only: the Control Plane forces scope='organization' and binds
2542
+ // the notice to the caller's own org — the owner cannot target other orgs.
2543
+ const payload = { title };
2544
+ if (typeof args.body === "string")
2545
+ payload.body = args.body;
2546
+ if (typeof args.kind === "string")
2547
+ payload.kind = args.kind;
2548
+ if (typeof args.severity === "string")
2549
+ payload.severity = args.severity;
2550
+ if (Array.isArray(args.target_roles))
2551
+ payload.target_roles = args.target_roles;
2552
+ if (Array.isArray(args.target_user_keys))
2553
+ payload.target_user_keys = args.target_user_keys;
2554
+ if (typeof args.cta_label === "string")
2555
+ payload.cta_label = args.cta_label;
2556
+ if (typeof args.cta_url === "string")
2557
+ payload.cta_url = args.cta_url;
2558
+ if (typeof args.starts_at === "string")
2559
+ payload.starts_at = args.starts_at;
2560
+ if (typeof args.expires_at === "string")
2561
+ payload.expires_at = args.expires_at;
2562
+ if (typeof args.max_deliveries === "number")
2563
+ payload.max_deliveries = args.max_deliveries;
2564
+ try {
2565
+ const created = await cpClient.createOrgNotification(payload);
2566
+ return {
2567
+ content: [{
2568
+ type: "text",
2569
+ text: `Organization notification created (id: ${created?.id ?? "?"}). It will reach the ` +
2570
+ `members of your org on their next bootstrap/heartbeat, newest-critical first, ` +
2571
+ `and leaves each member's inbox on ack or after max_deliveries days unseen.`,
2572
+ }],
2573
+ };
2574
+ }
2575
+ catch (error) {
2576
+ const status = error?.response?.status;
2577
+ if (status === 403) {
2578
+ return {
2579
+ content: [{
2580
+ type: "text",
2581
+ text: "Only an org owner can create organization notifications. This account is not an owner of the org.",
2582
+ }],
2583
+ isError: true,
2584
+ };
2585
+ }
2586
+ if (status === 422) {
2587
+ const detail = error?.response?.data?.detail;
2588
+ return {
2589
+ content: [{
2590
+ type: "text",
2591
+ text: `notification_create rejected: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
2592
+ }],
2593
+ isError: true,
2594
+ };
2595
+ }
2596
+ const apiErr = handleApiError(error);
2597
+ if (apiErr)
2598
+ return apiErr;
2599
+ throw error;
2600
+ }
2601
+ },
2410
2602
  };
2411
2603
  // ---------------------------------------------------------------------------
2412
2604
  // Dispatcher: looks up the handler and executes it with error handling
@@ -66,11 +66,28 @@ export interface NotificationItem {
66
66
  body: string;
67
67
  kind: string;
68
68
  severity: string;
69
+ /** "system" (platform-wide) or "organization" (owner of this org). */
70
+ scope?: string;
69
71
  cta_label?: string | null;
70
72
  cta_url?: string | null;
71
73
  starts_at?: string | null;
72
74
  expires_at?: string | null;
73
75
  }
76
+ /** Payload an org owner sends to publish an organization-scoped notice. */
77
+ export interface OwnerNotificationCreate {
78
+ title: string;
79
+ body?: string;
80
+ kind?: string;
81
+ severity?: string;
82
+ target_roles?: string[];
83
+ target_user_keys?: string[];
84
+ cta_label?: string | null;
85
+ cta_url?: string | null;
86
+ starts_at?: string | null;
87
+ expires_at?: string | null;
88
+ max_deliveries?: number;
89
+ is_active?: boolean;
90
+ }
74
91
  /** Response shape of GET /api/v1/notifications/inbox. */
75
92
  export interface NotificationInbox {
76
93
  items: NotificationItem[];
@@ -95,6 +112,7 @@ export interface CpClientInterface {
95
112
  markDelivered?: boolean;
96
113
  }): Promise<NotificationInbox>;
97
114
  ackNotification(id: string): Promise<void>;
115
+ createOrgNotification(data: OwnerNotificationCreate): Promise<any>;
98
116
  storeCredential(integrationRef: string, data: Record<string, unknown>, metadata?: Record<string, unknown>): Promise<any>;
99
117
  getCredential(integrationRef: string): Promise<any>;
100
118
  listCredentials(): Promise<any>;
@@ -132,6 +150,7 @@ export interface MemoryClientInterface {
132
150
  created_by_user_id?: string;
133
151
  metadata?: Record<string, unknown>;
134
152
  context_id?: string;
153
+ context_path?: string;
135
154
  credential_ref?: string;
136
155
  universe_id?: string;
137
156
  }): Promise<any>;
@@ -141,6 +160,11 @@ export interface MemoryClientInterface {
141
160
  memory_types?: string[];
142
161
  limit?: number;
143
162
  }): Promise<any>;
163
+ findHierarchy(data: {
164
+ query_text: string;
165
+ universe_id?: string;
166
+ limit?: number;
167
+ }): Promise<any>;
144
168
  getMemory(memoryId: string): Promise<any>;
145
169
  getAgentMemories(agentId: string, memoryType?: string, limit?: number): Promise<any>;
146
170
  deleteMemory(memoryId: string): Promise<any>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Single source of truth for the MCP server version.
3
+ *
4
+ * Imported by `index.ts` (server `name`/`version` handshake) and by the
5
+ * `agent_bootstrap` Boot Contract block so the version the user sees always
6
+ * matches the running build. Keep in sync with `package.json` and
7
+ * `manifest.json` at release time.
8
+ */
9
+ export declare const MCP_VERSION = "3.8.0";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Single source of truth for the MCP server version.
3
+ *
4
+ * Imported by `index.ts` (server `name`/`version` handshake) and by the
5
+ * `agent_bootstrap` Boot Contract block so the version the user sees always
6
+ * matches the running build. Keep in sync with `package.json` and
7
+ * `manifest.json` at release time.
8
+ */
9
+ export const MCP_VERSION = "3.8.0";
package/manifest.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "frinus",
4
4
  "display_name": "Frinus — Memória Cognitiva",
5
- "version": "3.7.0",
6
- "description": "Memória cognitiva de longo prazo para o Claude (φρήν + νοῦς). 83 ferramentas de memória, sessões, agentes e conhecimento.",
5
+ "version": "3.8.0",
6
+ "description": "Memória cognitiva de longo prazo para o Claude (φρήν + νοῦς). 91 ferramentas de memória, sessões, agentes e conhecimento.",
7
7
  "long_description": "O Frinus dá ao Claude memória de longo prazo, identidade e inteligência persistente entre conversas. Conecta o Claude Desktop à plataforma Frinus (Memory Engine, Control Plane e Agent Service) via MCP. Basta colar sua chave de API pessoal — nada para instalar ou compilar.",
8
8
  "author": {
9
9
  "name": "Frinus",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frinus-mcp",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "Frinus - MCP Server for Cognitive Memory (φρήν + νοῦς). Long-term memory, sessions, agents and knowledge for Claude and any MCP client.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",