@hasna/hooks 0.9.1 → 0.9.3

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.
@@ -0,0 +1,3 @@
1
+ export declare function readCodexSettings(path: string): Record<string, any>;
2
+ /** Preserve the exact read version. Never write trust state or config.toml. */
3
+ export declare function writeCodexSettings(path: string, settings: Record<string, any>): void;
@@ -11,8 +11,9 @@
11
11
  */
12
12
  import { type HookEvent, type HookMeta } from "./registry.js";
13
13
  import { type StaleRegistration } from "./registration.js";
14
+ import { type MementosRegistration } from "./mementos-options.js";
14
15
  export type Scope = "global" | "project";
15
- export type Target = "claude" | "gemini" | "codewith" | "all";
16
+ export type Target = "claude" | "gemini" | "codewith" | "codex" | "all";
16
17
  type SingleTarget = Exclude<Target, "all">;
17
18
  export type ConcreteTarget = SingleTarget;
18
19
  export type CodewithInstallMode = "fragment" | "write";
@@ -29,6 +30,8 @@ export interface InstallResult {
29
30
  configPath?: string;
30
31
  }
31
32
  export interface InstallOptions {
33
+ /** Explicit persistent opt-in for mementos-context; omitted updates retain its current choice. */
34
+ mementos?: MementosRegistration;
32
35
  scope?: Scope;
33
36
  overwrite?: boolean;
34
37
  target?: Target;
@@ -47,7 +50,7 @@ export declare function getHookPath(name: string): string;
47
50
  export declare function hookExists(name: string): boolean;
48
51
  /** Whether a hook's event can be registered for the given target */
49
52
  export declare function isEventSupported(internalEvent: HookEvent, target: SingleTarget): boolean;
50
- export declare function buildCodewithTomlFragment(name: string, profile?: string): string;
53
+ export declare function buildCodewithTomlFragment(name: string, profile?: string, mementos?: MementosRegistration): string;
51
54
  /**
52
55
  * The one conflict class that BLOCKS an install: two PreToolUse hooks on
53
56
  * overlapping matchers that both rewrite the tool input
@@ -0,0 +1,16 @@
1
+ /** Persistent opt-in lives in the native hook command, never in a credential file. */
2
+ export interface MementosRegistration {
3
+ enabled: boolean;
4
+ secretRef?: string;
5
+ }
6
+ export interface MementosFlags {
7
+ enableMementos?: boolean;
8
+ disableMementos?: boolean;
9
+ openrouterSecretRef?: string;
10
+ }
11
+ export declare function validateMementosRegistration(name: string, value?: MementosRegistration): void;
12
+ export declare function mementosRegistrationFromFlags(name: string, flags: MementosFlags): MementosRegistration | undefined;
13
+ export declare function mementosCommandSuffix(value?: MementosRegistration): string;
14
+ /** Preserve explicit activation across ordinary install --overwrite / update. */
15
+ export declare function retainedMementosRegistration(commands: string[]): MementosRegistration | undefined;
16
+ export declare function mementosRunEnvironment(env: NodeJS.ProcessEnv, value?: MementosRegistration): NodeJS.ProcessEnv;
@@ -6,6 +6,7 @@
6
6
  * an array of { matcher?, hooks: [...] } entries — so a composite event such
7
7
  * as 'PreToolUse:Bash' must be split before lookup, never used as a key.
8
8
  */
9
+ export declare function registrationName(command: unknown): string | undefined;
9
10
  /** A settings registration whose hook name cannot resolve. */
10
11
  export interface StaleRegistration {
11
12
  /** Settings file the registration lives in. */
@@ -0,0 +1,106 @@
1
+ # Mementos prompt context
2
+
3
+ Optional `UserPromptSubmit` context for Codex, Claude Code and Codewith. Mementos
4
+ retrieves existing memories from an explicitly resolved project. If decision
5
+ assistance is enabled, the configured provider judges relevance; it does not
6
+ generate memories, authorize actions or change stored records.
7
+
8
+ Install compatible `@hasna/mementos` and `@hasna/hooks` CLIs, then register:
9
+
10
+ ```sh
11
+ hooks install mementos-context --target codex
12
+ # Review the exact registration in Codex /hooks before trusting it.
13
+ # Claude Code: use --target claude.
14
+ # Codewith: --target codewith emits a TOML fragment for its config owner.
15
+ ```
16
+
17
+ Codex registration writes `~/.codex/hooks.json` (or `.codex/hooks.json` with
18
+ `--project`). It preserves unrelated entries and refuses malformed or changed
19
+ settings. It does not change `config.toml`, project trust or native hook trust.
20
+ Codex skips an untrusted or modified definition until reviewed. `--target all`
21
+ retains the existing Claude/Codewith install group; select Codex explicitly.
22
+ Use `hooks list --registered --target codex` and
23
+ `hooks remove mementos-context --target codex` to inspect or remove registration.
24
+
25
+ The hook is off by default. To save a one-time opt-in in the native registration:
26
+
27
+ ```sh
28
+ hooks install mementos-context --target codex --overwrite --enable-mementos
29
+ # Review the changed definition in Codex /hooks before trusting it.
30
+ ```
31
+
32
+ New sessions now run the hook without shell exports. Its project is the session's
33
+ working directory, which must resolve to an existing Mementos project. Routine
34
+ JSON-target registration updates retain this choice. To save a persistent opt-out:
35
+
36
+ ```sh
37
+ hooks install mementos-context --target codex --overwrite --disable-mementos
38
+ ```
39
+
40
+ For a temporary opt-in instead, start a fresh Codex CLI from a shell with:
41
+
42
+ ```sh
43
+ export HOOKS_MEMENTOS_ENABLED=1
44
+ export HOOKS_MEMENTOS_PROJECT=my-project
45
+ codex
46
+ ```
47
+
48
+ `my-project` must resolve to an existing Mementos project ID, name or registered
49
+ path. Without this override, the native event's `cwd` is the project reference.
50
+ An unknown project returns no context. Shared memories span sessions; the native
51
+ session ID is not automatically applied as a retrieval filter. Existing running
52
+ clients do not acquire newly exported environment variables.
53
+
54
+ Provider assistance is independently opt-in. For OpenRouter:
55
+
56
+ ```sh
57
+ mementos decisions configure --provider openrouter --model typesafe/jev-1.13
58
+ mementos decisions enable --retrieval
59
+ export HOOKS_MEMENTOS_OPENROUTER_SECRET_REF=example/openrouter/key
60
+ ```
61
+
62
+ To persist the reference with activation, replace that export with:
63
+
64
+ ```sh
65
+ hooks install mementos-context --target codex --overwrite --enable-mementos \
66
+ --openrouter-secret-ref example/openrouter/key
67
+ ```
68
+
69
+ The native command stores only the reference. Existing hook definitions are
70
+ preserved, and native trust is still required after a command changes. Set
71
+ `HOOKS_MEMENTOS_ENABLED=0` to opt out for one session even when its registration
72
+ is enabled. A persistent `--disable-mementos` always disables execution.
73
+
74
+ The reference must name an existing key available through the optional Secrets
75
+ CLI. The hook invokes `secrets exec <reference> --as OPENROUTER_API_KEY -- mementos
76
+ prompt-context ...`; the key reaches only the Mementos consumer. Do not place key
77
+ values in hook commands, profiles or settings. Hooks' environment credential
78
+ filter remains in force. Without a provider or key, baseline retrieval works.
79
+ Provider enablement permits sending the redacted prompt and bounded memory
80
+ excerpts to that provider; redaction is heuristic, so select the intended scope.
81
+
82
+ | Environment variable (prefix `HOOKS_MEMENTOS_`) | Default / meaning |
83
+ | --- | --- |
84
+ | `ENABLED` | Unset; only `1` enables execution |
85
+ | `PROJECT` | Native event `cwd`; explicit registered project reference |
86
+ | `SCOPE` | `shared`; `private` or `working` require both filters below |
87
+ | `AGENT_ID`, `SESSION_ID` | Optional explicit Mementos retrieval filters |
88
+ | `TAGS` | Optional JSON array of up to 10 tags |
89
+ | `MAX_ITEMS`, `MAX_CANDIDATES` | 3 selected / 12 retrieved; bounds 10 / 20 |
90
+ | `MAX_TOKENS` | 1000, estimated as UTF-8 bytes / 4; hard maximum 8192 bytes |
91
+ | `MIN_RELEVANCE` | 0.5; applied only to successful provider judgments |
92
+ | `TIMEOUT_MS` | 3500; total child deadline adds 1000 ms, maximum 11000 ms |
93
+ | `COMMAND` | `mementos`; optional executable path, never a shell expression |
94
+ | `OPENROUTER_SECRET_REF` | Unset; optional Secrets key reference |
95
+
96
+ The hook reads at most 32 KiB of native input and 24 KiB of CLI output, inserts at
97
+ most 8 KiB of context, and always continues the prompt. Provider failure falls
98
+ back to the retrieved order; retrieval/configuration failure inserts nothing.
99
+ Context is quoted JSON with record IDs, versions, scope and source, explicitly
100
+ labelled untrusted reference data. Jev scores are advisory and are not guaranteed
101
+ to be identical across repeated requests.
102
+
103
+ Mementos owns the retrieval operation (`mementos prompt-context --help`). Hooks
104
+ owns registration and execution. No watcher, channel consumer or web interface
105
+ is required. Installed registration, native trust and actual event execution are
106
+ separate states; use a native prompt event to verify the final integration.
@@ -0,0 +1,116 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const INPUT_BYTES = 32768;
4
+ const OUTPUT_BYTES = 24576;
5
+ const CONTEXT_BYTES = 8192;
6
+ type HookOutput = { continue: true; hookSpecificOutput?: { hookEventName: "UserPromptSubmit"; additionalContext: string } };
7
+ type Invocation = { command: string; args: string[]; input: string; timeoutMs: number };
8
+ type Executor = (invocation: Invocation) => Promise<string | null>;
9
+ const pass = (): HookOutput => ({ continue: true });
10
+
11
+ function numberOption(env: NodeJS.ProcessEnv, name: string, fallback: number, min: number, max: number, integer = true): number {
12
+ const raw = env[`HOOKS_MEMENTOS_${name}`];
13
+ const value = raw === undefined ? fallback : Number(raw);
14
+ if (!Number.isFinite(value) || (integer && !Number.isInteger(value)) || value < min || value > max) throw new Error("invalid_configuration");
15
+ return value;
16
+ }
17
+
18
+ export function buildInvocation(raw: unknown, env: NodeJS.ProcessEnv): Invocation | null {
19
+ if (env.HOOKS_MEMENTOS_ENABLED !== "1" || !raw || typeof raw !== "object" || Array.isArray(raw)) return null;
20
+ const event = raw as Record<string, unknown>;
21
+ if (event.hook_event_name !== "UserPromptSubmit" || typeof event.prompt !== "string" || !event.prompt.trim() || event.prompt.length > 4096) return null;
22
+ const project = env.HOOKS_MEMENTOS_PROJECT ?? event.cwd;
23
+ if (typeof project !== "string" || !project.trim() || project.length > 4096 || /[\u0000-\u001f]/.test(project)) return null;
24
+ const timeout = numberOption(env, "TIMEOUT_MS", 3500, 100, 10000);
25
+ // Shared memory spans sessions. Native session_id is deliberately not a retrieval filter.
26
+ const input = JSON.stringify({
27
+ prompt: event.prompt, project, scope: env.HOOKS_MEMENTOS_SCOPE ?? "shared",
28
+ agent_id: env.HOOKS_MEMENTOS_AGENT_ID, session_id: env.HOOKS_MEMENTOS_SESSION_ID,
29
+ tags: env.HOOKS_MEMENTOS_TAGS === undefined ? undefined : JSON.parse(env.HOOKS_MEMENTOS_TAGS),
30
+ });
31
+ if (Buffer.byteLength(input) > INPUT_BYTES) return null;
32
+ const args = ["prompt-context", "--enabled", "--input", "-",
33
+ "--max-items", String(numberOption(env, "MAX_ITEMS", 3, 1, 10)),
34
+ "--max-candidates", String(numberOption(env, "MAX_CANDIDATES", 12, 1, 20)),
35
+ "--max-tokens", String(numberOption(env, "MAX_TOKENS", 1000, 128, 2048)),
36
+ "--min-relevance", String(numberOption(env, "MIN_RELEVANCE", 0.5, 0, 1, false)),
37
+ "--timeout-ms", String(timeout)];
38
+ const command = env.HOOKS_MEMENTOS_COMMAND ?? "mementos";
39
+ // This is one executable, never a shell expression or a command-line string.
40
+ if (!command || /[\u0000-\u001f]/.test(command)) return null;
41
+ const secretRef = env.HOOKS_MEMENTOS_OPENROUTER_SECRET_REF;
42
+ if (secretRef !== undefined) {
43
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*(?:\/[a-zA-Z0-9][a-zA-Z0-9_.-]*){1,12}$/.test(secretRef) || secretRef.length > 256) return null;
44
+ return { command: "secrets", args: ["exec", secretRef, "--as", "OPENROUTER_API_KEY", "--", command, ...args], input, timeoutMs: timeout + 1000 };
45
+ }
46
+ return { command, args, input, timeoutMs: timeout + 1000 };
47
+ }
48
+
49
+ /** Bound only the freshly spawned Mementos/Secrets child tree owned by this invocation. */
50
+ export const execute: Executor = invocation => new Promise(resolve => {
51
+ let child: ReturnType<typeof spawn>;
52
+ let timer: ReturnType<typeof setTimeout> | undefined;
53
+ let settled = false;
54
+ let size = 0;
55
+ const parts: Buffer[] = [];
56
+ const finish = (value: string | null) => {
57
+ if (settled) return;
58
+ settled = true;
59
+ if (timer) clearTimeout(timer);
60
+ resolve(value);
61
+ };
62
+ const abort = () => {
63
+ if (settled) return;
64
+ try {
65
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
66
+ else child.kill("SIGKILL");
67
+ } catch { /* child may already have exited */ }
68
+ finish(null);
69
+ };
70
+ try {
71
+ child = spawn(invocation.command, invocation.args, {
72
+ stdio: ["pipe", "pipe", "ignore"], shell: false, detached: process.platform !== "win32", env: process.env,
73
+ });
74
+ timer = setTimeout(abort, invocation.timeoutMs);
75
+ child.stdout!.on("data", (chunk: Buffer) => {
76
+ size += chunk.length;
77
+ if (size > OUTPUT_BYTES) abort();
78
+ else parts.push(Buffer.from(chunk));
79
+ });
80
+ child.stdin!.on("error", abort);
81
+ child.on("error", () => finish(null));
82
+ child.on("close", code => finish(code === 0 ? Buffer.concat(parts).toString("utf8") : null));
83
+ child.stdin!.end(invocation.input);
84
+ } catch { finish(null); }
85
+ });
86
+
87
+ export async function buildHookOutput(raw: unknown, executor: Executor = execute, env: NodeJS.ProcessEnv = process.env): Promise<HookOutput> {
88
+ try {
89
+ const invocation = buildInvocation(raw, env);
90
+ if (!invocation) return pass();
91
+ const output = await executor(invocation);
92
+ if (!output || Buffer.byteLength(output) > OUTPUT_BYTES) return pass();
93
+ const receipt = JSON.parse(output);
94
+ if (receipt?.contract !== "mementos.prompt-context.v1" || receipt.status !== "ready" || receipt.advisory !== true
95
+ || typeof receipt.context !== "string" || !receipt.context || Buffer.byteLength(receipt.context) > CONTEXT_BYTES
96
+ || receipt.context_bytes !== Buffer.byteLength(receipt.context) || !Array.isArray(receipt.items) || receipt.items.length < 1 || receipt.items.length > 10) return pass();
97
+ return { continue: true, hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: receipt.context } };
98
+ } catch { return pass(); }
99
+ }
100
+
101
+ export async function run(): Promise<void> {
102
+ let output: HookOutput = pass();
103
+ try {
104
+ const chunks: Buffer[] = [];
105
+ let bytes = 0;
106
+ for await (const chunk of process.stdin) {
107
+ bytes += chunk.length;
108
+ if (bytes > INPUT_BYTES) throw new Error("input_limit");
109
+ chunks.push(Buffer.from(chunk));
110
+ }
111
+ output = await buildHookOutput(JSON.parse(Buffer.concat(chunks).toString("utf8")));
112
+ } catch { /* Hook failures never block the user's prompt. */ }
113
+ process.stdout.write(JSON.stringify(output) + "\n");
114
+ }
115
+
116
+ if (import.meta.main) void run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -89,12 +89,13 @@
89
89
  "!hooks/**/*.test.ts",
90
90
  "!hooks/**/tsconfig.json"
91
91
  ],
92
- "homepage": "https://github.com/hasna/hooks#readme",
92
+ "homepage": "https://github.com/hasna/apps/tree/main/apps/hooks#readme",
93
93
  "bugs": {
94
- "url": "https://github.com/hasna/hooks/issues"
94
+ "url": "https://github.com/hasna/apps/issues"
95
95
  },
96
96
  "repository": {
97
97
  "type": "git",
98
- "url": "git+https://github.com/hasna/hooks.git"
98
+ "url": "https://github.com/hasna/apps.git",
99
+ "directory": "apps/hooks"
99
100
  }
100
101
  }