@josephyoung/pi-openviking 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph Young
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # pi-openviking
2
+
3
+ An independent OpenViking memory extension for pi, tracked by
4
+ [Dano #465](https://github.com/zhengchengqiaobusiness-arch/Dano/issues/465).
5
+
6
+ **Implementation in progress. No package release or production activation yet.**
7
+ The package name is `@josephyoung/pi-openviking`. Both entry modules compile
8
+ against pi 0.85.1. The real pi loader loads both entries and keeps a single
9
+ registration after reload; the standard entry fails closed without its launcher binding.
10
+ The Linux CLI now runs through the public pi entry; memory-enabled CLI acceptance and product integration continue under [#474](https://github.com/zhengchengqiaobusiness-arch/Dano/issues/474).
11
+
12
+ ## Implemented
13
+
14
+ - A Linux native-tool IPC worker with distinct UID, irreversible `no_new_privs`, explicit environment
15
+ allowlist, kernel identity checks, bounded requests/results, streamed updates
16
+ and cancellation. All seven native definitions and interactive `!`/`!!` shell
17
+ operations have worker proxies; the standard entry registers these proxies
18
+ with the memory extension. The protected CLI bootstraps and binds the worker.
19
+ - Immutable account/user binding and owner-checked private state files. Before
20
+ first data access, the authenticated health response must confirm the expected
21
+ account, user and USER role; an HTTP 200 with missing identity is insufficient.
22
+ - OS advisory locks, atomic replacement, file and directory fsync. The state
23
+ contains delivery/consent metadata and pending payloads, not a second memory
24
+ database. Kernel locks are released when a writer dies; no lease timeout can
25
+ grant a second process permission to repeat a remote mutation.
26
+ - Standard and host factory entry modules; the host never reads global credentials.
27
+ - Bounded, quoted recall in a non-persisted custom context message, with a host
28
+ tokenizer, per-request cache and pause/lifecycle invalidation.
29
+ - Default-off consent, explicit durable enqueue and stable source deduplication.
30
+ - A dedicated remote Session per save operation, with persisted causal phases.
31
+ An unknown message/commit outcome is reconciled through public APIs and never
32
+ blindly retransmitted. Missing or expired receipts require reconciliation;
33
+ they do not authorize repeating a non-idempotent call.
34
+ - Owner-level background scheduling with durable backoff, startup recovery,
35
+ bounded processing and shutdown. Exhausted reconciliation stays visibly blocked;
36
+ it never turns an unknown remote outcome into an automatic resend.
37
+ - Protected resource-loader configuration rejects workspace packages/extensions
38
+ while preserving explicitly supplied trusted Skills. Apply before package
39
+ resolution; `noExtensions` alone is insufficient.
40
+ - `ready` requires a completed matching task, an archive containing the source,
41
+ a matching memory diff, current content and a successful retrieval probe.
42
+ - Pause suppresses unsent operations and removes their pending bodies. Enabling
43
+ again does not replay those operations or authorize automatic collection.
44
+
45
+ ## Development
46
+
47
+ Requires Node.js >=22.19, a POSIX system, Python and a C++ compiler for the
48
+ `fs-ext` native advisory-lock binding. Install scripts for that audited native
49
+ module must run when installing. This first local run used Node 26.8.2; the
50
+ release gate must also test the selected Linux/Node deployment combination.
51
+
52
+ ```sh
53
+ npm ci
54
+ npm test
55
+ npm run check
56
+ ```
57
+
58
+ `npm test` covers independent processes, killed writers, concurrent processors,
59
+ response loss, source conflicts, owner mismatch, consent, recall budgets and
60
+ lifecycle behavior (38 tests in the current development run). It does not prove
61
+ end-to-end host isolation or UI acceptance.
62
+
63
+ For a separately provisioned disposable `extension-test-*` account, place an
64
+ owner-only JSON file outside the repository with `owner: {accountId, userId}`,
65
+ `baseUrl` (server origin) and a USER-level `apiKey`, then run:
66
+
67
+ ```sh
68
+ npm run build
69
+ node scripts/real-service.mjs /absolute/private/test-run/connection.json
70
+ ```
71
+
72
+ This invokes the configured server's extraction models and may incur charges.
73
+ The script rejects non-test account names, leaves synthetic data for inspection
74
+ and writes a credential-free result alongside the protected connection file.
75
+ Do not commit connection files, state, credentials or real user data.
76
+
77
+ ## Security and release boundaries
78
+
79
+ State permissions alone do not protect credentials from same-UID Agent tools.
80
+ The selected memory-enabled profile requires a trusted Linux host and a
81
+ separate tool UID, protected installation/state and no executable discovery
82
+ from tool-writable paths. That launcher must be integrated and verified before
83
+ activating memory in either pi or Dano. Current modules are not a substitute
84
+ for that boundary.
85
+
86
+ Further #474 gates: memory-enabled CLI acceptance and Dano worker lifecycle integration,
87
+ credential isolation, Dano exact-version integration, authenticated settings
88
+ and management, ordinary pi and real in-app Browser acceptance. Subsequent
89
+ #475–477 work covers full collection/lifecycle/governance and release gates.
90
+
91
+ ## Verified service combination
92
+
93
+ - OpenViking server: unmodified 0.4.20.
94
+ - OpenViking TypeScript SDK: 0.1.0, exact dependency.
95
+ - pi: 0.85.1, exact peer dependency (not bundled).
96
+ - Local actual-adapter save: 2026-09-18, synthetic fact reached `ready` and was
97
+ recalled after ~22.8 seconds. Every delivery step recreated the adapter from
98
+ persisted state. This is one functional run, not the PRD performance sample.
99
+
100
+ See `THIRD_PARTY_NOTICES.md` for distribution responsibilities.
101
+
102
+ ## Executed worker boundary
103
+
104
+ `scripts/linux-worker.mjs` exercises the actual worker in a disposable root
105
+ Linux container, with the three numeric identities supplied as arguments. The
106
+ 2026-09-18 run used pi 0.82.1 and Node 22.23.2: workspace read/write succeeded;
107
+ absolute and symlink read/write/edit against the host-private credential failed;
108
+ Bash inherited no synthetic memory key; updates and cancellation worked. The
109
+ container used no network and was removed after the run. This verifies the
110
+ worker primitive, not the final CLI/Dano launch and resource-discovery profile.
111
+
112
+ The actual background scheduler also completed a fresh real-service save on
113
+ 2026-09-18: it reached `ready` after 30.3 seconds and the subsequent query
114
+ retrieved the synthetic preference. No viewer or foreground delivery calls
115
+ advanced the operation. Reproduce with `scripts/real-scheduler.mjs` and a fresh
116
+ disposable account config, using the same private-config rules above.
117
+
118
+ The standard entry exposes `/memory enable` (interactive confirmation),
119
+ `/memory pause`, `/memory status`, and `/memory show <operation-id>`. Its enable
120
+ gate checks the exact worker used by its native tools. Automatic collection
121
+ remains unapproved. Read-only saved-content and credential-owner checks against
122
+ the actual service are reproducible with `scripts/real-read.mjs`.
123
+
124
+ The Linux worker integration now exercises the registered tool proxies and
125
+ interactive shell, preserving streaming and exit codes. Both cancellation
126
+ paths are checked for absence of a delayed file write, rather than only testing
127
+ that the caller receives a cancellation error.
128
+
129
+ ## Protected bootstrap primitive
130
+
131
+ `bootstrapProtectedWorker` validates canonical workspace, private agent/state
132
+ roots and a protected installation tree before starting the worker and dropping
133
+ bootstrap UID/GID. It rejects workspace overlap, replaceable ancestors,
134
+ worker-writable code, and installation symlinks escaping the installation root.
135
+ Private directories must already belong to the configured host UID with no
136
+ group/other permissions. Provisioning is explicit; this function never widens
137
+ permissions or repairs arbitrary paths.
138
+
139
+ The worker uses a configured absolute util-linux `setpriv` path to set
140
+ `no_new_privs` before Node executes. Kernel `NoNewPrivs: 1` is checked alongside
141
+ UID identity. The complete bootstrap primitive passed the real Linux worker
142
+ fixture, including its tool/interactive-shell and cancellation checks. This
143
+ does not by itself establish the multi-user Dano worker lifecycle.
144
+
145
+ ## Protected pi CLI
146
+
147
+ `pi-openviking /etc/pi-openviking/profile.json [pi chat arguments]` starts the
148
+ Linux worker, drops host privileges, loads a trusted host module and calls pi's
149
+ public `main` entry with the standard extension factory. The profile and its
150
+ ancestors must be root-owned and not group/other-writable. It contains bootstrap
151
+ paths/IDs/limits, `hostModule`, `shutdownTimeoutMs` and optional
152
+ `trustedSkillPaths`; it must contain no provider credentials. See the exported
153
+ `LauncherProfile` type for required fields.
154
+
155
+ The installed host module exports `createHost({ paths, assertToolIsolation })`
156
+ and returns `{ memory, scheduler }`. It reads keys from the host-private root
157
+ and supplies the selected model's exact tokenizer. It runs after privilege
158
+ drop. Its source and approved Skill paths must be inside the protected
159
+ installation. The CLI fixes private session storage and denies executable
160
+ resource/trust overrides and package/config administration commands.
161
+
162
+ Print mode closes its scheduler/worker on return. Interactive pi emits its own
163
+ shutdown hooks and exits; worker IPC disconnect terminates outstanding tool
164
+ work. Delivery does not depend on an exit flush: the durable queue recovers on
165
+ the next launch. A container supervisor must terminate the entire process tree
166
+ on abrupt host termination.
167
+
168
+ The 2026-09-18 Linux run used the real configured model through pi 0.82.1:
169
+ Bash wrote `cli-proof.txt`, read returned its content, and the file belonged to
170
+ the separate worker UID. Workspace extension discovery was denied and the CLI
171
+ exited normally. `scripts/linux-cli.mjs` reproduces this in a disposable root
172
+ container; its `cli-test-host.mjs` deliberately leaves memory disabled and does
173
+ not substitute for memory-enabled acceptance. Model credentials are copied to
174
+ a private agent directory. Extra CA certificates must remain readable after
175
+ host privilege drop; TLS verification stays enabled.
176
+
177
+ ## Release candidate validation
178
+
179
+ The standard CLI RPC path has now passed explicit enable confirmation, real
180
+ OpenViking save to `ready`, content/source inspection, new-session recall and
181
+ pause. Automatic collection remains separately unapproved. This passed first
182
+ on pi 0.82.1 and again with a fresh account on pi 0.85.1. See
183
+ [the acceptance record](docs/acceptance-2026-09-18.md). Interactive TUI screenshots
184
+ and Dano's real in-app Browser gate remain outstanding.
185
+
186
+ The exact peer moved to pi 0.85.1 because pi 0.82.1's bundled shrinkwrap kept
187
+ vulnerable transitive dependencies despite root overrides. The 0.85.1 install
188
+ resolves undici 8.9.0 and brace-expansion 5.0.9; `npm audit` currently reports
189
+ zero vulnerabilities. The package includes the Apache-2.0 license text needed
190
+ for the unmodified OpenViking SDK.
@@ -0,0 +1,27 @@
1
+ # Third-party distribution review
2
+
3
+ This repository's adapter code is original MIT-licensed code. It does not copy
4
+ OpenViking server or example implementation code.
5
+
6
+ - `@openviking/sdk@0.1.0` declares Apache-2.0. Its upstream npm tarball omits a
7
+ standalone LICENSE. The complete Apache-2.0 text is shipped in `licenses/Apache-2.0.txt`.
8
+ Upstream: https://github.com/volcengine/OpenViking/tree/b54001e2e5c974ffd7a09ba543813fa104a99561/sdk/typescript.
9
+ The dependency is unmodified; its published tarball contains no separate
10
+ NOTICE or copyright attribution file. Its package metadata and original
11
+ source notices remain in the dependency. Review the final image dependency
12
+ tree separately; this notice does not relabel the server license.
13
+ - `fs-ext@2.1.1` declares MIT. Preserve its license and the notices of native
14
+ build dependencies when distributing them.
15
+ - pi 0.85.1 is an MIT peer dependency. The extension must not bundle a second
16
+ kernel. Images distributing pi must retain its applicable license/notices.
17
+ - OpenViking server 0.4.20 is a separate AGPL-3.0 service, not part of this
18
+ extension artifact. Server-image delivery must include applicable license,
19
+ notices and exact corresponding source/build/install materials. Reassess
20
+ obligations if the server is modified. HTTP separation is not a blanket
21
+ exemption from license obligations.
22
+
23
+ The release candidate uses pi 0.85.1 after the initial pi 0.82.1 combination
24
+ reported undici and brace-expansion vulnerabilities. Root overrides did not
25
+ replace the old package's bundled shrinkwrap. The current exact peer resolves
26
+ undici 8.9.0 and brace-expansion 5.0.9 and passes `npm audit` with zero findings
27
+ on 2026-09-18. Re-audit the final host image and later releases separately.
@@ -0,0 +1,20 @@
1
+ import { NativeToolWorker, type WorkerOptions } from './tool-worker.js';
2
+ export interface ProtectedPaths {
3
+ workspace: string;
4
+ agentDir: string;
5
+ stateDir: string;
6
+ installationDir: string;
7
+ hostUid: number;
8
+ workerUid: number;
9
+ workerGid: number;
10
+ }
11
+ /** Canonical paths returned here must be used throughout the subsequent bootstrap. */
12
+ export declare function validateProtectedPaths(options: ProtectedPaths): Promise<ProtectedPaths>;
13
+ /** Single-host CLI bootstrap. Multi-user hosts must provision each worker explicitly. */
14
+ export declare function bootstrapProtectedWorker(options: ProtectedPaths & Omit<WorkerOptions, 'workspace' | 'hostUid' | 'workerUid' | 'workerGid'> & {
15
+ hostGid: number;
16
+ }): Promise<{
17
+ worker: NativeToolWorker;
18
+ paths: ProtectedPaths;
19
+ piPackageContext: string;
20
+ }>;
@@ -0,0 +1,117 @@
1
+ import { lstat, realpath, readdir } from 'node:fs/promises';
2
+ import { dirname, relative, isAbsolute } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { NativeToolWorker } from './tool-worker.js';
5
+ function contains(parent, child) {
6
+ const path = relative(parent, child);
7
+ return path === '' || (!path.startsWith('../') && path !== '..' && !isAbsolute(path));
8
+ }
9
+ function permissions(stat, options) {
10
+ if (stat.uid === options.workerUid)
11
+ return (stat.mode >> 6) & 7;
12
+ if (stat.gid === options.workerGid)
13
+ return (stat.mode >> 3) & 7;
14
+ return stat.mode & 7;
15
+ }
16
+ async function protectedAncestors(path, options, readable) {
17
+ let current = path;
18
+ let child;
19
+ for (;;) {
20
+ const stat = await lstat(current);
21
+ const access = permissions(stat, options);
22
+ // A sticky shared ancestor may be writable, but the worker must own neither
23
+ // it nor the protected child entry. Without sticky semantics, replacement
24
+ // remains possible even when the protected child itself is read-only.
25
+ const stickyProtection = child && (stat.mode & 0o1000) && stat.uid !== options.workerUid && child.uid !== options.workerUid;
26
+ if (stat.uid === options.workerUid || ((access & 2) && !stickyProtection))
27
+ throw new Error('WORKER_CAN_REPLACE_PROTECTED_PATH');
28
+ if (readable && (!(access & 1) || (current === path && !(access & 4))))
29
+ throw new Error('WORKER_CANNOT_LOAD_INSTALLATION');
30
+ const parent = dirname(current);
31
+ if (parent === current)
32
+ return;
33
+ child = stat;
34
+ current = parent;
35
+ }
36
+ }
37
+ async function verifyInstallationTree(root, options) {
38
+ const visited = new Set();
39
+ const pending = [root];
40
+ while (pending.length) {
41
+ const path = await realpath(pending.pop());
42
+ if (!contains(root, path))
43
+ throw new Error('INSTALLATION_LINK_ESCAPES_ROOT');
44
+ if (visited.has(path))
45
+ continue;
46
+ visited.add(path);
47
+ const stat = await lstat(path);
48
+ const access = permissions(stat, options);
49
+ if (stat.uid === options.workerUid || (access & 2))
50
+ throw new Error('WORKER_CAN_REPLACE_PROTECTED_PATH');
51
+ if (stat.isDirectory()) {
52
+ for (const entry of await readdir(path))
53
+ pending.push(`${path}/${entry}`);
54
+ }
55
+ else if (!stat.isFile())
56
+ throw new Error('UNSUPPORTED_INSTALLATION_ENTRY');
57
+ }
58
+ }
59
+ /** Canonical paths returned here must be used throughout the subsequent bootstrap. */
60
+ export async function validateProtectedPaths(options) {
61
+ if (![options.hostUid, options.workerUid, options.workerGid].every(id => Number.isSafeInteger(id) && id > 0)
62
+ || options.hostUid === options.workerUid)
63
+ throw new Error('INVALID_MEMORY_IDENTITIES');
64
+ const keys = ['workspace', 'agentDir', 'stateDir', 'installationDir'];
65
+ const paths = { ...options };
66
+ for (const key of keys) {
67
+ if (!isAbsolute(paths[key]))
68
+ throw new Error('MEMORY_PATH_MUST_BE_ABSOLUTE');
69
+ paths[key] = await realpath(paths[key]);
70
+ if (!(await lstat(paths[key])).isDirectory())
71
+ throw new Error('MEMORY_PATH_NOT_DIRECTORY');
72
+ }
73
+ for (const key of ['agentDir', 'stateDir', 'installationDir']) {
74
+ if (contains(paths.workspace, paths[key]) || contains(paths[key], paths.workspace))
75
+ throw new Error('MEMORY_WORKSPACE_OVERLAP');
76
+ await protectedAncestors(paths[key], paths, key === 'installationDir');
77
+ }
78
+ for (const key of ['agentDir', 'stateDir']) {
79
+ if (contains(paths.installationDir, paths[key]) || contains(paths[key], paths.installationDir))
80
+ throw new Error('MEMORY_INSTALLATION_STATE_OVERLAP');
81
+ const stat = await lstat(paths[key]);
82
+ if (stat.uid !== paths.hostUid || (stat.mode & 0o077) !== 0)
83
+ throw new Error('MEMORY_PRIVATE_DIRECTORY_REQUIRED');
84
+ }
85
+ if ((permissions(await lstat(paths.workspace), paths) & 7) !== 7)
86
+ throw new Error('WORKER_CANNOT_USE_WORKSPACE');
87
+ await verifyInstallationTree(paths.installationDir, paths);
88
+ return paths;
89
+ }
90
+ /** Single-host CLI bootstrap. Multi-user hosts must provision each worker explicitly. */
91
+ export async function bootstrapProtectedWorker(options) {
92
+ if (process.platform !== 'linux' || process.getuid?.() !== 0
93
+ || !Number.isSafeInteger(options.hostGid) || options.hostGid <= 0)
94
+ throw new Error('PRIVILEGED_LINUX_BOOTSTRAP_REQUIRED');
95
+ const paths = await validateProtectedPaths(options);
96
+ const extensionEntry = await realpath(fileURLToPath(import.meta.url));
97
+ if (!contains(paths.installationDir, extensionEntry))
98
+ throw new Error('EXTENSION_OUTSIDE_PROTECTED_INSTALLATION');
99
+ const piPackageContext = await realpath(options.piPackageContext);
100
+ if (!contains(paths.installationDir, piPackageContext) || !(await lstat(piPackageContext)).isFile()) {
101
+ throw new Error('PI_CONTEXT_OUTSIDE_PROTECTED_INSTALLATION');
102
+ }
103
+ const privilegeGuard = await realpath(options.privilegeGuard);
104
+ await protectedAncestors(privilegeGuard, paths, true);
105
+ await protectedAncestors(await realpath(process.execPath), paths, true);
106
+ const worker = new NativeToolWorker({ ...options, ...paths, piPackageContext, privilegeGuard });
107
+ try {
108
+ process.setgid(options.hostGid);
109
+ process.setuid(paths.hostUid);
110
+ await worker.assertIsolated();
111
+ return { worker, paths, piPackageContext };
112
+ }
113
+ catch (error) {
114
+ worker.close();
115
+ throw error;
116
+ }
117
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { dirname, isAbsolute } from 'node:path';
4
+ import { runProtectedPi } from './launcher.js';
5
+ try {
6
+ const [profilePath, ...args] = process.argv.slice(2);
7
+ if (!profilePath || !isAbsolute(profilePath))
8
+ throw new Error('ABSOLUTE_LAUNCH_PROFILE_REQUIRED');
9
+ const canonical = await realpath(profilePath);
10
+ // The bootstrap profile is administrator-owned data. Validate the whole path
11
+ // before parsing it; it must not be replaceable from an untrusted workspace.
12
+ for (let current = canonical;; current = dirname(current)) {
13
+ const stat = await lstat(current);
14
+ if (stat.uid !== 0 || (stat.mode & 0o022) !== 0)
15
+ throw new Error('ADMIN_OWNED_LAUNCH_PROFILE_REQUIRED');
16
+ if (current === canonical && !stat.isFile())
17
+ throw new Error('INVALID_LAUNCH_PROFILE');
18
+ if (dirname(current) === current)
19
+ break;
20
+ }
21
+ const profile = JSON.parse(await readFile(canonical, 'utf8'));
22
+ await runProtectedPi(profile, args);
23
+ }
24
+ catch {
25
+ // Configuration and provider failures can contain keys or endpoints.
26
+ console.error('Protected pi startup failed. Check the administrator-owned launch profile and private host configuration.');
27
+ process.exitCode = 1;
28
+ }
@@ -0,0 +1,44 @@
1
+ import { type Operation, type Owner, type Source, type StateStore } from './types.js';
2
+ /** Each method is owner-bound. Reconciliation never mutates the service. */
3
+ export interface DeliveryTransport {
4
+ readonly owner: Owner;
5
+ createSession(id: string): Promise<void>;
6
+ sessionExists(id: string): Promise<boolean>;
7
+ append(operation: Readonly<Operation>): Promise<void>;
8
+ hasSource(operation: Readonly<Operation>): Promise<boolean>;
9
+ commit(id: string): Promise<{
10
+ taskId: string;
11
+ archiveId?: string;
12
+ }>;
13
+ findCommit(id: string): Promise<{
14
+ taskId: string;
15
+ archiveId?: string;
16
+ } | null>;
17
+ inspect(operation: Readonly<Operation>): Promise<{
18
+ status: 'processing';
19
+ } | {
20
+ status: 'failed';
21
+ code: string;
22
+ } | {
23
+ status: 'ready';
24
+ archiveId: string;
25
+ memoryUris: string[];
26
+ }>;
27
+ }
28
+ export declare class MemoryDelivery {
29
+ #private;
30
+ constructor(options: {
31
+ store: StateStore;
32
+ transport: DeliveryTransport;
33
+ maxPayloadBytes: number;
34
+ });
35
+ get owner(): Owner;
36
+ enable(policyVersion: string): Promise<void>;
37
+ pause(): Promise<void>;
38
+ save(source: Source, content: string, scope?: string | null): Promise<Operation | {
39
+ phase: 'blocked';
40
+ errorCode: string;
41
+ }>;
42
+ /** Advances at most one remote mutation. The caller owns scheduling/lifetime. */
43
+ advance(id: string): Promise<void>;
44
+ }
@@ -0,0 +1,167 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { checkedOwner, sameOwner } from './types.js';
3
+ const terminal = new Set(['ready', 'failed', 'blocked_by_pause', 'blocked']);
4
+ const unsent = new Set(['queued', 'session_created', 'message_delivered']);
5
+ export class MemoryDelivery {
6
+ #store;
7
+ #transport;
8
+ #maxPayloadBytes;
9
+ constructor(options) {
10
+ checkedOwner(options.store.owner);
11
+ if (!sameOwner(options.store.owner, options.transport.owner))
12
+ throw new Error('MEMORY_OWNER_MISMATCH');
13
+ if (!Number.isSafeInteger(options.maxPayloadBytes) || options.maxPayloadBytes <= 0)
14
+ throw new Error('INVALID_MEMORY_LIMIT');
15
+ this.#store = options.store;
16
+ this.#transport = options.transport;
17
+ this.#maxPayloadBytes = options.maxPayloadBytes;
18
+ }
19
+ get owner() { return this.#store.owner; }
20
+ async enable(policyVersion) {
21
+ if (!policyVersion)
22
+ throw new Error('MISSING_POLICY_VERSION');
23
+ await this.#store.transact(state => {
24
+ state.authorization = { enabled: true, automaticCollection: false,
25
+ epoch: state.authorization.epoch + 1, effectiveAt: new Date().toISOString(), policyVersion };
26
+ });
27
+ }
28
+ async pause() {
29
+ await this.#store.transact(state => {
30
+ state.authorization.enabled = false;
31
+ state.authorization.epoch++;
32
+ state.authorization.effectiveAt = new Date().toISOString();
33
+ for (const operation of Object.values(state.operations)) {
34
+ if (unsent.has(operation.phase)) {
35
+ operation.phase = 'blocked_by_pause';
36
+ delete operation.payload;
37
+ operation.updatedAt = new Date().toISOString();
38
+ }
39
+ }
40
+ });
41
+ }
42
+ async save(source, content, scope = null) {
43
+ if (typeof content !== 'string' || !content.trim() || Buffer.byteLength(content) > this.#maxPayloadBytes
44
+ || !source || ![source.sessionId, source.entryId, source.branchId, source.contentVersion].every(x => typeof x === 'string' && x.length > 0)) {
45
+ throw new Error('INVALID_MEMORY_SOURCE');
46
+ }
47
+ // Scope is supplied by the host, never copied from model input.
48
+ if (scope !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(scope))
49
+ throw new Error('INVALID_MEMORY_SCOPE');
50
+ return this.#store.transact(state => {
51
+ if (!state.authorization.enabled)
52
+ return { phase: 'blocked', errorCode: 'MEMORY_DISABLED' };
53
+ const id = createHash('sha256').update(JSON.stringify([
54
+ state.owner, scope, source, state.authorization.epoch,
55
+ ])).digest('hex');
56
+ const previous = state.operations[id];
57
+ if (previous) {
58
+ if (previous.payload !== undefined && previous.payload !== content)
59
+ throw new Error('MEMORY_SOURCE_CONFLICT');
60
+ return structuredClone(previous);
61
+ }
62
+ const now = new Date().toISOString();
63
+ const operation = {
64
+ id, owner: state.owner, source: { ...source }, scope, kind: 'explicit',
65
+ authorizationEpoch: state.authorization.epoch, createdAt: now, updatedAt: now,
66
+ phase: 'queued', remoteSessionId: randomUUID(), payload: content,
67
+ };
68
+ state.operations[id] = operation;
69
+ return structuredClone(operation);
70
+ });
71
+ }
72
+ /** Advances at most one remote mutation. The caller owns scheduling/lifetime. */
73
+ async advance(id) {
74
+ if (!/^[a-f0-9]{64}$/.test(id))
75
+ throw new Error('INVALID_MEMORY_OPERATION');
76
+ const operation = await this.#store.transact(state => {
77
+ const current = state.operations[id];
78
+ if (!current || terminal.has(current.phase))
79
+ return null;
80
+ if (unsent.has(current.phase) && (!state.authorization.enabled
81
+ || current.authorizationEpoch !== state.authorization.epoch)) {
82
+ current.phase = 'blocked_by_pause';
83
+ delete current.payload;
84
+ return null;
85
+ }
86
+ const snapshot = structuredClone(current);
87
+ if (current.phase === 'queued')
88
+ current.phase = 'session_unknown';
89
+ if (current.phase === 'session_created')
90
+ current.phase = 'message_unknown';
91
+ if (current.phase === 'message_delivered')
92
+ current.phase = 'commit_unknown';
93
+ current.updatedAt = new Date().toISOString();
94
+ return snapshot;
95
+ });
96
+ if (!operation)
97
+ return;
98
+ try {
99
+ switch (operation.phase) {
100
+ case 'queued':
101
+ await this.#transport.createSession(operation.remoteSessionId);
102
+ await this.#transition(id, 'session_unknown', { phase: 'session_created' });
103
+ break;
104
+ case 'session_unknown':
105
+ if (await this.#transport.sessionExists(operation.remoteSessionId)) {
106
+ await this.#transition(id, 'session_unknown', { phase: 'session_created' });
107
+ }
108
+ break;
109
+ case 'session_created':
110
+ await this.#transport.append(operation);
111
+ await this.#transition(id, 'message_unknown', { phase: 'message_delivered' });
112
+ break;
113
+ case 'message_unknown':
114
+ if (await this.#transport.hasSource(operation)) {
115
+ await this.#transition(id, 'message_unknown', { phase: 'message_delivered' });
116
+ }
117
+ break;
118
+ case 'message_delivered': {
119
+ const receipt = await this.#transport.commit(operation.remoteSessionId);
120
+ await this.#transition(id, 'commit_unknown', { phase: 'processing', ...receipt });
121
+ break;
122
+ }
123
+ case 'commit_unknown': {
124
+ const receipt = await this.#transport.findCommit(operation.remoteSessionId);
125
+ if (receipt)
126
+ await this.#transition(id, 'commit_unknown', { phase: 'processing', ...receipt });
127
+ break;
128
+ }
129
+ case 'processing': {
130
+ const result = await this.#transport.inspect(operation);
131
+ if (result.status === 'failed') {
132
+ await this.#transition(id, 'processing', { phase: 'failed', errorCode: result.code });
133
+ }
134
+ else if (result.status === 'ready') {
135
+ if (!result.memoryUris.length)
136
+ throw new Error('MEMORY_NOT_RETRIEVABLE');
137
+ await this.#transition(id, 'processing', { phase: 'ready', archiveId: result.archiveId, memoryUris: result.memoryUris });
138
+ }
139
+ break;
140
+ }
141
+ }
142
+ }
143
+ catch {
144
+ // The persisted unknown phase survives transport errors. Neither an
145
+ // exception nor an absent receipt authorizes replay of a mutation.
146
+ await this.#store.transact(state => {
147
+ const current = state.operations[id];
148
+ if (current && !terminal.has(current.phase))
149
+ current.errorCode = 'MEMORY_RECONCILIATION_REQUIRED';
150
+ });
151
+ }
152
+ }
153
+ async #transition(id, expected, patch) {
154
+ await this.#store.transact(state => {
155
+ const current = state.operations[id];
156
+ if (current?.phase !== expected)
157
+ return;
158
+ Object.assign(current, patch, { updatedAt: new Date().toISOString() });
159
+ if (!patch.errorCode)
160
+ delete current.errorCode;
161
+ current.deliveryAttempts = 0;
162
+ current.nextAttemptAt = 0;
163
+ if (terminal.has(current.phase))
164
+ delete current.payload;
165
+ });
166
+ }
167
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { ExtensionFactory } from '@earendil-works/pi-coding-agent';
2
+ import { type DeliveryTransport } from './delivery.js';
3
+ import type { RecalledMemory } from './openviking-client.js';
4
+ import { type Owner, type StateStore } from './types.js';
5
+ export { protectedMemoryResources } from './resource-profile.js';
6
+ export { FileStateStore } from './state-store.js';
7
+ export { DeliveryScheduler } from './scheduler.js';
8
+ export { MemoryDelivery } from './delivery.js';
9
+ export { OwnerMemoryClient } from './openviking-client.js';
10
+ export type { Owner, Source, Operation, StateStore } from './types.js';
11
+ export interface MemoryExtensionOptions {
12
+ owner: Owner;
13
+ client: DeliveryTransport & {
14
+ readMemory?(uri: string): Promise<string>;
15
+ recall(query: string, limit: number, signal?: AbortSignal): Promise<RecalledMemory[]>;
16
+ };
17
+ stateStore: StateStore;
18
+ scope?: string | null;
19
+ /** Host implementation must verify the live isolated worker, not a config flag. */
20
+ assertToolIsolation(): Promise<void>;
21
+ policy: {
22
+ maxPayloadBytes: number;
23
+ recallTimeoutMs: number;
24
+ recallTokenBudget: number;
25
+ recallLimit: number;
26
+ minimumScore: number;
27
+ /** Exact tokenizer for the active model, supplied by the trusted host. */
28
+ countTokens(text: string): number;
29
+ };
30
+ /** Wake the owner-level bounded scheduler; does not perform a foreground flush. */
31
+ wakeDelivery(): void;
32
+ }
33
+ export declare function createOpenVikingExtension(options: MemoryExtensionOptions): ExtensionFactory;