@ai-wayfinding/client 0.1.1
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 +90 -0
- package/dist/cache.d.ts +15 -0
- package/dist/cache.js +45 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +203 -0
- package/dist/connection.d.ts +19 -0
- package/dist/connection.js +51 -0
- package/dist/import.d.ts +3 -0
- package/dist/import.js +46 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/journey.d.ts +45 -0
- package/dist/journey.js +195 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/mcp.js +82 -0
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +8 -0
- package/dist/storage.d.ts +23 -0
- package/dist/storage.js +129 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Journey agent client
|
|
2
|
+
|
|
3
|
+
The `wayfinding` command gives an agent access to one encrypted journey after a person approves it. It also runs a Model Context Protocol (MCP) server over standard input and output. Agents can read, add items, and comment; **they cannot change journey membership or access**. Read-only agents cannot write.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Node.js 22 or newer is required. Download both packages from the [latest release](https://github.com/AI-Wayfinding/journey/releases/latest), then install them together:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
R=https://github.com/AI-Wayfinding/journey/releases/latest/download
|
|
11
|
+
curl -fsSLO "$R/ai-wayfinding-core.tgz"
|
|
12
|
+
curl -fsSLO "$R/ai-wayfinding-client.tgz"
|
|
13
|
+
npm install -g ./ai-wayfinding-core.tgz ./ai-wayfinding-client.tgz
|
|
14
|
+
wayfinding --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Downloading first works even where npm is set to refuse installs from a URL.
|
|
18
|
+
|
|
19
|
+
### Build from this repository
|
|
20
|
+
|
|
21
|
+
The packages are not on npm. From a clone of this repository:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm ci
|
|
25
|
+
npm run build -w packages/core
|
|
26
|
+
npm run build -w packages/client
|
|
27
|
+
npm pack -w packages/core
|
|
28
|
+
npm pack -w packages/client
|
|
29
|
+
# Install both local tarballs together. The client needs the unpublished core package.
|
|
30
|
+
npm install -g ./ai-wayfinding-core-*.tgz ./ai-wayfinding-client-*.tgz
|
|
31
|
+
wayfinding --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If a global install is not wanted, run `node packages/client/dist/cli.js --help` from the clone after building. A GitHub URL is not a reliable npm install target for one package inside this workspace; installing the root does not install the client binary. The two local tarballs are the supported install path for now. The client uses the MCP SDK version 1.30.1; no other direct runtime dependency is added.
|
|
35
|
+
|
|
36
|
+
## Connect to a journey
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
wayfinding connect <journey-id> --scope read
|
|
40
|
+
wayfinding connect <journey-id> --scope readwrite --remember
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The journey server defaults to `https://app.wayfinding.support`; use `--server https://your-server` if needed. The command creates a new age encryption identity and signing key **in memory**, asks the server for approval, and prints a link and a six-digit code. Open the link in your browser, check that the code matches, choose the approved access and time, then confirm with your passkey. The command waits until approval, expiry, or lockout. Approval is valid for at most 8 hours without remembering, or 90 days when remembered. The person must choose remembered access in the approval screen before keys are saved; the client refuses expired keys.
|
|
44
|
+
|
|
45
|
+
Without `--remember`, the one-shot `connect` command does not save keys. It closes the connection when it exits. Start `wayfinding mcp --connect <journey-id>` to keep an in-memory connection for that MCP process, or pass `--journey <journey-id>` to an individual CLI command to ask for fresh approval on each invocation. There is no background daemon and no invisible persistent login.
|
|
46
|
+
|
|
47
|
+
With `--remember`, agent keys go to macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). Unlock/install that service first. If the OS keychain is unavailable or the platform is unsupported, the command refuses instead of storing unencrypted keys. Windows users can use `--key-folder <path>` with a passphrase; there is no Windows Credential Manager adapter. On every OS, `--key-folder <path> --remember` stores an age-passphrase-encrypted `agent.age` in a private folder (0700 folder, 0600 file on Unix). Set `WAYFINDING_PASSPHRASE` or enter it at the terminal when prompted; the passphrase is never stored. Do not choose a shared folder.
|
|
48
|
+
|
|
49
|
+
`wayfinding disconnect` removes the remembered keys and the local cache. When using `--key-folder`, pass the **same folder** to both connect and disconnect. Server-side membership remains until the person removes the agent or the approval expires.
|
|
50
|
+
|
|
51
|
+
## Work with an approved journey
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
wayfinding status
|
|
55
|
+
wayfinding list --type resource
|
|
56
|
+
wayfinding search "journey words"
|
|
57
|
+
wayfinding show <item-id>
|
|
58
|
+
wayfinding add --type resource --title "A useful link" --body "Notes" --tags reading,guide
|
|
59
|
+
wayfinding import ./notes.md
|
|
60
|
+
wayfinding import ./markdown-folder
|
|
61
|
+
wayfinding comment <item-id> "A follow-up"
|
|
62
|
+
wayfinding comments <item-id>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Markdown imports retain simple front matter fields `title`, `type` (or `itemType`), `tags` (comma-separated or inline array), `created`, `resourceKind`, and `sharedFrom`; the remaining Markdown is the body. Files must have `.md` extensions. The agent is always marked as the author, regardless of input front matter. For a directory, Markdown files are read recursively.
|
|
66
|
+
|
|
67
|
+
Each read checks the full signed journey membership log. Before a write, the client checks that log again, confirms this agent is still an active read-write member and that its version meets the journey minimum, then encrypts the item or comment locally. Server requests are signed with the method, path and query, body digest, timestamp, and fresh nonce. If access ends, the client says so and stops. Keys and decrypted items remain in memory for the process lifetime.
|
|
68
|
+
|
|
69
|
+
### Optional encrypted-data cache
|
|
70
|
+
|
|
71
|
+
A remembered session uses a local cache by default. An in-memory connection uses no cache by default. Pass `--cache` to enable it for a one-shot command or `--no-cache` to disable it. Under the OS cache directory (macOS: `~/Library/Caches`; Linux: `$XDG_CACHE_HOME` or `~/.cache`; Windows: `%LOCALAPPDATA%`), the client stores **only ciphertext envelopes and the head of a freshly verified signed log**. The journey key, secret signing key, item titles, and item bodies never go to this cache. On Unix, cache folders are 0700 and files 0600. The client refreshes records by sequence number but verifies the full signed log again before every use. Disable the cache on a shared device.
|
|
72
|
+
|
|
73
|
+
## Connect an MCP tool host
|
|
74
|
+
|
|
75
|
+
The MCP server exposes `add`, `import`, `list`, `search`, `show`, `comment`, `comments`, `status`, and `connect_status`. Tool descriptions explain that the person approves access. Standard output is reserved for MCP messages; approval instructions go to standard error. With a remembered connection, use this command without `--connect`. With an in-memory connection, give the journey ID:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"mcpServers": {
|
|
80
|
+
"wayfinding": {
|
|
81
|
+
"command": "wayfinding",
|
|
82
|
+
"args": ["mcp", "--connect", "YOUR_JOURNEY_ID"]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For Claude Desktop, add the `mcpServers.wayfinding` entry to its MCP configuration. For Claude Code, use `claude mcp add wayfinding -- wayfinding mcp --connect YOUR_JOURNEY_ID`. For Codex, use `codex mcp add wayfinding -- wayfinding mcp --connect YOUR_JOURNEY_ID`. Use the absolute path to the built `cli.js` with `node` if `wayfinding` is not on the host's PATH. When the MCP process begins, the person sees the link and code in the host's standard-error log; approve it before the MCP connection finishes. Some hosts hide stderr: run `wayfinding connect <journey-id> --remember` in a terminal first, then configure `wayfinding mcp` without `--connect`.
|
|
89
|
+
|
|
90
|
+
The person manages other members and any key rotation in the browser. An agent cannot approve itself, change someone's scope, add or remove members, or rotate journey keys. If the person previously rotated the journey key, the current approval API may not provide this agent the old epoch wraps needed to verify *all* earlier history; this client stops with a missing-key message rather than writing without verification.
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Envelope } from '@ai-wayfinding/core';
|
|
2
|
+
export interface CipherRow {
|
|
3
|
+
seq: number;
|
|
4
|
+
entry: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CipherCache {
|
|
7
|
+
seq: number;
|
|
8
|
+
hash: string;
|
|
9
|
+
log: CipherRow[];
|
|
10
|
+
records: Envelope[];
|
|
11
|
+
}
|
|
12
|
+
/** Only copy ciphertext envelopes and the head of a fully verified signed log. */
|
|
13
|
+
export declare function writeCache(root: string, journeyId: string, cache: CipherCache): Promise<string>;
|
|
14
|
+
export declare function readCache(root: string, journeyId: string): Promise<CipherCache | null>;
|
|
15
|
+
export declare function forgetCache(root: string, journeyId: string): Promise<void>;
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isId } from '@ai-wayfinding/core';
|
|
4
|
+
function directory(root) { return join(root, 'ai-wayfinding', 'journeys'); }
|
|
5
|
+
function location(root, journeyId) {
|
|
6
|
+
if (!isId(journeyId))
|
|
7
|
+
throw new Error('Invalid journey ID');
|
|
8
|
+
return join(directory(root), journeyId + '.json');
|
|
9
|
+
}
|
|
10
|
+
/** Only copy ciphertext envelopes and the head of a fully verified signed log. */
|
|
11
|
+
export async function writeCache(root, journeyId, cache) {
|
|
12
|
+
const path = location(root, journeyId), dir = directory(root);
|
|
13
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
await chmod(join(root, 'ai-wayfinding'), 0o700);
|
|
15
|
+
await chmod(dir, 0o700);
|
|
16
|
+
const safe = { seq: cache.seq, hash: cache.hash, log: cache.log.map(row => ({ seq: row.seq, entry: row.entry })), records: cache.records.map(row => ({ outside: { v: 1, id: row.outside.id, journey: row.outside.journey, seq: row.outside.seq, epoch: row.outside.epoch, size: row.outside.size, createdAt: row.outside.createdAt }, nonce: row.nonce, ciphertext: row.ciphertext })) };
|
|
17
|
+
const temp = join(dir, '.' + crypto.randomUUID());
|
|
18
|
+
try {
|
|
19
|
+
await writeFile(temp, JSON.stringify(safe), { mode: 0o600, flag: 'wx' });
|
|
20
|
+
await rename(temp, path);
|
|
21
|
+
await chmod(path, 0o600);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await rm(temp, { force: true });
|
|
25
|
+
}
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
export async function readCache(root, journeyId) {
|
|
29
|
+
let value;
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(await readFile(location(root, journeyId), 'utf8'));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error.code === 'ENOENT')
|
|
35
|
+
return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (!value || typeof value !== 'object')
|
|
39
|
+
throw new Error('Invalid journey cache');
|
|
40
|
+
const row = value;
|
|
41
|
+
if (!Number.isSafeInteger(row.seq) || typeof row.hash !== 'string' || !Array.isArray(row.log) || !Array.isArray(row.records))
|
|
42
|
+
throw new Error('Invalid journey cache');
|
|
43
|
+
return row;
|
|
44
|
+
}
|
|
45
|
+
export async function forgetCache(root, journeyId) { await rm(location(root, journeyId), { force: true }); }
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { stdin, stderr } from 'node:process';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { connectJourney } from './connection.js';
|
|
6
|
+
import { importMarkdown } from './import.js';
|
|
7
|
+
import { JourneyClient } from './journey.js';
|
|
8
|
+
import { runMcp } from './mcp.js';
|
|
9
|
+
import { forgetRemembered, loadRemembered } from './storage.js';
|
|
10
|
+
const help = `wayfinding — read and write an approved journey
|
|
11
|
+
|
|
12
|
+
wayfinding connect <journey-id> [--name "Agent name"] [--scope read|readwrite] [--remember] [--server https://app.wayfinding.support] [--key-folder PATH]
|
|
13
|
+
wayfinding disconnect [--key-folder PATH]
|
|
14
|
+
wayfinding add --type TYPE --title TITLE --body TEXT [--tags a,b]
|
|
15
|
+
wayfinding import <file-or-folder>
|
|
16
|
+
wayfinding list [--type TYPE]
|
|
17
|
+
wayfinding search <text>
|
|
18
|
+
wayfinding show <id>
|
|
19
|
+
wayfinding comment <id> <text>
|
|
20
|
+
wayfinding comments <id>
|
|
21
|
+
wayfinding status
|
|
22
|
+
wayfinding mcp [--connect <journey-id>] [--name "Agent name"]
|
|
23
|
+
|
|
24
|
+
Use --journey <journey-id> with any one-shot command to ask for approval each time without remembering keys.
|
|
25
|
+
Use --cache to store only encrypted journey records and a verified log head; --no-cache turns it off.
|
|
26
|
+
Keys never go into the local cache. An agent cannot change journey membership or access.`;
|
|
27
|
+
const valueFlags = new Set(['--scope', '--name', '--server', '--key-folder', '--journey', '--connect', '--type', '--title', '--body', '--tags']);
|
|
28
|
+
const boolFlags = new Set(['--remember', '--cache', '--no-cache', '--help']);
|
|
29
|
+
function parse(args) {
|
|
30
|
+
const command = args[0] ?? '--help', flags = {}, positional = [];
|
|
31
|
+
for (let index = 1; index < args.length; index++) {
|
|
32
|
+
const part = args[index];
|
|
33
|
+
if (valueFlags.has(part)) {
|
|
34
|
+
if (args[index + 1] === undefined || args[index + 1].startsWith('--'))
|
|
35
|
+
throw new Error(part + ' needs a value.');
|
|
36
|
+
flags[part] = args[++index];
|
|
37
|
+
}
|
|
38
|
+
else if (boolFlags.has(part))
|
|
39
|
+
flags[part] = true;
|
|
40
|
+
else if (part.startsWith('-'))
|
|
41
|
+
throw new Error('Unknown option: ' + part);
|
|
42
|
+
else
|
|
43
|
+
positional.push(part);
|
|
44
|
+
}
|
|
45
|
+
return { command, positional, flags };
|
|
46
|
+
}
|
|
47
|
+
function flag(flags, key) { return typeof flags[key] === 'string' ? flags[key] : undefined; }
|
|
48
|
+
async function passphrase(folder) {
|
|
49
|
+
if (!folder)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (process.env.WAYFINDING_PASSPHRASE)
|
|
52
|
+
return process.env.WAYFINDING_PASSPHRASE;
|
|
53
|
+
if (!stdin.isTTY || !stdin.setRawMode)
|
|
54
|
+
throw new Error('A passphrase is required for --key-folder. Set WAYFINDING_PASSPHRASE or run from a terminal to enter it.');
|
|
55
|
+
stderr.write('Passphrase for the journey key folder: ');
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let value = '';
|
|
58
|
+
const finish = (error) => {
|
|
59
|
+
stdin.removeListener('data', onData);
|
|
60
|
+
stdin.setRawMode(false);
|
|
61
|
+
stdin.pause();
|
|
62
|
+
stderr.write('\n');
|
|
63
|
+
if (error)
|
|
64
|
+
reject(error);
|
|
65
|
+
else if (!value)
|
|
66
|
+
reject(new Error('A journey key-folder passphrase cannot be empty.'));
|
|
67
|
+
else
|
|
68
|
+
resolve(value);
|
|
69
|
+
};
|
|
70
|
+
const onData = (chunk) => {
|
|
71
|
+
for (const byte of chunk) {
|
|
72
|
+
if (byte === 3) {
|
|
73
|
+
finish(new Error('Cancelled.'));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (byte === 13 || byte === 10) {
|
|
77
|
+
finish();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (byte === 127 || byte === 8)
|
|
81
|
+
value = value.slice(0, -1);
|
|
82
|
+
else
|
|
83
|
+
value += String.fromCharCode(byte);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
stdin.setRawMode(true);
|
|
87
|
+
stdin.resume();
|
|
88
|
+
stdin.on('data', onData);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function cacheFolder() {
|
|
92
|
+
if (process.platform === 'win32')
|
|
93
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'wayfinding', 'journeys');
|
|
94
|
+
if (process.platform === 'darwin')
|
|
95
|
+
return join(homedir(), 'Library', 'Caches', 'wayfinding', 'journeys');
|
|
96
|
+
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'wayfinding', 'journeys');
|
|
97
|
+
}
|
|
98
|
+
export async function main(args = process.argv.slice(2)) {
|
|
99
|
+
const { command, positional, flags } = parse(args);
|
|
100
|
+
if (command === '--help' || command === 'help' || flags['--help']) {
|
|
101
|
+
console.log(help);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const keyFolder = flag(flags, '--key-folder');
|
|
105
|
+
if (command === 'disconnect') {
|
|
106
|
+
await forgetRemembered({ folder: keyFolder });
|
|
107
|
+
const { rm } = await import('node:fs/promises');
|
|
108
|
+
await rm(cacheFolder(), { recursive: true, force: true });
|
|
109
|
+
console.log('Remembered journey keys and the local cache have been removed.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const secret = await passphrase(keyFolder);
|
|
113
|
+
const server = flag(flags, '--server'), scope = flag(flags, '--scope');
|
|
114
|
+
if (scope && scope !== 'read' && scope !== 'readwrite')
|
|
115
|
+
throw new Error('Use --scope read or --scope readwrite.');
|
|
116
|
+
const connect = async (journeyId, mcp = false) => connectJourney(journeyId, { server, scope: scope, name: flag(flags, '--name'), remember: !!flags['--remember'], keyFolder, passphrase: secret, onApproval: (url, code) => { (mcp ? console.error : console.log)('Open this link, check the code matches, and approve access to this journey: ' + url + '\nSix-digit code: ' + code); } });
|
|
117
|
+
if (command === 'connect') {
|
|
118
|
+
const journeyId = positional[0];
|
|
119
|
+
if (!journeyId)
|
|
120
|
+
throw new Error('Give the journey ID to connect.');
|
|
121
|
+
const { client } = await connect(journeyId);
|
|
122
|
+
client.close();
|
|
123
|
+
console.log(flags['--remember'] ? 'This journey is connected and the keys are remembered.' : 'Journey access was approved. No keys were saved; use wayfinding mcp --connect <journey-id> for an in-memory agent session, or --journey on each command.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
let held;
|
|
127
|
+
const getClient = async () => {
|
|
128
|
+
if (held)
|
|
129
|
+
return held;
|
|
130
|
+
const oneShot = command === 'mcp' ? flag(flags, '--connect') : flag(flags, '--journey');
|
|
131
|
+
if (oneShot) {
|
|
132
|
+
held = (await connect(oneShot, command === 'mcp')).client;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const session = await loadRemembered({ folder: keyFolder, passphrase: secret });
|
|
136
|
+
if (!session)
|
|
137
|
+
throw new Error('No remembered journey connection. Use wayfinding connect <journey-id> --remember, or --journey <journey-id> to ask for approval for this command. For an in-memory MCP session, use wayfinding mcp --connect <journey-id>.');
|
|
138
|
+
held = new JourneyClient(session, flags['--no-cache'] ? {} : { cacheRoot: cacheFolder() });
|
|
139
|
+
}
|
|
140
|
+
if ((flags['--cache'] || flags['--no-cache']) && held) {
|
|
141
|
+
// Cache is chosen when the client is constructed; ephemeral sessions default to no cache.
|
|
142
|
+
if (flags['--cache'] && oneShot)
|
|
143
|
+
held = new JourneyClient(held.session, { cacheRoot: cacheFolder() });
|
|
144
|
+
if (flags['--no-cache'] && oneShot)
|
|
145
|
+
held = new JourneyClient(held.session);
|
|
146
|
+
}
|
|
147
|
+
return held;
|
|
148
|
+
};
|
|
149
|
+
if (command === 'mcp') {
|
|
150
|
+
await runMcp(getClient);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const client = await getClient();
|
|
155
|
+
let result;
|
|
156
|
+
switch (command) {
|
|
157
|
+
case 'add': {
|
|
158
|
+
const type = flag(flags, '--type'), title = flag(flags, '--title'), body = flag(flags, '--body');
|
|
159
|
+
if (!type || !title || !body)
|
|
160
|
+
throw new Error('Add needs --type, --title and --body.');
|
|
161
|
+
result = await client.add({ type, title, body, tags: flag(flags, '--tags')?.split(',').map(tag => tag.trim()).filter(Boolean) ?? [] });
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case 'import':
|
|
165
|
+
if (!positional[0])
|
|
166
|
+
throw new Error('Give a Markdown file or folder to import.');
|
|
167
|
+
result = await importMarkdown(client, positional[0]);
|
|
168
|
+
break;
|
|
169
|
+
case 'list':
|
|
170
|
+
result = await client.list(flag(flags, '--type'));
|
|
171
|
+
break;
|
|
172
|
+
case 'search':
|
|
173
|
+
if (!positional[0])
|
|
174
|
+
throw new Error('Give words to search for in the journey.');
|
|
175
|
+
result = await client.search(positional.join(' '));
|
|
176
|
+
break;
|
|
177
|
+
case 'show':
|
|
178
|
+
if (!positional[0])
|
|
179
|
+
throw new Error('Give the journey item ID to show.');
|
|
180
|
+
result = await client.show(positional[0]);
|
|
181
|
+
break;
|
|
182
|
+
case 'comment':
|
|
183
|
+
if (!positional[0] || !positional[1])
|
|
184
|
+
throw new Error('Give the journey item ID and comment text.');
|
|
185
|
+
result = await client.comment(positional[0], positional.slice(1).join(' '));
|
|
186
|
+
break;
|
|
187
|
+
case 'comments':
|
|
188
|
+
if (!positional[0])
|
|
189
|
+
throw new Error('Give the journey item ID to read comments.');
|
|
190
|
+
result = await client.comments(positional[0]);
|
|
191
|
+
break;
|
|
192
|
+
case 'status':
|
|
193
|
+
result = await client.status();
|
|
194
|
+
break;
|
|
195
|
+
default: throw new Error('Unknown journey command: ' + command + '. Try wayfinding --help.');
|
|
196
|
+
}
|
|
197
|
+
console.log(JSON.stringify(result, null, 2));
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
held?.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { JourneyClient } from './journey.js';
|
|
2
|
+
import type { RememberedAgent } from './storage.js';
|
|
3
|
+
export interface ConnectOptions {
|
|
4
|
+
server?: string;
|
|
5
|
+
scope?: 'read' | 'readwrite';
|
|
6
|
+
name?: string;
|
|
7
|
+
remember?: boolean;
|
|
8
|
+
keyFolder?: string;
|
|
9
|
+
passphrase?: string;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
pollMs?: number;
|
|
12
|
+
onApproval?: (url: string, code: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface Connection {
|
|
15
|
+
client: JourneyClient;
|
|
16
|
+
session: RememberedAgent;
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_SERVER = "https://app.wayfinding.support";
|
|
19
|
+
export declare function connectJourney(journeyId: string, options?: ConnectOptions): Promise<Connection>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createAgeIdentity, createSigningIdentity, validAgentName } from '@ai-wayfinding/core';
|
|
2
|
+
import { JourneyClient } from './journey.js';
|
|
3
|
+
import { saveRemembered } from './storage.js';
|
|
4
|
+
export const DEFAULT_SERVER = 'https://app.wayfinding.support';
|
|
5
|
+
const sleep = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
6
|
+
export async function connectJourney(journeyId, options = {}) {
|
|
7
|
+
const server = (options.server ?? DEFAULT_SERVER).replace(/\/$/, '');
|
|
8
|
+
const origin = new URL(server);
|
|
9
|
+
if (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && (origin.hostname === 'localhost' || origin.hostname === '127.0.0.1')))
|
|
10
|
+
throw new Error('The journey server must use HTTPS (except on this computer).');
|
|
11
|
+
if (options.keyFolder && !options.remember)
|
|
12
|
+
throw new Error('--key-folder needs --remember.');
|
|
13
|
+
if (options.name !== undefined && !validAgentName(options.name))
|
|
14
|
+
throw new Error('Agent name must be trimmed, 1–60 characters and contain no control characters.');
|
|
15
|
+
const identity = await createAgeIdentity(), signing = await createSigningIdentity();
|
|
16
|
+
const response = await (options.fetch ?? fetch)(server + '/v1/agent-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin.origin, 'X-Wayfinding': '1' }, body: JSON.stringify({ journeyId, agentPublicKey: { recipient: identity.recipient, signingKey: signing.publicKey }, requestedScope: options.scope ?? 'read', remembered: options.remember === true, ...(options.name === undefined ? {} : { name: options.name }) }) });
|
|
17
|
+
if (!response.ok)
|
|
18
|
+
throw new Error('Could not ask to join this journey (' + response.status + ').');
|
|
19
|
+
const created = await response.json();
|
|
20
|
+
options.onApproval?.(created.approvalUrl, created.code);
|
|
21
|
+
let approved;
|
|
22
|
+
for (;;) {
|
|
23
|
+
await sleep(options.pollMs ?? 3000);
|
|
24
|
+
const polled = await (options.fetch ?? fetch)(server + '/v1/agent-sessions/' + encodeURIComponent(created.id));
|
|
25
|
+
if (!polled.ok)
|
|
26
|
+
throw new Error('Could not check journey approval (' + polled.status + ').');
|
|
27
|
+
approved = await polled.json();
|
|
28
|
+
if (approved.status === 'approved')
|
|
29
|
+
break;
|
|
30
|
+
if (approved.status !== 'pending')
|
|
31
|
+
throw new Error(approved.status === 'locked' ? 'Too many wrong approval codes. Ask for a new journey connection.' : 'Journey approval expired or was refused. Connect again.');
|
|
32
|
+
}
|
|
33
|
+
if (options.remember && !approved.remembered)
|
|
34
|
+
throw new Error('The person did not approve remembered journey access.');
|
|
35
|
+
if (!Number.isFinite(approved.expiresAt) || approved.expiresAt <= Date.now() || approved.expiresAt > Date.now() + (options.remember ? 90 * 86_400_000 : 8 * 3_600_000) + 60_000)
|
|
36
|
+
throw new Error('Invalid journey access expiry. Connect again.');
|
|
37
|
+
const session = { server, journeyId, sessionId: created.id, principal: approved.principal, identity: identity.identity, recipient: identity.recipient, signingPrivateKey: signing.privateKey, signingKey: signing.publicKey, scope: approved.scope, expiresAt: approved.expiresAt };
|
|
38
|
+
const client = new JourneyClient(session, { fetch: options.fetch });
|
|
39
|
+
try {
|
|
40
|
+
await client.status();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
client.close();
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (options.remember) {
|
|
47
|
+
const store = options.keyFolder ? { folder: options.keyFolder, passphrase: options.passphrase } : {};
|
|
48
|
+
await saveRemembered(session, store);
|
|
49
|
+
}
|
|
50
|
+
return { client, session };
|
|
51
|
+
}
|
package/dist/import.d.ts
ADDED
package/dist/import.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
/** Read only the simple fields shared by Markdown front matter and journey items. */
|
|
4
|
+
export async function importMarkdown(client, path) {
|
|
5
|
+
const files = [];
|
|
6
|
+
async function collect(name) {
|
|
7
|
+
if ((await stat(name)).isDirectory()) {
|
|
8
|
+
for (const entry of await readdir(name, { withFileTypes: true })) {
|
|
9
|
+
if (entry.isFile() || entry.isDirectory())
|
|
10
|
+
await collect(join(name, entry.name));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else if (/\.md$/i.test(name))
|
|
14
|
+
files.push(name);
|
|
15
|
+
}
|
|
16
|
+
await collect(path);
|
|
17
|
+
if (files.length === 0)
|
|
18
|
+
throw new Error('No Markdown files found to import into this journey.');
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const file of files.sort()) {
|
|
21
|
+
const markdown = await readFile(file, 'utf8');
|
|
22
|
+
const front = {};
|
|
23
|
+
let body = markdown;
|
|
24
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(markdown);
|
|
25
|
+
if (match) {
|
|
26
|
+
body = markdown.slice(match[0].length);
|
|
27
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
28
|
+
const pair = /^([A-Za-z][A-Za-z0-9]*):\s*(.*?)\s*$/.exec(line);
|
|
29
|
+
if (pair)
|
|
30
|
+
front[pair[1]] = pair[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const title = front.title || basename(file).replace(/\.md$/i, '');
|
|
34
|
+
const type = front.itemType || front.type || 'resource';
|
|
35
|
+
const tags = front.tags ? (front.tags.startsWith('[') ? front.tags.slice(1, -1) : front.tags).split(',').map(tag => tag.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean) : [];
|
|
36
|
+
const item = { type, title, body, tags };
|
|
37
|
+
if (front.created)
|
|
38
|
+
item.created = front.created;
|
|
39
|
+
if (front.resourceKind)
|
|
40
|
+
item.resourceKind = front.resourceKind;
|
|
41
|
+
if (front.sharedFrom)
|
|
42
|
+
item.sharedFrom = front.sharedFrom;
|
|
43
|
+
results.push(await client.add(item));
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { connectJourney } from './connection.js';
|
|
2
|
+
export type { ConnectOptions, Connection } from './connection.js';
|
|
3
|
+
export { JourneyClient } from './journey.js';
|
|
4
|
+
export type { AddInput, ItemView, JourneyOptions } from './journey.js';
|
|
5
|
+
export { createWayfindingServer } from './mcp.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CommentBody, ItemBody } from '@ai-wayfinding/core';
|
|
2
|
+
import type { RememberedAgent } from './storage.js';
|
|
3
|
+
export interface AddInput {
|
|
4
|
+
type: string;
|
|
5
|
+
title: string;
|
|
6
|
+
body: string;
|
|
7
|
+
tags: string[];
|
|
8
|
+
created?: string;
|
|
9
|
+
resourceKind?: string;
|
|
10
|
+
sharedFrom?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface JourneyOptions {
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
cacheRoot?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ItemView {
|
|
17
|
+
item: ItemBody;
|
|
18
|
+
comments: CommentBody[];
|
|
19
|
+
}
|
|
20
|
+
export declare class JourneyClient {
|
|
21
|
+
readonly session: RememberedAgent;
|
|
22
|
+
private readonly options;
|
|
23
|
+
private readonly fetcher;
|
|
24
|
+
private readonly keys;
|
|
25
|
+
constructor(session: RememberedAgent, options?: JourneyOptions);
|
|
26
|
+
private request;
|
|
27
|
+
private verified;
|
|
28
|
+
private records;
|
|
29
|
+
private writable;
|
|
30
|
+
private write;
|
|
31
|
+
add(input: AddInput): Promise<ItemBody>;
|
|
32
|
+
list(type?: string): Promise<ItemBody[]>;
|
|
33
|
+
search(text: string): Promise<ItemBody[]>;
|
|
34
|
+
show(id: string): Promise<ItemView>;
|
|
35
|
+
comments(id: string): Promise<CommentBody[]>;
|
|
36
|
+
comment(id: string, text: string): Promise<CommentBody>;
|
|
37
|
+
status(): Promise<{
|
|
38
|
+
journeyId: string;
|
|
39
|
+
principal: string;
|
|
40
|
+
scope: 'read' | 'readwrite';
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
seq: number;
|
|
43
|
+
}>;
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
package/dist/journey.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { CLIENT_VERSION, meetsMinClientVersion, newId, open, parseRecord, seal, unwrapJourneyKey, verifyLog } from '@ai-wayfinding/core';
|
|
2
|
+
import { readCache, writeCache } from './cache.js';
|
|
3
|
+
import { signedHeaders } from './signing.js';
|
|
4
|
+
const historyError = 'This journey history could not be verified. Stop and ask a member for help.';
|
|
5
|
+
function decode(value) { return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); }
|
|
6
|
+
export class JourneyClient {
|
|
7
|
+
session;
|
|
8
|
+
options;
|
|
9
|
+
fetcher;
|
|
10
|
+
keys = new Map();
|
|
11
|
+
constructor(session, options = {}) {
|
|
12
|
+
this.session = session;
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.fetcher = options.fetch ?? fetch;
|
|
15
|
+
}
|
|
16
|
+
async request(path, method = 'GET', data) {
|
|
17
|
+
if (this.session.expiresAt <= Date.now())
|
|
18
|
+
throw new Error('Your journey agent access has expired. Connect again.');
|
|
19
|
+
const body = data === undefined ? '' : JSON.stringify(data);
|
|
20
|
+
const route = '/v1' + path;
|
|
21
|
+
const headers = await signedHeaders(this.session.signingPrivateKey, method, route, body);
|
|
22
|
+
const result = await this.fetcher(this.session.server + route, { method, headers: { ...headers, 'X-Agent-Session': this.session.sessionId, ...(data === undefined ? {} : { 'Content-Type': 'application/json', Origin: new URL(this.session.server).origin, 'X-Wayfinding': '1' }) }, ...(data === undefined ? {} : { body }) });
|
|
23
|
+
if (!result.ok) {
|
|
24
|
+
if (result.status === 403 || result.status === 401)
|
|
25
|
+
throw new Error('Access to this journey has ended');
|
|
26
|
+
const error = await result.json().catch(() => null);
|
|
27
|
+
const code = error && typeof error === 'object' && 'error' in error && error.error && typeof error.error === 'object' && 'code' in error.error ? String(error.error.code) : String(result.status);
|
|
28
|
+
throw new Error('Journey request failed (' + code + ').');
|
|
29
|
+
}
|
|
30
|
+
return result.json();
|
|
31
|
+
}
|
|
32
|
+
async verified() {
|
|
33
|
+
// Never trust a cache head without decrypting and verifying the entire signed chain.
|
|
34
|
+
const cached = this.options.cacheRoot ? await readCache(this.options.cacheRoot, this.session.journeyId).catch(() => null) : null;
|
|
35
|
+
const log = cached?.log ? [...cached.log] : [];
|
|
36
|
+
for (;;) {
|
|
37
|
+
const after = log.length ? '?after=' + (log.at(-1).seq) : '';
|
|
38
|
+
const page = (await this.request(`/journeys/${this.session.journeyId}/log${after}`)).log;
|
|
39
|
+
if (!Array.isArray(page))
|
|
40
|
+
throw new Error(historyError);
|
|
41
|
+
log.push(...page);
|
|
42
|
+
if (log.length > 100_000)
|
|
43
|
+
throw new Error('Journey history is too large.');
|
|
44
|
+
if (page.length < 1000)
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
const response = await this.request(`/journeys/${this.session.journeyId}/wraps/me`);
|
|
48
|
+
const epochs = new Map();
|
|
49
|
+
for (const wrap of response.wraps) {
|
|
50
|
+
if (!Number.isSafeInteger(wrap.epoch) || wrap.epoch < 1)
|
|
51
|
+
throw new Error(historyError);
|
|
52
|
+
const key = this.keys.get(wrap.epoch) ?? await unwrapJourneyKey({ epoch: wrap.epoch, recipient: this.session.principal, ciphertext: wrap.wrap }, this.session.identity);
|
|
53
|
+
this.keys.set(wrap.epoch, key);
|
|
54
|
+
epochs.set(wrap.epoch, key);
|
|
55
|
+
}
|
|
56
|
+
const entries = [];
|
|
57
|
+
for (const row of log) {
|
|
58
|
+
const envelope = decode(row.entry);
|
|
59
|
+
if (row.seq !== entries.length || envelope.outside?.journey !== this.session.journeyId)
|
|
60
|
+
throw new Error(historyError);
|
|
61
|
+
const key = epochs.get(envelope.outside.epoch);
|
|
62
|
+
if (!key)
|
|
63
|
+
throw new Error('Missing a key for the journey history. Ask a member to approve access again.');
|
|
64
|
+
const record = await open(envelope, key).catch(() => { throw new Error(historyError); });
|
|
65
|
+
if (record.type !== 'membership' || record.typeVersion !== 1)
|
|
66
|
+
throw new Error(historyError);
|
|
67
|
+
const wrapped = record.body.entry;
|
|
68
|
+
const entry = typeof wrapped === 'string' ? JSON.parse(wrapped) : record.body;
|
|
69
|
+
entries.push(entry);
|
|
70
|
+
}
|
|
71
|
+
const checked = await verifyLog(entries);
|
|
72
|
+
if (!checked.ok || checked.state.journey !== this.session.journeyId)
|
|
73
|
+
throw new Error(historyError);
|
|
74
|
+
const mine = checked.state.members[this.session.principal]?.member;
|
|
75
|
+
if (!mine || mine.kind !== 'agent' || mine.signingKey !== this.session.signingKey || mine.recipient !== this.session.recipient || mine.expiresAt && Date.parse(mine.expiresAt) <= Date.now())
|
|
76
|
+
throw new Error('Access to this journey has ended');
|
|
77
|
+
if (!meetsMinClientVersion(CLIENT_VERSION, checked.state.minClientVersion))
|
|
78
|
+
throw new Error('This journey needs a newer client version. Update wayfinding before writing.');
|
|
79
|
+
if (!epochs.has(checked.state.currentEpoch))
|
|
80
|
+
throw new Error('The journey key changed. Ask a member to reconnect this agent.');
|
|
81
|
+
if (this.options.cacheRoot && (!cached || cached.seq !== checked.state.lastSeq || cached.hash !== checked.state.lastHash))
|
|
82
|
+
await writeCache(this.options.cacheRoot, this.session.journeyId, { seq: checked.state.lastSeq, hash: checked.state.lastHash, log, records: cached?.records ?? [] });
|
|
83
|
+
return { state: checked.state, epochs, log };
|
|
84
|
+
}
|
|
85
|
+
async records(verified) {
|
|
86
|
+
const cached = this.options.cacheRoot ? await readCache(this.options.cacheRoot, this.session.journeyId).catch(() => null) : null;
|
|
87
|
+
const envelopes = cached?.records ? [...cached.records] : [];
|
|
88
|
+
let after = envelopes.at(-1)?.outside.seq ?? 0;
|
|
89
|
+
for (;;) {
|
|
90
|
+
const page = (await this.request(`/journeys/${this.session.journeyId}/records?after=${after}&limit=100`)).records;
|
|
91
|
+
if (!Array.isArray(page))
|
|
92
|
+
throw new Error('Invalid journey records.');
|
|
93
|
+
for (const envelope of page) {
|
|
94
|
+
if (envelope.outside?.seq !== after + 1 || envelope.outside.journey !== this.session.journeyId)
|
|
95
|
+
throw new Error('Invalid journey records.');
|
|
96
|
+
after = envelope.outside.seq;
|
|
97
|
+
envelopes.push(envelope);
|
|
98
|
+
}
|
|
99
|
+
if (envelopes.length > 100_000)
|
|
100
|
+
throw new Error('Too many journey records.');
|
|
101
|
+
if (page.length < 100)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
const records = [];
|
|
105
|
+
for (const envelope of envelopes) {
|
|
106
|
+
const key = verified.epochs.get(envelope.outside.epoch);
|
|
107
|
+
if (!key || envelope.outside.epoch > verified.state.currentEpoch)
|
|
108
|
+
throw new Error('Missing a key for the journey records.');
|
|
109
|
+
const record = await open(envelope, key);
|
|
110
|
+
parseRecord(record);
|
|
111
|
+
records.push(record);
|
|
112
|
+
}
|
|
113
|
+
if (this.options.cacheRoot)
|
|
114
|
+
await writeCache(this.options.cacheRoot, this.session.journeyId, { seq: verified.state.lastSeq, hash: verified.state.lastHash, log: verified.log, records: envelopes });
|
|
115
|
+
return records;
|
|
116
|
+
}
|
|
117
|
+
async writable() {
|
|
118
|
+
const verified = await this.verified();
|
|
119
|
+
if (verified.state.members[this.session.principal]?.member.scope !== 'readwrite')
|
|
120
|
+
throw new Error('This journey is read-only for this agent. Ask a person to approve write access.');
|
|
121
|
+
return verified;
|
|
122
|
+
}
|
|
123
|
+
async write(record, verified) {
|
|
124
|
+
if (parseRecord(record).kind !== 'known')
|
|
125
|
+
throw new Error('Unknown journey record type.');
|
|
126
|
+
const { seq, epoch } = await this.request(`/journeys/${this.session.journeyId}/seq`, 'POST', {});
|
|
127
|
+
if (epoch !== verified.state.currentEpoch)
|
|
128
|
+
throw new Error('The journey key changed. Reload before writing.');
|
|
129
|
+
const key = verified.epochs.get(epoch);
|
|
130
|
+
const envelope = await seal(record, { id: newId(), journey: this.session.journeyId, seq, epoch, createdAt: new Date().toISOString() }, key);
|
|
131
|
+
await this.request(`/journeys/${this.session.journeyId}/records`, 'POST', { envelope });
|
|
132
|
+
}
|
|
133
|
+
async add(input) {
|
|
134
|
+
const verified = await this.writable();
|
|
135
|
+
const item = { id: newId(), itemType: input.type, title: input.title, body: input.body, tags: [...input.tags], author: this.session.principal, authoredBy: 'agent', created: input.created ?? new Date().toISOString(), ...(input.resourceKind ? { resourceKind: input.resourceKind } : {}), ...(input.sharedFrom ? { sharedFrom: input.sharedFrom } : {}) };
|
|
136
|
+
await this.write({ type: 'item', typeVersion: 1, body: item }, verified);
|
|
137
|
+
return item;
|
|
138
|
+
}
|
|
139
|
+
async list(type) {
|
|
140
|
+
const records = await this.records(await this.verified());
|
|
141
|
+
const roots = new Map(), items = new Map(), deleted = new Set();
|
|
142
|
+
for (const record of records) {
|
|
143
|
+
if (record.type === 'item') {
|
|
144
|
+
const item = record.body;
|
|
145
|
+
const root = item.replaces ? roots.get(item.replaces) : item.id;
|
|
146
|
+
if (root) {
|
|
147
|
+
roots.set(item.id, root);
|
|
148
|
+
items.set(root, item);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (record.type === 'delete')
|
|
152
|
+
deleted.add(String(record.body.target));
|
|
153
|
+
}
|
|
154
|
+
return [...items.entries()].filter(([id, item]) => !deleted.has(id) && (!type || type === item.itemType)).map(([, item]) => item);
|
|
155
|
+
}
|
|
156
|
+
async search(text) { const needle = text.toLocaleLowerCase(); return (await this.list()).filter(item => [item.title, item.body, item.itemType, ...item.tags].some(value => value.toLocaleLowerCase().includes(needle))); }
|
|
157
|
+
async show(id) {
|
|
158
|
+
const records = await this.records(await this.verified());
|
|
159
|
+
const roots = new Map(), items = new Map(), comments = [], deleted = new Set();
|
|
160
|
+
for (const record of records) {
|
|
161
|
+
if (record.type === 'item') {
|
|
162
|
+
const item = record.body, root = item.replaces ? roots.get(item.replaces) : item.id;
|
|
163
|
+
if (root) {
|
|
164
|
+
roots.set(item.id, root);
|
|
165
|
+
items.set(root, item);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (record.type === 'comment')
|
|
169
|
+
comments.push(record.body);
|
|
170
|
+
if (record.type === 'delete')
|
|
171
|
+
deleted.add(String(record.body.target));
|
|
172
|
+
}
|
|
173
|
+
const root = roots.get(id), item = root && !deleted.has(root) ? items.get(root) : undefined;
|
|
174
|
+
if (!item)
|
|
175
|
+
throw new Error('No journey item has that ID.');
|
|
176
|
+
return { item, comments: comments.filter(comment => roots.get(comment.item) === root) };
|
|
177
|
+
}
|
|
178
|
+
async comments(id) { return (await this.show(id)).comments; }
|
|
179
|
+
async comment(id, text) {
|
|
180
|
+
const verified = await this.writable();
|
|
181
|
+
const records = await this.records(verified);
|
|
182
|
+
const root = records.find(record => record.type === 'item' && record.body.id === id);
|
|
183
|
+
if (!root)
|
|
184
|
+
throw new Error('No journey item has that ID.');
|
|
185
|
+
const comment = { id: newId(), item: id, onVersion: id, author: this.session.principal, authoredBy: 'agent', at: new Date().toISOString(), body: text };
|
|
186
|
+
await this.write({ type: 'comment', typeVersion: 1, body: comment }, verified);
|
|
187
|
+
return comment;
|
|
188
|
+
}
|
|
189
|
+
async status() {
|
|
190
|
+
const { state } = await this.verified();
|
|
191
|
+
return { journeyId: this.session.journeyId, principal: this.session.principal, scope: state.members[this.session.principal].member.scope, expiresAt: this.session.expiresAt, seq: state.lastSeq };
|
|
192
|
+
}
|
|
193
|
+
close() { for (const value of this.keys.values())
|
|
194
|
+
value.key.fill(0); this.keys.clear(); }
|
|
195
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import type { JourneyClient } from './journey.js';
|
|
3
|
+
export declare function createWayfindingServer(getClient: () => Promise<JourneyClient>): Server;
|
|
4
|
+
export declare function runMcp(getClient: () => Promise<JourneyClient>): Promise<void>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { importMarkdown } from './import.js';
|
|
5
|
+
const text = { type: 'string' };
|
|
6
|
+
const schema = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
7
|
+
const tools = [
|
|
8
|
+
{ name: 'add', description: 'Add an item to the journey. The person must approve read-write access first; the agent cannot change membership or access.', inputSchema: schema({ type: text, title: text, body: text, tags: { type: 'array', items: text } }, ['type', 'title', 'body']) },
|
|
9
|
+
{ name: 'import', description: 'Add Markdown files from this computer to the journey. The person must approve read-write access first.', inputSchema: schema({ path: text }, ['path']) },
|
|
10
|
+
{ name: 'list', description: 'List decrypted journey items, optionally by type. The person must approve access first.', inputSchema: schema({ type: text }) },
|
|
11
|
+
{ name: 'search', description: 'Find words in decrypted journey item titles, tags, and bodies. The person must approve access first.', inputSchema: schema({ text: text }) },
|
|
12
|
+
{ name: 'show', description: 'Read one journey item by its ID. The person must approve access first.', inputSchema: schema({ id: text }, ['id']) },
|
|
13
|
+
{ name: 'comment', description: 'Add a comment to a journey item. The person must approve read-write access first.', inputSchema: schema({ id: text, text: text }, ['id', 'text']) },
|
|
14
|
+
{ name: 'comments', description: 'Read comments for a journey item. The person must approve access first.', inputSchema: schema({ id: text }, ['id']) },
|
|
15
|
+
{ name: 'status', description: 'Check this journey and your approved scope.', inputSchema: schema({}) },
|
|
16
|
+
{ name: 'connect_status', description: 'Check whether a person has approved this agent for a journey. No journey access is granted by this tool.', inputSchema: schema({}) }
|
|
17
|
+
];
|
|
18
|
+
function field(args, name) {
|
|
19
|
+
if (typeof args[name] !== 'string' || !args[name])
|
|
20
|
+
throw new Error(name + ' must be text.');
|
|
21
|
+
return args[name];
|
|
22
|
+
}
|
|
23
|
+
export function createWayfindingServer(getClient) {
|
|
24
|
+
const server = new Server({ name: 'wayfinding', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
25
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
26
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
27
|
+
const args = request.params.arguments ?? {};
|
|
28
|
+
try {
|
|
29
|
+
let result;
|
|
30
|
+
if (request.params.name === 'connect_status') {
|
|
31
|
+
try {
|
|
32
|
+
const client = await getClient();
|
|
33
|
+
result = await client.status();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
result = { connected: false, message: error instanceof Error ? error.message : String(error) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const client = await getClient();
|
|
41
|
+
switch (request.params.name) {
|
|
42
|
+
case 'add': {
|
|
43
|
+
if (args.tags !== undefined && (!Array.isArray(args.tags) || !args.tags.every(tag => typeof tag === 'string')))
|
|
44
|
+
throw new Error('Journey tags must be text.');
|
|
45
|
+
result = await client.add({ type: field(args, 'type'), title: field(args, 'title'), body: field(args, 'body'), tags: args.tags === undefined ? [] : [...args.tags] });
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'import':
|
|
49
|
+
result = await importMarkdown(client, field(args, 'path'));
|
|
50
|
+
break;
|
|
51
|
+
case 'list':
|
|
52
|
+
result = await client.list(typeof args.type === 'string' ? args.type : undefined);
|
|
53
|
+
break;
|
|
54
|
+
case 'search':
|
|
55
|
+
result = await client.search(field(args, 'text'));
|
|
56
|
+
break;
|
|
57
|
+
case 'show':
|
|
58
|
+
result = await client.show(field(args, 'id'));
|
|
59
|
+
break;
|
|
60
|
+
case 'comment':
|
|
61
|
+
result = await client.comment(field(args, 'id'), field(args, 'text'));
|
|
62
|
+
break;
|
|
63
|
+
case 'comments':
|
|
64
|
+
result = await client.comments(field(args, 'id'));
|
|
65
|
+
break;
|
|
66
|
+
case 'status':
|
|
67
|
+
result = await client.status();
|
|
68
|
+
break;
|
|
69
|
+
default: throw new Error('Unknown journey tool: ' + request.params.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }] };
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return server;
|
|
79
|
+
}
|
|
80
|
+
export async function runMcp(getClient) {
|
|
81
|
+
await createWayfindingServer(getClient).connect(new StdioServerTransport());
|
|
82
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SigningIdentity } from '@ai-wayfinding/core';
|
|
2
|
+
export interface AgentHeaders {
|
|
3
|
+
'X-Agent-Timestamp': string;
|
|
4
|
+
'X-Agent-Nonce': string;
|
|
5
|
+
'X-Agent-Signature': string;
|
|
6
|
+
}
|
|
7
|
+
/** Sign the exact bytes sent to the server, including the path and query string. */
|
|
8
|
+
export declare function signedHeaders(privateKey: SigningIdentity['privateKey'], method: string, path: string, body: string, timestamp?: string, nonce?: `${string}-${string}-${string}-${string}-${string}`): Promise<AgentHeaders>;
|
package/dist/signing.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { importSigningKey } from '@ai-wayfinding/core';
|
|
2
|
+
/** Sign the exact bytes sent to the server, including the path and query string. */
|
|
3
|
+
export async function signedHeaders(privateKey, method, path, body, timestamp = String(Date.now()), nonce = crypto.randomUUID()) {
|
|
4
|
+
const hash = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))).toString('base64url');
|
|
5
|
+
const message = [method.toUpperCase(), path, hash, timestamp, nonce].join('\n');
|
|
6
|
+
const signature = await crypto.subtle.sign('Ed25519', await importSigningKey(privateKey), new TextEncoder().encode(message));
|
|
7
|
+
return { 'X-Agent-Timestamp': timestamp, 'X-Agent-Nonce': nonce, 'X-Agent-Signature': Buffer.from(signature).toString('base64url') };
|
|
8
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RememberedAgent {
|
|
2
|
+
server: string;
|
|
3
|
+
journeyId: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
principal: string;
|
|
6
|
+
identity: string;
|
|
7
|
+
recipient: string;
|
|
8
|
+
signingPrivateKey: string;
|
|
9
|
+
signingKey: string;
|
|
10
|
+
scope: 'read' | 'readwrite';
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
export type CommandRunner = (command: string, args: string[], input?: string) => Promise<string>;
|
|
14
|
+
export interface StoreOptions {
|
|
15
|
+
folder?: string;
|
|
16
|
+
passphrase?: string;
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
run?: CommandRunner;
|
|
19
|
+
}
|
|
20
|
+
export declare const runCommand: CommandRunner;
|
|
21
|
+
export declare function saveRemembered(value: RememberedAgent, options?: StoreOptions): Promise<void>;
|
|
22
|
+
export declare function loadRemembered(options?: StoreOptions): Promise<RememberedAgent | null>;
|
|
23
|
+
export declare function forgetRemembered(options?: StoreOptions): Promise<void>;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as age from 'age-encryption';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const service = 'ai-wayfinding-journey-agent';
|
|
6
|
+
const account = 'remembered-session';
|
|
7
|
+
const location = (folder) => join(folder, 'agent.age');
|
|
8
|
+
export const runCommand = (command, args, input) => new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
10
|
+
let output = '', error = '';
|
|
11
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => { output += chunk; if (output.length > 1_000_000)
|
|
12
|
+
child.kill(); });
|
|
13
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => { error += chunk; if (error.length > 1_000_000)
|
|
14
|
+
child.kill(); });
|
|
15
|
+
child.on('error', reject);
|
|
16
|
+
child.on('close', code => code === 0 ? resolve(output.trim()) : reject(new Error(command + ' could not access the OS keychain. ' + (error.includes('not found') ? 'Install or unlock it.' : 'Check that it is available and unlocked.'))));
|
|
17
|
+
child.stdin.end(input);
|
|
18
|
+
});
|
|
19
|
+
function validate(value) {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
21
|
+
throw new Error('Invalid saved journey keys. Disconnect and connect again.');
|
|
22
|
+
const row = value;
|
|
23
|
+
for (const key of ['server', 'journeyId', 'sessionId', 'principal', 'identity', 'recipient', 'signingPrivateKey', 'signingKey'])
|
|
24
|
+
if (typeof row[key] !== 'string' || !row[key])
|
|
25
|
+
throw new Error('Invalid saved journey keys. Disconnect and connect again.');
|
|
26
|
+
if (row.scope !== 'read' && row.scope !== 'readwrite' || typeof row.expiresAt !== 'number' || !Number.isFinite(row.expiresAt))
|
|
27
|
+
throw new Error('Invalid saved journey keys. Disconnect and connect again.');
|
|
28
|
+
if (row.expiresAt <= Date.now())
|
|
29
|
+
throw new Error('Your journey agent access has expired. Disconnect and connect again.');
|
|
30
|
+
return { server: row.server, journeyId: row.journeyId, sessionId: row.sessionId, principal: row.principal, identity: row.identity, recipient: row.recipient, signingPrivateKey: row.signingPrivateKey, signingKey: row.signingKey, scope: row.scope, expiresAt: row.expiresAt };
|
|
31
|
+
}
|
|
32
|
+
function supported(options) {
|
|
33
|
+
const platform = options.platform ?? process.platform;
|
|
34
|
+
if (platform !== 'darwin' && platform !== 'linux')
|
|
35
|
+
throw new Error('Remembered journey keys are not supported on this OS. Use --key-folder with a passphrase instead.');
|
|
36
|
+
return { platform, run: options.run ?? runCommand };
|
|
37
|
+
}
|
|
38
|
+
async function privateFolder(folder) {
|
|
39
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
40
|
+
const info = await stat(folder);
|
|
41
|
+
if (!info.isDirectory())
|
|
42
|
+
throw new Error('Key folder must be a directory');
|
|
43
|
+
await chmod(folder, 0o700);
|
|
44
|
+
}
|
|
45
|
+
function requirePassphrase(options) {
|
|
46
|
+
if (!options.passphrase)
|
|
47
|
+
throw new Error('A passphrase is required for the journey key folder. Set WAYFINDING_PASSPHRASE or use the prompt.');
|
|
48
|
+
return options.passphrase;
|
|
49
|
+
}
|
|
50
|
+
export async function saveRemembered(value, options = {}) {
|
|
51
|
+
if (value.expiresAt <= Date.now())
|
|
52
|
+
throw new Error('Your journey agent access has expired.');
|
|
53
|
+
if (value.expiresAt > Date.now() + 90 * 86_400_000 + 60_000)
|
|
54
|
+
throw new Error('Remembered journey access cannot last more than 90 days.');
|
|
55
|
+
const json = JSON.stringify(validate(value));
|
|
56
|
+
if (options.folder) {
|
|
57
|
+
await privateFolder(options.folder);
|
|
58
|
+
const cipher = new age.Encrypter();
|
|
59
|
+
cipher.setPassphrase(requirePassphrase(options));
|
|
60
|
+
const bytes = await cipher.encrypt(new TextEncoder().encode(json));
|
|
61
|
+
const temp = join(options.folder, '.agent-' + crypto.randomUUID());
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(temp, bytes, { mode: 0o600, flag: 'wx' });
|
|
64
|
+
await rename(temp, location(options.folder));
|
|
65
|
+
await chmod(location(options.folder), 0o600);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await rm(temp, { force: true });
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const { platform, run } = supported(options);
|
|
73
|
+
if (platform === 'linux') {
|
|
74
|
+
await run('secret-tool', ['store', '--label=Wayfinding journey agent', 'service', service, 'account', account], json);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// security add-generic-password accepts passwords only in argv. Its interactive mode reads
|
|
78
|
+
// a base64url-encoded command from stdin instead, keeping the key out of the process list.
|
|
79
|
+
const encoded = Buffer.from(json).toString('base64url');
|
|
80
|
+
// security -i runs until EOF; "quit" is not a command and exits 1 even after a successful write.
|
|
81
|
+
await run('security', ['-i'], 'add-generic-password -U -s "' + service + '" -a "' + account + '" -w "' + encoded + '"\n');
|
|
82
|
+
}
|
|
83
|
+
export async function loadRemembered(options = {}) {
|
|
84
|
+
let raw;
|
|
85
|
+
if (options.folder) {
|
|
86
|
+
let bytes;
|
|
87
|
+
try {
|
|
88
|
+
bytes = await readFile(location(options.folder));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === 'ENOENT')
|
|
92
|
+
return null;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
const decipher = new age.Decrypter();
|
|
96
|
+
decipher.addPassphrase(requirePassphrase(options));
|
|
97
|
+
raw = new TextDecoder().decode(await decipher.decrypt(bytes));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const { platform, run } = supported(options);
|
|
101
|
+
try {
|
|
102
|
+
raw = platform === 'darwin' ? Buffer.from(await run('security', ['find-generic-password', '-s', service, '-a', account, '-w']), 'base64url').toString() : await run('secret-tool', ['lookup', 'service', service, 'account', account]);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!raw)
|
|
109
|
+
return null;
|
|
110
|
+
let value;
|
|
111
|
+
try {
|
|
112
|
+
value = JSON.parse(raw);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new Error('Invalid saved journey keys. Disconnect and connect again.');
|
|
116
|
+
}
|
|
117
|
+
return validate(value);
|
|
118
|
+
}
|
|
119
|
+
export async function forgetRemembered(options = {}) {
|
|
120
|
+
if (options.folder) {
|
|
121
|
+
await rm(location(options.folder), { force: true });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const { platform, run } = supported(options);
|
|
125
|
+
try {
|
|
126
|
+
await (platform === 'darwin' ? run('security', ['delete-generic-password', '-s', service, '-a', account]) : run('secret-tool', ['clear', 'service', service, 'account', account]));
|
|
127
|
+
}
|
|
128
|
+
catch { /* No saved key. */ }
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-wayfinding/client",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22"
|
|
7
|
+
},
|
|
8
|
+
"bin": {
|
|
9
|
+
"wayfinding": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:integration": "vitest run test/integration.test.ts",
|
|
26
|
+
"test:mcp": "vitest run test/integration.test.ts -t MCP"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@ai-wayfinding/core": "0.1.1",
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.30.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vitest": "^4.1.0",
|
|
36
|
+
"wrangler": "^4.138.0"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/AI-Wayfinding/journey.git",
|
|
42
|
+
"directory": "packages/client"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|