@hydraharness/harness-spill-local 0.1.1-rc.6

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 DeepSeek
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,32 @@
1
+ # @hydraharness/harness-spill-local
2
+
3
+ The **local-filesystem** implementation of the [`@hydraharness/harness-spill`](../spill) storage seam. Registers as `ctx.spillStore` and persists a tool's oversized text to a private, session-scoped file; its locator is the file path and its retrieval hint tells the model to use `read` or `grep` on that path.
4
+
5
+ ## Storage layout
6
+
7
+ Files land at `<root>/session-<hash>/​<random>-<safeName>`:
8
+
9
+ - **`root`** — the config `root` (resolved to absolute), or a lazily-created private (0700) per-process directory under the OS temp dir when omitted. A predictable, world-readable root would let other local users read spilled tool output or plant symlinks.
10
+ - **`session-<hash>`** — a short `sha256(sessionId)` prefix, so a session's spill files group together and a future cleanup can drop them per session.
11
+ - **`<random>-<safeName>`** — an unpredictable hex prefix (defeats symlink planting in a shared root) plus the caller's `suggestedName` sanitized to one safe path segment (traversal-proof; mirrors the JSONL persistence backend's `encodeSegment`). The write is exclusive + owner-only (`open(path, 'wx', 0o600)`): it fails on any pre-existing path, symlink or not, so a planted target cannot redirect it.
12
+
13
+ ## Config
14
+
15
+ | Key | Default | Meaning |
16
+ |---|---|---|
17
+ | `root` | private 0700 temp dir | Root directory for spill files. Set to keep them under a known location. |
18
+
19
+ `saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design.
20
+
21
+ ## Model Experience
22
+
23
+ Indirectly, through spill consumers that render the local path and `read`/`grep` retrieval guidance.
24
+
25
+ #### KV Cache effect
26
+
27
+ No direct invalidation; the named consumer owns any request-prefix changes.
28
+
29
+ ## Known Limitations and Deferred Work
30
+
31
+ - **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path.
32
+ - **Locators require a co-located filesystem consumer** — a remote or virtual deployment needs another `SpillStore` backend whose locator and retrieval hint are meaningful there.
package/lib/index.js ADDED
@@ -0,0 +1,138 @@
1
+ import { join, resolve } from "node:path";
2
+ import z from "@hydraharness/schemastery";
3
+ import { SpillLocator, SpillStore } from "@hydraharness/harness-spill";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { mkdtempSync } from "node:fs";
6
+ import { mkdir, open } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ //#region lib/types/store.js
9
+ /**
10
+ * Cordis-free storage mechanics for the local spill backend: private
11
+ * session-scoped directory selection, safe-name derivation, path-traversal
12
+ * protection, and the exclusive owner-only write. Kept out of the service class
13
+ * (like `@hydraharness/harness-bash-local`'s `run.ts`) so the filesystem behavior is unit-testable
14
+ * without a `ctx` and without the OS temp dir.
15
+ *
16
+ * @module @hydraharness/harness-spill-local/store
17
+ */
18
+ let defaultRoot;
19
+ /**
20
+ * The default spill root: a private (0700) per-process directory under the OS
21
+ * tmpdir, created lazily. Predictable world-readable paths would let other
22
+ * local users read spilled tool output or pre-create symlinks; `mkdtemp` gives
23
+ * an unpredictable suffix and 0700 semantics.
24
+ *
25
+ * @returns The lazily-created private spill root.
26
+ */
27
+ function privateRoot() {
28
+ defaultRoot ??= mkdtempSync(join(tmpdir(), "hydra-spill-"));
29
+ return defaultRoot;
30
+ }
31
+ /**
32
+ * Encode an arbitrary string as one safe path segment, injectively over ALL JS
33
+ * (UTF-16) strings. A session id / suggested name is untrusted input, so this
34
+ * neutralizes `../`, absolute paths, NUL, and separators before any filesystem
35
+ * use. Each code unit is kept literal (`[A-Za-z0-9._-]`, minus `~`) or escaped
36
+ * as `~XXXX`; `~` is itself escaped, so the mapping is reversible and distinct
37
+ * inputs never collide. The whole-segment tokens `.`/`..` are escaped so they
38
+ * can never traverse. An empty string encodes to `~` (never an empty segment).
39
+ * (Mirrors the JSONL persistence backend's `encodeSegment`.)
40
+ *
41
+ * @param raw The untrusted string to encode as one safe path segment.
42
+ * @returns An injective, filesystem-safe single path segment.
43
+ */
44
+ function encodeSegment(raw) {
45
+ if (raw.length === 0) return "~";
46
+ if (raw === ".") return "~002E";
47
+ if (raw === "..") return "~002E~002E";
48
+ let out = "";
49
+ for (let i = 0; i < raw.length; i++) {
50
+ const code = raw.charCodeAt(i);
51
+ const ch = String.fromCharCode(code);
52
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
53
+ else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
54
+ }
55
+ return out;
56
+ }
57
+ /**
58
+ * The session-scoped directory: `<root>/session-<hash(sessionId)>`, a short stable hash.
59
+ *
60
+ * @param root The spill root directory.
61
+ * @param sessionId The owning session id to hash into a stable directory name.
62
+ * @returns The absolute session-scoped spill directory path.
63
+ */
64
+ function sessionDir(root, sessionId) {
65
+ return join(root, `session-${createHash("sha256").update(sessionId).digest("hex").slice(0, 12)}`);
66
+ }
67
+ /**
68
+ * Write `content` to a fresh file under the session-scoped directory and return
69
+ * its path + byte length. The filename is a random hex prefix plus the
70
+ * sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in
71
+ * a shared root) AND stays readable. The open is exclusive + owner-only
72
+ * (`'wx', 0o600`): it fails on any existing path — symlink or not — so a
73
+ * pre-planted target cannot redirect the write.
74
+ *
75
+ * @param options The resolved root and request fields required to save the file.
76
+ * @returns The written file path and UTF-8 byte length.
77
+ */
78
+ async function saveTextFile(options) {
79
+ const dir = sessionDir(options.root, options.sessionId);
80
+ await mkdir(dir, {
81
+ recursive: true,
82
+ mode: 448
83
+ });
84
+ const safeName = encodeSegment(options.suggestedName);
85
+ const path = join(dir, `${randomBytes(6).toString("hex")}-${safeName}`);
86
+ const bytes = Buffer.byteLength(options.content, "utf8");
87
+ const handle = await open(path, "wx", 384);
88
+ try {
89
+ await handle.writeFile(options.content);
90
+ } finally {
91
+ await handle.close();
92
+ }
93
+ return {
94
+ path,
95
+ bytes
96
+ };
97
+ }
98
+ //#endregion
99
+ //#region lib/types/index.js
100
+ /**
101
+ * `LocalSpillStore`: the host-filesystem implementation of the
102
+ * `@hydraharness/harness-spill` storage seam. Persists a tool's oversized text to a
103
+ * private, session-scoped file (see `./store.ts` for the traversal-safe naming
104
+ * and exclusive owner-only write) and returns a path locator plus local
105
+ * read/grep retrieval guidance.
106
+ *
107
+ * @module @hydraharness/harness-spill-local
108
+ */
109
+ /**
110
+ * Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
111
+ * with unpredictable names, an exclusive owner-only (0600) write, and a private
112
+ * (0700) root — a spilled tool result must not be readable by other local users
113
+ * or redirectable via a planted symlink.
114
+ */
115
+ var LocalSpillStore = class extends SpillStore {
116
+ static Config = z.object({ root: z.string() });
117
+ /** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
118
+ root;
119
+ constructor(ctx, config) {
120
+ super(ctx);
121
+ this.root = config.root !== void 0 ? resolve(config.root) : privateRoot();
122
+ }
123
+ async saveText(input) {
124
+ const saved = await saveTextFile({
125
+ root: this.root,
126
+ sessionId: input.owner.sessionId,
127
+ suggestedName: input.suggestedName,
128
+ content: input.content
129
+ });
130
+ return {
131
+ locator: SpillLocator(saved.path),
132
+ bytes: saved.bytes,
133
+ retrievalHint: "Use read with offset/limit, or grep this path to search within it."
134
+ };
135
+ }
136
+ };
137
+ //#endregion
138
+ export { LocalSpillStore, LocalSpillStore as default, encodeSegment, privateRoot, saveTextFile, sessionDir };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@hydraharness/harness-spill-local`.
4
+ * @module @hydraharness/harness-spill-local/invariant
5
+ */
6
+ const PACKAGE_NAME = "@hydraharness/harness-spill-local";
7
+ /** Cordis companion plugin name. */
8
+ const name = "spill-local-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: this package exposes no independent event sequence or mutable data relation
13
+ * beyond contracts enforced at its owning seam.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `LocalSpillStore`: the host-filesystem implementation of the
3
+ * `@hydraharness/harness-spill` storage seam. Persists a tool's oversized text to a
4
+ * private, session-scoped file (see `./store.ts` for the traversal-safe naming
5
+ * and exclusive owner-only write) and returns a path locator plus local
6
+ * read/grep retrieval guidance.
7
+ *
8
+ * @module @hydraharness/harness-spill-local
9
+ */
10
+ import { Context } from '@hydraharness/cordis';
11
+ import z from '@hydraharness/schemastery';
12
+ import { SpillStore } from '@hydraharness/harness-spill';
13
+ import type { SaveTextSpill, SpillRef } from '@hydraharness/harness-spill';
14
+ export { encodeSegment, privateRoot, saveTextFile, sessionDir } from './store.ts';
15
+ export type { SavedText, SaveTextOptions } from './store.ts';
16
+ /** Plugin config (all optional — `static Config` supplies the defaults). */
17
+ export interface Config {
18
+ /**
19
+ * Root directory for spill files. Omitted uses a lazily-created private
20
+ * (0700) per-process directory under the OS temp dir — the safe default for
21
+ * a local deployment. Set it to keep spill files under a known location.
22
+ */
23
+ root?: string;
24
+ }
25
+ /**
26
+ * Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
27
+ * with unpredictable names, an exclusive owner-only (0600) write, and a private
28
+ * (0700) root — a spilled tool result must not be readable by other local users
29
+ * or redirectable via a planted symlink.
30
+ */
31
+ export declare class LocalSpillStore extends SpillStore {
32
+ static Config: z<Config>;
33
+ /** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
34
+ readonly root: string;
35
+ constructor(ctx: Context, config: Config);
36
+ saveText(input: SaveTextSpill): Promise<SpillRef>;
37
+ }
38
+ export default LocalSpillStore;
39
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@hydraharness/harness-spill-local`.
3
+ * @module @hydraharness/harness-spill-local/invariant
4
+ */
5
+ import type { Context } from '@hydraharness/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "spill-local-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Cordis-free storage mechanics for the local spill backend: private
3
+ * session-scoped directory selection, safe-name derivation, path-traversal
4
+ * protection, and the exclusive owner-only write. Kept out of the service class
5
+ * (like `@hydraharness/harness-bash-local`'s `run.ts`) so the filesystem behavior is unit-testable
6
+ * without a `ctx` and without the OS temp dir.
7
+ *
8
+ * @module @hydraharness/harness-spill-local/store
9
+ */
10
+ /**
11
+ * The default spill root: a private (0700) per-process directory under the OS
12
+ * tmpdir, created lazily. Predictable world-readable paths would let other
13
+ * local users read spilled tool output or pre-create symlinks; `mkdtemp` gives
14
+ * an unpredictable suffix and 0700 semantics.
15
+ *
16
+ * @returns The lazily-created private spill root.
17
+ */
18
+ export declare function privateRoot(): string;
19
+ /**
20
+ * Encode an arbitrary string as one safe path segment, injectively over ALL JS
21
+ * (UTF-16) strings. A session id / suggested name is untrusted input, so this
22
+ * neutralizes `../`, absolute paths, NUL, and separators before any filesystem
23
+ * use. Each code unit is kept literal (`[A-Za-z0-9._-]`, minus `~`) or escaped
24
+ * as `~XXXX`; `~` is itself escaped, so the mapping is reversible and distinct
25
+ * inputs never collide. The whole-segment tokens `.`/`..` are escaped so they
26
+ * can never traverse. An empty string encodes to `~` (never an empty segment).
27
+ * (Mirrors the JSONL persistence backend's `encodeSegment`.)
28
+ *
29
+ * @param raw The untrusted string to encode as one safe path segment.
30
+ * @returns An injective, filesystem-safe single path segment.
31
+ */
32
+ export declare function encodeSegment(raw: string): string;
33
+ /**
34
+ * The session-scoped directory: `<root>/session-<hash(sessionId)>`, a short stable hash.
35
+ *
36
+ * @param root The spill root directory.
37
+ * @param sessionId The owning session id to hash into a stable directory name.
38
+ * @returns The absolute session-scoped spill directory path.
39
+ */
40
+ export declare function sessionDir(root: string, sessionId: string): string;
41
+ /** Options for {@link saveTextFile} — the resolved root and the request fields the store needs. */
42
+ export interface SaveTextOptions {
43
+ /** The spill root directory (configured or the lazy private default). */
44
+ root: string;
45
+ /** The owning session id (scopes the directory). */
46
+ sessionId: string;
47
+ /** Caller-suggested base name; sanitized to one safe segment before use. */
48
+ suggestedName: string;
49
+ /** The full text to persist. */
50
+ content: string;
51
+ }
52
+ /** A written spill file. */
53
+ export interface SavedText {
54
+ path: string;
55
+ bytes: number;
56
+ }
57
+ /**
58
+ * Write `content` to a fresh file under the session-scoped directory and return
59
+ * its path + byte length. The filename is a random hex prefix plus the
60
+ * sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in
61
+ * a shared root) AND stays readable. The open is exclusive + owner-only
62
+ * (`'wx', 0o600`): it fails on any existing path — symlink or not — so a
63
+ * pre-planted target cannot redirect the write.
64
+ *
65
+ * @param options The resolved root and request fields required to save the file.
66
+ * @returns The written file path and UTF-8 byte length.
67
+ */
68
+ export declare function saveTextFile(options: SaveTextOptions): Promise<SavedText>;
69
+ //# sourceMappingURL=store.d.ts.map
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@hydraharness/harness-spill-local",
3
+ "description": "Local-filesystem implementation of the Hydra harness spill storage seam (private session-scoped files)",
4
+ "hydra": {
5
+ "plugin": {
6
+ "application": "Store large tool output privately on disk for later retrieval within the session."
7
+ }
8
+ },
9
+ "version": "0.1.1-rc.6",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
16
+ "directory": "packages/spill/spill-local"
17
+ },
18
+ "type": "module",
19
+ "main": "lib/index.js",
20
+ "types": "lib/types/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./lib/types/index.d.ts",
24
+ "default": "./lib/index.js"
25
+ },
26
+ "./invariant": {
27
+ "types": "./lib/types/invariant.d.ts",
28
+ "default": "./lib/invariant.js"
29
+ },
30
+ "./src/*": "./src/*",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "lib/index.js",
35
+ "lib/invariant.js",
36
+ "lib/types/**/*.d.ts"
37
+ ],
38
+ "license": "MIT",
39
+ "peerDependencies": {
40
+ "@hydraharness/harness-invariants": "^0.1.1-rc.6",
41
+ "@hydraharness/cordis": "^4.0.2",
42
+ "@hydraharness/harness-spill": "^0.1.1-rc.6"
43
+ },
44
+ "dependencies": {
45
+ "@hydraharness/schemastery": "^3.18.2"
46
+ },
47
+ "devDependencies": {
48
+ "@hydraharness/harness-brand": "^0.1.1-rc.6",
49
+ "@hydraharness/harness-session": "^0.1.1-rc.6",
50
+ "@hydraharness/harness-llm": "^0.1.1-rc.6",
51
+ "@hydraharness/harness-spill": "^0.1.1-rc.6",
52
+ "@hydraharness/harness-invariants": "^0.1.1-rc.6",
53
+ "@hydraharness/cordis": "^4.0.2"
54
+ }
55
+ }