@hasna/hooks 0.9.1 → 0.9.2
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 +10 -0
- package/bin/hooks-mcp.js +197 -79
- package/bin/index.js +304 -144
- package/bin/serve.js +11 -0
- package/dist/index.js +204 -83
- package/dist/lib/codex-settings.d.ts +3 -0
- package/dist/lib/installer.d.ts +1 -1
- package/hooks/hook-mementos-context/README.md +80 -0
- package/hooks/hook-mementos-context/src/hook.ts +116 -0
- package/package.json +5 -4
package/dist/lib/installer.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { type HookEvent, type HookMeta } from "./registry.js";
|
|
13
13
|
import { type StaleRegistration } from "./registration.js";
|
|
14
14
|
export type Scope = "global" | "project";
|
|
15
|
-
export type Target = "claude" | "gemini" | "codewith" | "all";
|
|
15
|
+
export type Target = "claude" | "gemini" | "codewith" | "codex" | "all";
|
|
16
16
|
type SingleTarget = Exclude<Target, "all">;
|
|
17
17
|
export type ConcreteTarget = SingleTarget;
|
|
18
18
|
export type CodewithInstallMode = "fragment" | "write";
|
|
@@ -0,0 +1,80 @@
|
|
|
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 does nothing until its calling process sets `HOOKS_MEMENTOS_ENABLED=1`.
|
|
26
|
+
For example, start a fresh Codex CLI from a shell with:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
export HOOKS_MEMENTOS_ENABLED=1
|
|
30
|
+
export HOOKS_MEMENTOS_PROJECT=my-project
|
|
31
|
+
codex
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`my-project` must resolve to an existing Mementos project ID, name or registered
|
|
35
|
+
path. Without this override, the native event's `cwd` is the project reference.
|
|
36
|
+
An unknown project returns no context. Shared memories span sessions; the native
|
|
37
|
+
session ID is not automatically applied as a retrieval filter. Existing running
|
|
38
|
+
clients do not acquire newly exported environment variables.
|
|
39
|
+
|
|
40
|
+
Provider assistance is independently opt-in. For OpenRouter:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
mementos decisions configure --provider openrouter --model typesafe/jev-1.13
|
|
44
|
+
mementos decisions enable --retrieval
|
|
45
|
+
export HOOKS_MEMENTOS_OPENROUTER_SECRET_REF=example/openrouter/key
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The reference must name an existing key available through the optional Secrets
|
|
49
|
+
CLI. The hook invokes `secrets exec <reference> --as OPENROUTER_API_KEY -- mementos
|
|
50
|
+
prompt-context ...`; the key reaches only the Mementos consumer. Do not place key
|
|
51
|
+
values in hook commands, profiles or settings. Hooks' environment credential
|
|
52
|
+
filter remains in force. Without a provider or key, baseline retrieval works.
|
|
53
|
+
Provider enablement permits sending the redacted prompt and bounded memory
|
|
54
|
+
excerpts to that provider; redaction is heuristic, so select the intended scope.
|
|
55
|
+
|
|
56
|
+
| Environment variable (prefix `HOOKS_MEMENTOS_`) | Default / meaning |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `ENABLED` | Unset; only `1` enables execution |
|
|
59
|
+
| `PROJECT` | Native event `cwd`; explicit registered project reference |
|
|
60
|
+
| `SCOPE` | `shared`; `private` or `working` require both filters below |
|
|
61
|
+
| `AGENT_ID`, `SESSION_ID` | Optional explicit Mementos retrieval filters |
|
|
62
|
+
| `TAGS` | Optional JSON array of up to 10 tags |
|
|
63
|
+
| `MAX_ITEMS`, `MAX_CANDIDATES` | 3 selected / 12 retrieved; bounds 10 / 20 |
|
|
64
|
+
| `MAX_TOKENS` | 1000, estimated as UTF-8 bytes / 4; hard maximum 8192 bytes |
|
|
65
|
+
| `MIN_RELEVANCE` | 0.5; applied only to successful provider judgments |
|
|
66
|
+
| `TIMEOUT_MS` | 3500; total child deadline adds 1000 ms, maximum 11000 ms |
|
|
67
|
+
| `COMMAND` | `mementos`; optional executable path, never a shell expression |
|
|
68
|
+
| `OPENROUTER_SECRET_REF` | Unset; optional Secrets key reference |
|
|
69
|
+
|
|
70
|
+
The hook reads at most 32 KiB of native input and 24 KiB of CLI output, inserts at
|
|
71
|
+
most 8 KiB of context, and always continues the prompt. Provider failure falls
|
|
72
|
+
back to the retrieved order; retrieval/configuration failure inserts nothing.
|
|
73
|
+
Context is quoted JSON with record IDs, versions, scope and source, explicitly
|
|
74
|
+
labelled untrusted reference data. Jev scores are advisory and are not guaranteed
|
|
75
|
+
to be identical across repeated requests.
|
|
76
|
+
|
|
77
|
+
Mementos owns the retrieval operation (`mementos prompt-context --help`). Hooks
|
|
78
|
+
owns registration and execution. No watcher, channel consumer or web interface
|
|
79
|
+
is required. Installed registration, native trust and actual event execution are
|
|
80
|
+
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.
|
|
3
|
+
"version": "0.9.2",
|
|
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/
|
|
94
|
+
"url": "https://github.com/hasna/apps/issues"
|
|
95
95
|
},
|
|
96
96
|
"repository": {
|
|
97
97
|
"type": "git",
|
|
98
|
-
"url": "
|
|
98
|
+
"url": "https://github.com/hasna/apps.git",
|
|
99
|
+
"directory": "apps/hooks"
|
|
99
100
|
}
|
|
100
101
|
}
|