@mercury-fw/core 0.33.0 → 0.34.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @mercury-fw/core
2
2
 
3
+ ## 0.34.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8bed46b: - A turn carries a `Principal` (the person behind it and the provider that vouched for them) instead of the opaque `userId` and `wikiUserId`; the core derives every per-person id from it.
8
+ - Episodic memory follows the principal, not the channel: a session is captured whenever a provider vouched for the person. Google Chat keeps the ids it had; the terminal and the HTTP channel (until it authenticates its callers) send a principal nobody vouched for, so they stay out of episodic memory as before.
9
+ - The HTTP channel accepts a `conversationId` on `/turn` and `/confirm` only made of letters, digits, `-` and `_`, up to 128 characters once trimmed, and answers `400` otherwise: a client-chosen id can no longer reach another user's wiki notes, forge log lines or break the turn. An existing HTTP conversation whose id has other characters can still be read with `/conversation`, but not continued. `/conversation` keeps opening any session key `/conversations` lists, whatever channel it came from.
10
+ - The model's wiki read tools refuse a per-user id that isn't one path segment, as writes already did.
11
+ - `CHANNEL_API_VERSION` is 2: a channel written for version 1 is refused at load.
12
+ - The first-party channels declare their channel api version as a literal, so an older channel can't claim a newer contract it doesn't implement.
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [8bed46b]
17
+ - @mercury-fw/channel-types@0.34.0
18
+ - @mercury-fw/confirm-engine@0.34.0
19
+ - @mercury-fw/plugin-types@0.34.0
20
+ - @mercury-fw/cli-engine@0.34.0
21
+ - @mercury-fw/utils@0.34.0
22
+
3
23
  ## 0.33.0
4
24
 
5
25
  ### Minor Changes
@@ -41,8 +41,8 @@ export declare const ConfirmationFrontmatterSchema: z.ZodObject<{
41
41
  type: z.ZodLiteral<"confirmation">;
42
42
  status: z.ZodEnum<{
43
43
  pending: "pending";
44
- failed: "failed";
45
44
  confirmed: "confirmed";
45
+ failed: "failed";
46
46
  }>;
47
47
  requested_at: z.ZodString;
48
48
  resolved_at: z.ZodNullable<z.ZodString>;
@@ -1,3 +1,5 @@
1
+ /** Throws unless `value` is exactly one non-empty path segment (no separator, not `.` or `..`). Shared by the read side's per-user root. */
2
+ export declare function assertNoPathSeparator(label: string, value: string): void;
1
3
  /** `path` relative to curated/: a vault-relative one (`curated/x.md`, as
2
4
  * listing, reading and grepping give it) loses its leading `curated/`. */
3
5
  export declare function relativeToCurated(path: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/core",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,11 +31,11 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@mercury-fw/channel-types": "0.33.0",
35
- "@mercury-fw/cli-engine": "0.33.0",
36
- "@mercury-fw/confirm-engine": "0.33.0",
37
- "@mercury-fw/plugin-types": "0.33.0",
38
- "@mercury-fw/utils": "0.33.0",
34
+ "@mercury-fw/channel-types": "0.34.0",
35
+ "@mercury-fw/cli-engine": "0.34.0",
36
+ "@mercury-fw/confirm-engine": "0.34.0",
37
+ "@mercury-fw/plugin-types": "0.34.0",
38
+ "@mercury-fw/utils": "0.34.0",
39
39
  "@qdrant/js-client-rest": "^1.19.0",
40
40
  "ai": "^7.0.126",
41
41
  "ai-sdk-ollama": "^4.4.0",
package/src/compose.ts CHANGED
@@ -383,7 +383,7 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
383
383
 
384
384
  // `wikiUserId` is separate from `sessionKey`: inferred/users/<userId> notes
385
385
  // are scoped per-person, not per-(space,person) pair, so it must not include
386
- // the space. An identity-less channel (the terminal) uses a fixed id.
386
+ // the space. The turn runner derives it from the turn's principal.
387
387
  function buildTools(
388
388
  sessionKey: string,
389
389
  wikiUserId: string,
@@ -17,10 +17,13 @@ import { getLoadedContextLength } from "../model/context-size.ts";
17
17
  import { detectPendingConfirmation } from "../session/pending-confirmation.ts";
18
18
  import { PENDING_CONFIRMATION_NOTE } from "../session/agent-turn.ts";
19
19
  import type { Provider, HandleTurn, TurnSink } from "./provider.ts";
20
+ import type { Principal } from "@mercury-fw/channel-types";
20
21
  import type { StepInfo } from "../session/step-info.ts";
21
22
  import type { writeConfirmationNote } from "../wiki/wiki-note.ts";
22
23
 
23
24
  const TERMINAL_SESSION_KEY = "terminal";
25
+ /** The terminal is a single-user debug console: one fixed principal nobody vouched for, so its sessions stay out of episodic memory. */
26
+ const TERMINAL_PRINCIPAL: Principal = { id: "terminal", provider: "none" };
24
27
 
25
28
  export type TerminalProviderDeps = {
26
29
  confirmDeps: {
@@ -134,7 +137,7 @@ export function createTerminalProvider(deps: TerminalProviderDeps): Provider {
134
137
  multiUser: false,
135
138
  text: input,
136
139
  sessionKey: TERMINAL_SESSION_KEY,
137
- wikiUserId: TERMINAL_SESSION_KEY,
140
+ principal: TERMINAL_PRINCIPAL,
138
141
  logPrefix: "",
139
142
  },
140
143
  sink,
@@ -24,6 +24,7 @@ export type { PostTurnGuard };
24
24
  import type { SessionHistory } from "../session/history.ts";
25
25
  import { recordStep } from "../session/tool-log-buffer.ts";
26
26
  import type { HandleTurn, InboundTurn, TurnSink } from "./provider.ts";
27
+ import type { Principal } from "@mercury-fw/channel-types";
27
28
 
28
29
  export type TurnRunnerDeps = {
29
30
  model: LanguageModel;
@@ -93,15 +94,32 @@ export type TurnRunnerDeps = {
93
94
  takeSurfacedDisplays?: (sessionKey: string) => string[];
94
95
  };
95
96
 
97
+ /**
98
+ * The per-person ids the core derives from a turn's principal. `captureUserId`
99
+ * is the raw id, set only when a provider vouched for the person, so a turn
100
+ * nobody vouched for is never tracked for Layer-3 capture. `wikiUserId` is the
101
+ * same id encoded for `inferred/users/<id>` and the verbatim archive, so a "/"
102
+ * can't add a segment; `.` and `..` survive encoding and are refused by the
103
+ * wiki's own guards. `toWellFormed` keeps a lone surrogate from making the
104
+ * encoding throw.
105
+ */
106
+ function principalIds(principal: Principal): { captureUserId?: string; wikiUserId: string } {
107
+ return {
108
+ captureUserId: principal.provider === "none" ? undefined : principal.id,
109
+ wikiUserId: encodeURIComponent(principal.id.toWellFormed()),
110
+ };
111
+ }
112
+
96
113
  /** Builds the shared `HandleTurn` every provider's driver calls once it has a real message to run through the model. */
97
114
  export function createTurnRunner(deps: TurnRunnerDeps): HandleTurn {
98
115
  const postTurnGuards = deps.postTurnGuards ?? [];
99
116
  const logPostTurnGuard = deps.logPostTurnGuardFn ?? ((message: string) => console.log(message));
100
117
 
101
118
  return async (turn: InboundTurn, sink: TurnSink): Promise<void> => {
102
- const tracked = turn.userId !== undefined;
119
+ const { captureUserId, wikiUserId } = principalIds(turn.principal);
120
+ const tracked = captureUserId !== undefined;
103
121
  if (tracked) {
104
- deps.trackSession(turn.sessionKey, turn.userId as string, (deps.now ?? Date.now)());
122
+ deps.trackSession(turn.sessionKey, captureUserId, (deps.now ?? Date.now)());
105
123
  deps.registerCaptureCallback(turn.sessionKey, sink.onToolStart, sink.onToolFinish);
106
124
  }
107
125
 
@@ -119,10 +137,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): HandleTurn {
119
137
  // already scheduled when the sink was constructed keeps running
120
138
  // otherwise, firing on its own 60s schedule regardless of whether
121
139
  // the turn itself already failed and was reported.
122
- history = await deps.getOrCreateHistory(turn.sessionKey, tracked, turn.userId);
140
+ history = await deps.getOrCreateHistory(turn.sessionKey, tracked, captureUserId);
123
141
  const text = await (deps.runTurnFn ?? runTurn)(history, turn.text, {
124
142
  model: deps.model,
125
- tools: deps.buildTools(turn.sessionKey, turn.wikiUserId, sink.onToolStart, sink.onToolFinish),
143
+ tools: deps.buildTools(turn.sessionKey, wikiUserId, sink.onToolStart, sink.onToolFinish),
126
144
  system: turn.multiUser ? deps.systemPrompts.multiUser : deps.systemPrompts.singleUser,
127
145
  onTextChunk: sink.onTextChunk,
128
146
  onReasoningChunk: sink.onReasoningChunk,
@@ -187,12 +205,16 @@ export function createTurnRunner(deps: TurnRunnerDeps): HandleTurn {
187
205
  // propagate out of this awaited handler and take the process down.
188
206
  if (deps.captureVerbatim) {
189
207
  // The archive is per-person and space-independent, so it keys on
190
- // wikiUserId — Mercury's canonical per-user id, the same identity the
191
- // wiki notes use — not the space-scoped session.
192
- const userId = turn.wikiUserId;
208
+ // wikiUserId (the same identity the wiki notes use), not the
209
+ // space-scoped session.
193
210
  try {
194
- await deps.captureVerbatim({ sessionKey: turn.sessionKey, userId, role: "user", content: turn.text });
195
- await deps.captureVerbatim({ sessionKey: turn.sessionKey, userId, role: "assistant", content: assistantText });
211
+ await deps.captureVerbatim({ sessionKey: turn.sessionKey, userId: wikiUserId, role: "user", content: turn.text });
212
+ await deps.captureVerbatim({
213
+ sessionKey: turn.sessionKey,
214
+ userId: wikiUserId,
215
+ role: "assistant",
216
+ content: assistantText,
217
+ });
196
218
  } catch (err) {
197
219
  console.log(
198
220
  `[verbatim-archive] capture failed, turn unaffected: ${String(err instanceof Error ? err.message : err)}`,
@@ -50,7 +50,8 @@ function resolveWithinRoot(root: string, ...segments: string[]): string {
50
50
  return target;
51
51
  }
52
52
 
53
- function assertNoPathSeparator(label: string, value: string): void {
53
+ /** Throws unless `value` is exactly one non-empty path segment (no separator, not `.` or `..`). Shared by the read side's per-user root. */
54
+ export function assertNoPathSeparator(label: string, value: string): void {
54
55
  if (value === "" || value.includes("/") || value.includes("\\") || value === "." || value === "..") {
55
56
  throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
56
57
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { readFile, stat } from "node:fs/promises";
12
12
  import { join, relative, resolve, sep } from "node:path";
13
+ import { assertNoPathSeparator } from "./wiki-note.ts";
13
14
 
14
15
  async function pathExists(path: string): Promise<boolean> {
15
16
  try {
@@ -21,6 +22,7 @@ async function pathExists(path: string): Promise<boolean> {
21
22
  }
22
23
 
23
24
  function allowedRoots(vaultPath: string, userId: string): string[] {
25
+ assertNoPathSeparator("userId", userId);
24
26
  const vaultRoot = resolve(vaultPath);
25
27
  return [resolve(vaultRoot, "curated"), resolve(vaultRoot, "inferred", "users", userId)];
26
28
  }