@mercury-fw/core 0.32.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 +40 -0
- package/dist/src/credentials/materialize.d.ts +13 -0
- package/dist/src/wiki/frontmatter-schema.d.ts +1 -1
- package/dist/src/wiki/wiki-note.d.ts +2 -0
- package/package.json +6 -5
- package/src/compose.ts +12 -1
- package/src/credentials/materialize.ts +104 -0
- package/src/router/terminal-provider.ts +4 -1
- package/src/router/turn-runner.ts +31 -9
- package/src/wiki/wiki-note.ts +2 -1
- package/src/wiki/wiki-read.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
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
|
+
|
|
23
|
+
## 0.33.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- a493d9b: - A plugin whose CLI keeps its login in a folder declares it in its `package.json` (`mercury.cliCredentials`): `{ folder }` under `~/.config`, the default, or `{ path }` anywhere else under the home. Any plugin's CLI gets the login mechanism, not only the first-party ones.
|
|
28
|
+
- The core unpacks each declared login from its env variable onto the credentials volume (`~/.config`) at startup, only when the folder isn't there yet, for the service and the REPL alike; a folder declared elsewhere in the home lives on the volume under `~/.config/mercury-home`, linked from its usual place. It warns about a declared folder with neither the folder nor the variable.
|
|
29
|
+
- `mfw credentials set|reset <plugin>` names the plugin by its package or its CLI's folder (`@mercury-fw/plugin-jira` or `jira-cli`), read from the app's installed plugins; the short name (`jira`) is no longer accepted, and reset asks for the folder's name.
|
|
30
|
+
- `mfw create` no longer writes `docker-entrypoint.sh` or the credentials variables in the env example, and always mounts the `cli-credentials` volume; an existing app's entrypoint keeps working alongside.
|
|
31
|
+
- The generated README explains how a plugin's CLI gets its login without listing plugins.
|
|
32
|
+
- jira, bitbucket and atlassian-admin declare their CLI's login folder; their READMEs point to `mfw credentials set`.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- Updated dependencies [a493d9b]
|
|
37
|
+
- @mercury-fw/utils@0.33.0
|
|
38
|
+
- @mercury-fw/plugin-types@0.33.0
|
|
39
|
+
- @mercury-fw/channel-types@0.33.0
|
|
40
|
+
- @mercury-fw/cli-engine@0.33.0
|
|
41
|
+
- @mercury-fw/confirm-engine@0.33.0
|
|
42
|
+
|
|
3
43
|
## 0.32.0
|
|
4
44
|
|
|
5
45
|
### Minor Changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Where to read the declarations and where to unpack them. */
|
|
2
|
+
export interface MaterializeOptions {
|
|
3
|
+
/** The app's folder (its package.json and node_modules). */
|
|
4
|
+
appDir: string;
|
|
5
|
+
/** The home the CLIs run with; its `.config` is the credentials volume. */
|
|
6
|
+
homeDir: string;
|
|
7
|
+
env: Record<string, string | undefined>;
|
|
8
|
+
log: (msg: string) => void;
|
|
9
|
+
}
|
|
10
|
+
/** Unpacks every declared login that's missing from the volume and has its
|
|
11
|
+
* variable set, and links one declared outside `~/.config`; warns about one
|
|
12
|
+
* that has neither, and logs each dependency it couldn't read. */
|
|
13
|
+
export declare function materializeCliCredentials({ appDir, homeDir, env, log }: MaterializeOptions): Promise<void>;
|
|
@@ -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.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@mercury-fw/channel-types": "0.
|
|
35
|
-
"@mercury-fw/cli-engine": "0.
|
|
36
|
-
"@mercury-fw/confirm-engine": "0.
|
|
37
|
-
"@mercury-fw/plugin-types": "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",
|
|
38
39
|
"@qdrant/js-client-rest": "^1.19.0",
|
|
39
40
|
"ai": "^7.0.126",
|
|
40
41
|
"ai-sdk-ollama": "^4.4.0",
|
package/src/compose.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { createConfirmationStore, createStageConfirmation, tryConfirm, resolveCo
|
|
|
21
21
|
import { createDisplayStore } from "./tools/display-store.ts";
|
|
22
22
|
import { createPresentTool } from "./tools/present-tool.ts";
|
|
23
23
|
import { loadPlugins } from "./plugins/plugin-loader.ts";
|
|
24
|
+
import { materializeCliCredentials } from "./credentials/materialize.ts";
|
|
24
25
|
import type { MercuryConfig } from "./config/define-config.ts";
|
|
25
26
|
import { createSessionHistory, type SessionHistory, type Message } from "./session/history.ts";
|
|
26
27
|
import { createSummarizer } from "./session/summarizer.ts";
|
|
@@ -66,6 +67,7 @@ import { listWikiFilesInRoots, readWikiFile, readWikiFileInRoots, readIndexFile
|
|
|
66
67
|
import { runRawTriagePass, runIndexAndOrphanPass, runContradictionCheckPass } from "./wiki/self-review-runner.ts";
|
|
67
68
|
import { startSelfReviewCron } from "./cron/self-review-cron.ts";
|
|
68
69
|
import { resolve as resolvePath } from "node:path";
|
|
70
|
+
import { homedir } from "node:os";
|
|
69
71
|
import type { Tool } from "ai";
|
|
70
72
|
import { startAdminServer } from "./admin/server.ts";
|
|
71
73
|
// The HTTP surface's read routes (4b) reuse the admin panel's per-domain
|
|
@@ -148,6 +150,15 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
|
|
|
148
150
|
// that fails (an invalid allowlist, missing configuration) degrades only itself.
|
|
149
151
|
const plugins = config.plugins;
|
|
150
152
|
|
|
153
|
+
// A plugin's CLI that keeps its login in a folder finds it in place before
|
|
154
|
+
// the plugin loads: unpacked from the env file on a fresh credentials volume.
|
|
155
|
+
await materializeCliCredentials({
|
|
156
|
+
appDir: process.cwd(),
|
|
157
|
+
homeDir: homedir(),
|
|
158
|
+
env: process.env,
|
|
159
|
+
log: (msg) => console.error(msg),
|
|
160
|
+
});
|
|
161
|
+
|
|
151
162
|
const loadedPlugins = await loadPlugins(plugins, {
|
|
152
163
|
model,
|
|
153
164
|
env: process.env,
|
|
@@ -372,7 +383,7 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
|
|
|
372
383
|
|
|
373
384
|
// `wikiUserId` is separate from `sessionKey`: inferred/users/<userId> notes
|
|
374
385
|
// are scoped per-person, not per-(space,person) pair, so it must not include
|
|
375
|
-
// the space.
|
|
386
|
+
// the space. The turn runner derives it from the turn's principal.
|
|
376
387
|
function buildTools(
|
|
377
388
|
sessionKey: string,
|
|
378
389
|
wikiUserId: string,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unpacks each plugin-declared CLI credentials variable onto the config folder
|
|
3
|
+
* at startup. A plugin whose CLI keeps its login in a folder declares it in its
|
|
4
|
+
* package.json (`mercury.cliCredentials`, read by `@mercury-fw/utils`); the
|
|
5
|
+
* app's env file carries that folder packed into a variable (`mfw credentials
|
|
6
|
+
* set` writes it). In the container `~/.config` is the credentials volume, so
|
|
7
|
+
* this unpacks only while a CLI's folder isn't there yet: what the CLI writes
|
|
8
|
+
* back afterwards, like a refreshed token, stays across redeploys and an older
|
|
9
|
+
* variable never overwrites it. A login declared elsewhere in the home lives
|
|
10
|
+
* on the volume too (`volumePath`), and the home path is made a link to it at
|
|
11
|
+
* every start, since the rest of the home is the image's and starts over each
|
|
12
|
+
* time. Called by `composeMercury` before the plugins load, so the service and
|
|
13
|
+
* the REPL both get it. Never throws: a CLI without its login degrades only
|
|
14
|
+
* that plugin's calls. It works on the home it's given, so an app run outside
|
|
15
|
+
* its container (against the Docker-first rule) would unpack into the real
|
|
16
|
+
* home: only with a credentials variable set and the folder missing there.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
|
19
|
+
import { basename, dirname, join } from "node:path";
|
|
20
|
+
import { appCliCredentials, volumePath, type CliCredentials } from "@mercury-fw/utils";
|
|
21
|
+
|
|
22
|
+
/** Where to read the declarations and where to unpack them. */
|
|
23
|
+
export interface MaterializeOptions {
|
|
24
|
+
/** The app's folder (its package.json and node_modules). */
|
|
25
|
+
appDir: string;
|
|
26
|
+
/** The home the CLIs run with; its `.config` is the credentials volume. */
|
|
27
|
+
homeDir: string;
|
|
28
|
+
env: Record<string, string | undefined>;
|
|
29
|
+
log: (msg: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Unpacks every declared login that's missing from the volume and has its
|
|
33
|
+
* variable set, and links one declared outside `~/.config`; warns about one
|
|
34
|
+
* that has neither, and logs each dependency it couldn't read. */
|
|
35
|
+
export async function materializeCliCredentials({ appDir, homeDir, env, log }: MaterializeOptions): Promise<void> {
|
|
36
|
+
let declared: CliCredentials[];
|
|
37
|
+
try {
|
|
38
|
+
const read = appCliCredentials(appDir);
|
|
39
|
+
for (const problem of read.problems) log(`CLI credentials: ${problem}`);
|
|
40
|
+
declared = read.declared;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
log(`CLI credentials not unpacked: ${err instanceof Error ? err.message : String(err)}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const c of declared) {
|
|
46
|
+
const target = join(homeDir, volumePath(c.path));
|
|
47
|
+
if (!existsSync(target)) {
|
|
48
|
+
const value = env[c.variable];
|
|
49
|
+
if (value === undefined || value === "") {
|
|
50
|
+
log(
|
|
51
|
+
`${c.package}: no login for its CLI (no ${c.name} folder, ${c.variable} not set): run mfw credentials set ${c.name}`,
|
|
52
|
+
);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await unpack(value, basename(c.path), target);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
log(`${c.package}: could not unpack ${c.variable} into ${c.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const link = join(homeDir, c.path);
|
|
63
|
+
if (link === target) continue;
|
|
64
|
+
try {
|
|
65
|
+
linkTo(target, link, (msg) => log(`${c.package}: ${msg}`));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
log(`${c.package}: could not link ${link} to the credentials volume: ${err instanceof Error ? err.message : String(err)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Makes `link` a symlink to `target` unless it already is one; anything else
|
|
73
|
+
* at `link` is never replaced, only reported. */
|
|
74
|
+
function linkTo(target: string, link: string, log: (msg: string) => void): void {
|
|
75
|
+
const existing = lstatSync(link, { throwIfNoEntry: false });
|
|
76
|
+
if (existing === undefined) {
|
|
77
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
78
|
+
symlinkSync(target, link);
|
|
79
|
+
} else if (!existing.isSymbolicLink() || readlinkSync(link) !== target) {
|
|
80
|
+
log(`${link} is already there and isn't a link to the credentials volume: the CLI won't find its login`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extracts only `member` from the base64 tar.gz into a staging folder next to
|
|
85
|
+
* `target`, then moves it to `target`: a failed extraction leaves nothing
|
|
86
|
+
* behind that would count as present on the next start. */
|
|
87
|
+
async function unpack(value: string, member: string, target: string): Promise<void> {
|
|
88
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
89
|
+
const staging = mkdtempSync(join(dirname(target), `.${member}-`));
|
|
90
|
+
try {
|
|
91
|
+
const proc = Bun.spawn(["tar", "-xzf", "-", "-C", staging, member], {
|
|
92
|
+
stdin: Buffer.from(value, "base64"),
|
|
93
|
+
stdout: "ignore",
|
|
94
|
+
stderr: "pipe",
|
|
95
|
+
});
|
|
96
|
+
const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
97
|
+
if (code !== 0 || !existsSync(join(staging, member))) {
|
|
98
|
+
throw new Error(stderr.trim() || `tar exited with ${code}`);
|
|
99
|
+
}
|
|
100
|
+
renameSync(join(staging, member), target);
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(staging, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
119
|
+
const { captureUserId, wikiUserId } = principalIds(turn.principal);
|
|
120
|
+
const tracked = captureUserId !== undefined;
|
|
103
121
|
if (tracked) {
|
|
104
|
-
deps.trackSession(turn.sessionKey,
|
|
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,
|
|
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,
|
|
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
|
|
191
|
-
//
|
|
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({
|
|
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)}`,
|
package/src/wiki/wiki-note.ts
CHANGED
|
@@ -50,7 +50,8 @@ function resolveWithinRoot(root: string, ...segments: string[]): string {
|
|
|
50
50
|
return target;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
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
|
}
|
package/src/wiki/wiki-read.ts
CHANGED
|
@@ -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
|
}
|