@hasna/hooks 0.3.4 → 0.3.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/bin/index.js +1 -1
- package/dist/index.js +1 -1
- package/hooks/hook-knowledge-context/README.md +22 -3
- package/hooks/hook-knowledge-context/package.json +2 -2
- package/hooks/hook-knowledge-context/src/hook.test.ts +125 -11
- package/hooks/hook-knowledge-context/src/hook.ts +88 -13
- package/package.json +2 -1
package/bin/index.js
CHANGED
|
@@ -2089,7 +2089,7 @@ var init_registry = __esm(() => {
|
|
|
2089
2089
|
name: "knowledge-context",
|
|
2090
2090
|
displayName: "Knowledge Context",
|
|
2091
2091
|
description: "Injects deterministic Knowledge context packs into Codewith SessionStart, UserPromptSubmit, and SubagentStart",
|
|
2092
|
-
version: "0.1.
|
|
2092
|
+
version: "0.1.5",
|
|
2093
2093
|
category: "Context Management",
|
|
2094
2094
|
event: "SessionStart",
|
|
2095
2095
|
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
package/dist/index.js
CHANGED
|
@@ -1010,7 +1010,7 @@ var HOOKS = [
|
|
|
1010
1010
|
name: "knowledge-context",
|
|
1011
1011
|
displayName: "Knowledge Context",
|
|
1012
1012
|
description: "Injects deterministic Knowledge context packs into Codewith SessionStart, UserPromptSubmit, and SubagentStart",
|
|
1013
|
-
version: "0.1.
|
|
1013
|
+
version: "0.1.5",
|
|
1014
1014
|
category: "Context Management",
|
|
1015
1015
|
event: "SessionStart",
|
|
1016
1016
|
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
|
@@ -27,12 +27,24 @@ knowledge context pack <query> --from search --max-items <n> --max-tokens <n> --
|
|
|
27
27
|
|
|
28
28
|
The hook intentionally does not pass `--semantic`, does not call web search,
|
|
29
29
|
does not call ask/build/generate flows, and does not crawl raw stores directly.
|
|
30
|
+
For `UserPromptSubmit`, the hook first applies a deterministic high-signal gate
|
|
31
|
+
so short acknowledgements and casual follow-ups do not inject Knowledge context.
|
|
30
32
|
When Knowledge returns a nonempty context pack, the hook redacts credential-like
|
|
31
33
|
text from that pack and emits Codewith-native
|
|
32
34
|
`hookSpecificOutput.additionalContext` with the same event name. Citation-only
|
|
33
|
-
packs are expanded into progressive context lines with
|
|
34
|
-
`knowledge get --id
|
|
35
|
-
are worth opening.
|
|
35
|
+
packs are expanded into progressive context lines with bounded previews and a
|
|
36
|
+
single `knowledge get --id <item_id> --json` hint so agents can decide which
|
|
37
|
+
full items are worth opening.
|
|
38
|
+
|
|
39
|
+
Example injected context:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
[hook-knowledge-context] Knowledge matches (UserPromptSubmit; top 3, deterministic search):
|
|
43
|
+
|
|
44
|
+
If a match looks relevant, read it with: knowledge get --id <item_id> --json
|
|
45
|
+
|
|
46
|
+
- item_id=k_example cite=cite_123: Bounded preview text...
|
|
47
|
+
```
|
|
36
48
|
|
|
37
49
|
All failures are fail-open: missing CLI, timeout, nonzero exit, malformed JSON,
|
|
38
50
|
bad stdin, or empty packs return `{"continue":true}` without context.
|
|
@@ -45,10 +57,17 @@ export HOOKS_KNOWLEDGE_COMMAND=knowledge # CLI command/path
|
|
|
45
57
|
export HOOKS_KNOWLEDGE_TIMEOUT_MS=5000 # Per-call timeout
|
|
46
58
|
export HOOKS_KNOWLEDGE_MAX_ITEMS=3 # Context pack item budget
|
|
47
59
|
export HOOKS_KNOWLEDGE_MAX_TOKENS=1200 # Context pack token budget
|
|
60
|
+
export HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL=1
|
|
61
|
+
export HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS=6
|
|
62
|
+
export HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE=3
|
|
48
63
|
export HOOKS_KNOWLEDGE_MAX_QUERY_CHARS=1200 # Redacted query bound
|
|
49
64
|
export HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS=8000
|
|
50
65
|
```
|
|
51
66
|
|
|
67
|
+
All numeric env overrides are bounded. `HOOKS_KNOWLEDGE_COMMAND` accepts only a
|
|
68
|
+
single command/path token; unsafe values with whitespace fall back to
|
|
69
|
+
`knowledge`.
|
|
70
|
+
|
|
52
71
|
## Events
|
|
53
72
|
|
|
54
73
|
- `SessionStart`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hook-knowledge-context",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Codewith hook that injects deterministic Knowledge context packs into lifecycle events",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/hook.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"build": "rm -rf dist && bun build ./src/hook.ts --outdir ./dist --target
|
|
19
|
+
"build": "rm -rf dist && bun build ./src/hook.ts --outdir ./dist --target bun && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false --noEmit false",
|
|
20
20
|
"prepublishOnly": "bun run build",
|
|
21
21
|
"typecheck": "tsc --noEmit"
|
|
22
22
|
},
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
buildQuery,
|
|
6
6
|
extractContextText,
|
|
7
7
|
getConfig,
|
|
8
|
+
promptSignalScore,
|
|
8
9
|
redactSecrets,
|
|
9
10
|
sanitizeQuery,
|
|
10
11
|
type KnowledgeContextConfig,
|
|
@@ -16,15 +17,44 @@ const baseConfig: KnowledgeContextConfig = {
|
|
|
16
17
|
timeoutMs: 5000,
|
|
17
18
|
maxItems: 3,
|
|
18
19
|
maxTokens: 1200,
|
|
20
|
+
minPromptChars: 6,
|
|
21
|
+
minSignalScore: 3,
|
|
19
22
|
maxQueryChars: 1200,
|
|
20
23
|
maxOutputChars: 8000,
|
|
24
|
+
requireHighSignal: true,
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
describe("hook-knowledge-context", () => {
|
|
24
28
|
test("defaults Knowledge CLI timeout to 5000ms and keeps env override", () => {
|
|
25
29
|
expect(getConfig({}).timeoutMs).toBe(5000);
|
|
26
30
|
expect(getConfig({}).maxItems).toBe(3);
|
|
31
|
+
expect(getConfig({}).minPromptChars).toBe(6);
|
|
32
|
+
expect(getConfig({}).minSignalScore).toBe(3);
|
|
33
|
+
expect(getConfig({}).requireHighSignal).toBe(true);
|
|
27
34
|
expect(getConfig({ HOOKS_KNOWLEDGE_TIMEOUT_MS: "2345" }).timeoutMs).toBe(2345);
|
|
35
|
+
expect(getConfig({ HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL: "0" }).requireHighSignal).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("applies bounded env overrides and rejects unsafe command override", () => {
|
|
39
|
+
const config = getConfig({
|
|
40
|
+
HOOKS_KNOWLEDGE_COMMAND: "/tmp/knowledge",
|
|
41
|
+
HOOKS_KNOWLEDGE_MAX_ITEMS: "9",
|
|
42
|
+
HOOKS_KNOWLEDGE_MAX_TOKENS: "2345",
|
|
43
|
+
HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS: "12",
|
|
44
|
+
HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE: "4",
|
|
45
|
+
HOOKS_KNOWLEDGE_MAX_QUERY_CHARS: "200",
|
|
46
|
+
HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS: "9999",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(config.command).toBe("/tmp/knowledge");
|
|
50
|
+
expect(config.maxItems).toBe(9);
|
|
51
|
+
expect(config.maxTokens).toBe(2345);
|
|
52
|
+
expect(config.minPromptChars).toBe(12);
|
|
53
|
+
expect(config.minSignalScore).toBe(4);
|
|
54
|
+
expect(config.maxQueryChars).toBe(200);
|
|
55
|
+
expect(config.maxOutputChars).toBe(9999);
|
|
56
|
+
expect(buildKnowledgeArgs("q", config)).toContain("9");
|
|
57
|
+
expect(getConfig({ HOOKS_KNOWLEDGE_COMMAND: "bad command" }).command).toBe("knowledge");
|
|
28
58
|
});
|
|
29
59
|
|
|
30
60
|
test("builds deterministic knowledge context pack args without semantic, web, or generation flags", () => {
|
|
@@ -67,7 +97,7 @@ describe("hook-knowledge-context", () => {
|
|
|
67
97
|
|
|
68
98
|
expect(output.continue).toBe(true);
|
|
69
99
|
expect(output.hookSpecificOutput?.hookEventName).toBe("SessionStart");
|
|
70
|
-
expect(output.hookSpecificOutput?.additionalContext).toContain("
|
|
100
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Knowledge matches");
|
|
71
101
|
expect(output.hookSpecificOutput?.additionalContext).toContain("Use hooks.json");
|
|
72
102
|
});
|
|
73
103
|
|
|
@@ -87,7 +117,7 @@ describe("hook-knowledge-context", () => {
|
|
|
87
117
|
{
|
|
88
118
|
hook_event_name: "UserPromptSubmit",
|
|
89
119
|
cwd: "/repo",
|
|
90
|
-
prompt: "implement
|
|
120
|
+
prompt: "implement the knowledge hook context output with token=demo",
|
|
91
121
|
},
|
|
92
122
|
executor
|
|
93
123
|
);
|
|
@@ -151,6 +181,46 @@ describe("hook-knowledge-context", () => {
|
|
|
151
181
|
).toEqual({ continue: true });
|
|
152
182
|
});
|
|
153
183
|
|
|
184
|
+
test("fails open for low-signal UserPromptSubmit prompts before calling Knowledge", async () => {
|
|
185
|
+
let executorCalls = 0;
|
|
186
|
+
const executor: KnowledgeExecutor = async () => {
|
|
187
|
+
executorCalls += 1;
|
|
188
|
+
throw new Error("executor should not run for low-signal prompts");
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
for (const prompt of ["", "ok", "thanks", "?", "continue", "tell me again"]) {
|
|
192
|
+
expect(
|
|
193
|
+
await buildHookOutput({ hook_event_name: "UserPromptSubmit", cwd: "/repo", prompt }, executor)
|
|
194
|
+
).toEqual({ continue: true });
|
|
195
|
+
}
|
|
196
|
+
expect(executorCalls).toBe(0);
|
|
197
|
+
for (const prompt of [
|
|
198
|
+
"fix authz",
|
|
199
|
+
"release",
|
|
200
|
+
"publish npm",
|
|
201
|
+
"check src/hook.ts",
|
|
202
|
+
"@hasna/hooks",
|
|
203
|
+
"merge PR 12",
|
|
204
|
+
"review staged diff",
|
|
205
|
+
"rerun tests",
|
|
206
|
+
]) {
|
|
207
|
+
expect(promptSignalScore(prompt, "/home/hasna/workspace/hasna/opensource/open-hooks")).toBeGreaterThanOrEqual(3);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("can opt out of high-signal gating for UserPromptSubmit", async () => {
|
|
212
|
+
const output = await buildHookOutput(
|
|
213
|
+
{ hook_event_name: "UserPromptSubmit", cwd: "/repo", prompt: "ok" },
|
|
214
|
+
async () => ({
|
|
215
|
+
ok: true,
|
|
216
|
+
stdout: JSON.stringify({ context: "Forced low-signal context." }),
|
|
217
|
+
}),
|
|
218
|
+
{ HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL: "0" }
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Forced low-signal context");
|
|
222
|
+
});
|
|
223
|
+
|
|
154
224
|
test("sanitizes and bounds queries", () => {
|
|
155
225
|
expect(redactSecrets("password=hunter2 token=demo")).toBe(
|
|
156
226
|
"password=<redacted> token=<redacted>"
|
|
@@ -172,21 +242,65 @@ describe("hook-knowledge-context", () => {
|
|
|
172
242
|
expect(extractContextText([{ title: "One", text: "Body", uri: "knowledge://item/one" }])).toContain("Body");
|
|
173
243
|
});
|
|
174
244
|
|
|
175
|
-
test("formats citation
|
|
245
|
+
test("formats citation-only packs as item_id/cite bullets with one read hint", () => {
|
|
176
246
|
const text = extractContextText({
|
|
177
247
|
citations: [
|
|
178
248
|
{
|
|
179
|
-
id: "
|
|
180
|
-
source_ref: "knowledge://item/
|
|
181
|
-
quote_preview: "
|
|
249
|
+
id: "cite_1",
|
|
250
|
+
source_ref: "knowledge://item/k_one",
|
|
251
|
+
quote_preview: "First preview that tells the agent why this Knowledge item might matter.",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: "cite_2",
|
|
255
|
+
source_ref: "knowledge://item/k_two",
|
|
256
|
+
quote_preview: "Second preview that tells the agent why this Knowledge item might matter.",
|
|
182
257
|
},
|
|
183
258
|
],
|
|
184
259
|
});
|
|
185
260
|
|
|
186
|
-
expect(text).toContain("
|
|
187
|
-
expect(text).
|
|
188
|
-
expect(text).toContain("
|
|
189
|
-
expect(text).toContain("
|
|
190
|
-
expect(text).not.toContain("
|
|
261
|
+
expect(text).toContain("If a match looks relevant, read it with: knowledge get --id <item_id> --json");
|
|
262
|
+
expect(text?.match(/knowledge get --id/g) ?? []).toHaveLength(1);
|
|
263
|
+
expect(text).toContain("- item_id=k_one cite=cite_1: First preview");
|
|
264
|
+
expect(text).toContain("- item_id=k_two cite=cite_2: Second preview");
|
|
265
|
+
expect(text).not.toContain("open: knowledge get --id k_one --json");
|
|
266
|
+
expect(text).not.toContain("(cite_1)");
|
|
267
|
+
expect(text).not.toContain("source: knowledge://item/k_one");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("extracts item_id from legacy Knowledge source paths", () => {
|
|
271
|
+
const text = extractContextText({
|
|
272
|
+
citations: [
|
|
273
|
+
{
|
|
274
|
+
id: "cite_abc",
|
|
275
|
+
source_ref: "open-files://source/legacy-json/path/k_mqyrz7gt_olq82e",
|
|
276
|
+
quote_preview: "Legacy file source preview.",
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(text).toContain("item_id=k_mqyrz7gt_olq82e");
|
|
282
|
+
expect(text).toContain("cite=cite_abc");
|
|
283
|
+
expect(text).not.toContain("source=open-files://source/legacy-json/path/k_mqyrz7gt_olq82e");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("keeps source fallback for non-Knowledge citations without item ids", () => {
|
|
287
|
+
const text = extractContextText({
|
|
288
|
+
citations: [
|
|
289
|
+
{
|
|
290
|
+
id: "cite_url",
|
|
291
|
+
source_ref: "https://example.test/doc",
|
|
292
|
+
quote_preview: "External preview.",
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(text).toContain("- cite=cite_url source=https://example.test/doc: External preview.");
|
|
298
|
+
expect(text).not.toContain("item_id=");
|
|
299
|
+
expect(text).not.toContain("knowledge get --id");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("cleans legacy empty-title bullet artifacts in item text", () => {
|
|
303
|
+
expect(extractContextText(["- : legacy empty title preview"])).toBe("- legacy empty title preview");
|
|
304
|
+
expect(extractContextText("- : legacy direct context")).toBe("- legacy direct context");
|
|
191
305
|
});
|
|
192
306
|
});
|
|
@@ -40,8 +40,11 @@ export interface KnowledgeContextConfig {
|
|
|
40
40
|
timeoutMs: number;
|
|
41
41
|
maxItems: number;
|
|
42
42
|
maxTokens: number;
|
|
43
|
+
minPromptChars: number;
|
|
44
|
+
minSignalScore: number;
|
|
43
45
|
maxQueryChars: number;
|
|
44
46
|
maxOutputChars: number;
|
|
47
|
+
requireHighSignal: boolean;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export interface KnowledgeExecResult {
|
|
@@ -58,6 +61,8 @@ export type KnowledgeExecutor = (
|
|
|
58
61
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
59
62
|
const DEFAULT_MAX_ITEMS = 3;
|
|
60
63
|
const DEFAULT_MAX_TOKENS = 1200;
|
|
64
|
+
const DEFAULT_MIN_PROMPT_CHARS = 6;
|
|
65
|
+
const DEFAULT_MIN_SIGNAL_SCORE = 3;
|
|
61
66
|
const DEFAULT_MAX_QUERY_CHARS = 1200;
|
|
62
67
|
const DEFAULT_MAX_OUTPUT_CHARS = 8000;
|
|
63
68
|
const SUPPORTED_EVENTS = new Set<KnowledgeContextEvent>([
|
|
@@ -97,8 +102,11 @@ export function getConfig(env: NodeJS.ProcessEnv = process.env): KnowledgeContex
|
|
|
97
102
|
timeoutMs: boundedNumber(env.HOOKS_KNOWLEDGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100, 10_000),
|
|
98
103
|
maxItems: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20),
|
|
99
104
|
maxTokens: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_TOKENS, DEFAULT_MAX_TOKENS, 100, 8_000),
|
|
105
|
+
minPromptChars: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS, DEFAULT_MIN_PROMPT_CHARS, 0, 4_000),
|
|
106
|
+
minSignalScore: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE, DEFAULT_MIN_SIGNAL_SCORE, 1, 10),
|
|
100
107
|
maxQueryChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_QUERY_CHARS, DEFAULT_MAX_QUERY_CHARS, 80, 4_000),
|
|
101
108
|
maxOutputChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS, 500, 20_000),
|
|
109
|
+
requireHighSignal: env.HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL !== "0",
|
|
102
110
|
};
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -135,6 +143,52 @@ export function sanitizeQuery(text: string, maxChars: number): string {
|
|
|
135
143
|
return redacted.length > maxChars ? redacted.slice(0, maxChars).trim() : redacted;
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
function cleanLegacyEmptyBullet(text: string): string {
|
|
147
|
+
return text.replace(/^(\s*[-*])\s*:\s*/gm, "$1 ");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cleanStringItem(text: string): string {
|
|
151
|
+
return cleanLegacyEmptyBullet(text).replace(/^[-*]\s*/, "").trim();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const DOMAIN_SIGNAL_RE =
|
|
155
|
+
/\b(auth|authz|rls|tenant|deploy|release|publish|rollback|merge|pr|staged|diff|security|secret|credential|prod|incident|migration|billing|stripe|oauth|webhook|connector|loop|workflow|agent|subagent|knowledge|hook|hooks|codewith|worktree|repo|repository|project|task|todo|todos|memento|mementos|conversation|conversations|context|config|configuration|npm|bun|package|cli|install|global|station|spark|machine|file|path|markdown|docs?|ci|tests?|build|typecheck|verify|debug|error|failing|failure|bug|regression)\b/i;
|
|
156
|
+
const ACTION_SIGNAL_RE =
|
|
157
|
+
/\b(add|build|change|check|configure|create|debug|fix|implement|improve|inspect|install|merge|patch|publish|refactor|release|rerun|review|rollback|test|update|verify)\b/i;
|
|
158
|
+
const STRUCTURAL_SIGNAL_RE =
|
|
159
|
+
/(`[^`]+`|(?:^|\s)(?:\.{1,2}\/)?[\w.-]+\/[\w./-]+|\/[\w./-]+|@hasna\/[a-z0-9-]+|\b(?:open|platform|iapp)-[a-z0-9-]+\b|[\w.-]+\.(ts|tsx|js|jsx|json|toml|md|yml|yaml|rs|py|go|sh|sql))/i;
|
|
160
|
+
const PROJECT_CWD_SIGNAL_RE = /\/(?:open|platform|iapp)-[a-z0-9-]+(?:\/|$)|\/hasna\/(?:opensource|infra|community)\//i;
|
|
161
|
+
|
|
162
|
+
export function promptSignalScore(prompt: string, cwd = ""): number {
|
|
163
|
+
const text = compactText(prompt);
|
|
164
|
+
if (!text) return 0;
|
|
165
|
+
|
|
166
|
+
let score = 0;
|
|
167
|
+
if (text.length >= 80) score += 3;
|
|
168
|
+
else if (text.length >= 40) score += 1;
|
|
169
|
+
|
|
170
|
+
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
171
|
+
if (wordCount >= 12) score += 3;
|
|
172
|
+
if (DOMAIN_SIGNAL_RE.test(text)) score += 3;
|
|
173
|
+
if (ACTION_SIGNAL_RE.test(text)) score += 2;
|
|
174
|
+
if (STRUCTURAL_SIGNAL_RE.test(text)) score += 3;
|
|
175
|
+
if (cwd && PROJECT_CWD_SIGNAL_RE.test(cwd) && text.length >= 20) score += 2;
|
|
176
|
+
if (/\b(error|exception|failed|failure|regression|stack trace|traceback)\b/i.test(text)) score += 2;
|
|
177
|
+
|
|
178
|
+
return score;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function shouldSearchKnowledge(input: HookInput, config: KnowledgeContextConfig): boolean {
|
|
182
|
+
if (!config.requireHighSignal) return true;
|
|
183
|
+
if (input.hook_event_name !== "UserPromptSubmit") return true;
|
|
184
|
+
|
|
185
|
+
const prompt = stringField(input, "prompt") || stringField(input, "user_prompt");
|
|
186
|
+
const score = promptSignalScore(prompt, stringField(input, "cwd"));
|
|
187
|
+
const promptChars = compactText(prompt).length;
|
|
188
|
+
|
|
189
|
+
return promptChars >= config.minPromptChars && score >= config.minSignalScore;
|
|
190
|
+
}
|
|
191
|
+
|
|
138
192
|
export function buildQuery(input: HookInput, config: KnowledgeContextConfig): string | null {
|
|
139
193
|
const cwd = stringField(input, "cwd");
|
|
140
194
|
const event = stringField(input, "hook_event_name");
|
|
@@ -258,16 +312,21 @@ function compactText(text: string): string {
|
|
|
258
312
|
}
|
|
259
313
|
|
|
260
314
|
function knowledgeItemId(source: string | null): string | null {
|
|
261
|
-
const
|
|
262
|
-
return
|
|
315
|
+
const direct = source?.match(/^knowledge:\/\/item\/([^/?#\s]+)$/);
|
|
316
|
+
if (direct) return direct[1];
|
|
317
|
+
|
|
318
|
+
const legacyPath = source?.match(/(?:^|\/)(k_[A-Za-z0-9]+_[A-Za-z0-9]+)(?:[?#].*)?$/);
|
|
319
|
+
return legacyPath?.[1] ?? null;
|
|
263
320
|
}
|
|
264
321
|
|
|
265
322
|
function formatPackItems(items: unknown[]): string | null {
|
|
266
323
|
const lines: string[] = [];
|
|
324
|
+
let hasKnowledgeItems = false;
|
|
267
325
|
|
|
268
326
|
for (const [index, item] of items.entries()) {
|
|
269
327
|
if (typeof item === "string" && item.trim()) {
|
|
270
|
-
|
|
328
|
+
const text = cleanStringItem(item);
|
|
329
|
+
if (text) lines.push(`- ${truncate(text, 900)}`);
|
|
271
330
|
continue;
|
|
272
331
|
}
|
|
273
332
|
if (!item || typeof item !== "object") continue;
|
|
@@ -295,23 +354,35 @@ function formatPackItems(items: unknown[]): string | null {
|
|
|
295
354
|
]);
|
|
296
355
|
if (!body && !source) continue;
|
|
297
356
|
|
|
298
|
-
const suffixParts = [
|
|
299
|
-
citationId && citationId !== title ? citationId : null,
|
|
300
|
-
source && !itemId ? `source: ${truncate(source, 180)}` : null,
|
|
301
|
-
].filter(Boolean);
|
|
302
|
-
const suffix = suffixParts.length > 0 ? ` (${suffixParts.join("; ")})` : "";
|
|
303
|
-
lines.push(`- ${truncate(title, 160)}${suffix}${body ? `: ${truncate(compactText(body), 520)}` : ""}`);
|
|
304
357
|
if (itemId) {
|
|
305
|
-
|
|
358
|
+
hasKnowledgeItems = true;
|
|
359
|
+
const labelParts = [`item_id=${itemId}`];
|
|
360
|
+
if (citationId) labelParts.push(`cite=${citationId}`);
|
|
361
|
+
const preview = body ? compactText(body) : title !== itemId ? title : "";
|
|
362
|
+
lines.push(`- ${labelParts.join(" ")}${preview ? `: ${truncate(preview, 520)}` : ""}`);
|
|
363
|
+
continue;
|
|
306
364
|
}
|
|
365
|
+
|
|
366
|
+
const suffixParts = [
|
|
367
|
+
citationId ? `cite=${citationId}` : null,
|
|
368
|
+
source ? `source=${truncate(source, 180)}` : null,
|
|
369
|
+
].filter(Boolean);
|
|
370
|
+
const label = suffixParts.length > 0 ? suffixParts.join(" ") : truncate(title, 160);
|
|
371
|
+
lines.push(`- ${label}${body ? `: ${truncate(compactText(body), 520)}` : ""}`);
|
|
307
372
|
}
|
|
308
373
|
|
|
309
|
-
|
|
374
|
+
if (lines.length === 0) return null;
|
|
375
|
+
if (!hasKnowledgeItems) return lines.join("\n");
|
|
376
|
+
return [
|
|
377
|
+
"If a match looks relevant, read it with: knowledge get --id <item_id> --json",
|
|
378
|
+
"",
|
|
379
|
+
...lines,
|
|
380
|
+
].join("\n");
|
|
310
381
|
}
|
|
311
382
|
|
|
312
383
|
export function extractContextText(pack: unknown): string | null {
|
|
313
384
|
if (typeof pack === "string") {
|
|
314
|
-
return pack.trim() || null;
|
|
385
|
+
return cleanLegacyEmptyBullet(pack.trim()) || null;
|
|
315
386
|
}
|
|
316
387
|
if (Array.isArray(pack)) {
|
|
317
388
|
return formatPackItems(pack);
|
|
@@ -339,7 +410,7 @@ export function formatAdditionalContext(
|
|
|
339
410
|
context: string,
|
|
340
411
|
config: KnowledgeContextConfig
|
|
341
412
|
): string {
|
|
342
|
-
const header = `[hook-knowledge-context]
|
|
413
|
+
const header = `[hook-knowledge-context] Knowledge matches (${event}; top ${config.maxItems}, deterministic search):`;
|
|
343
414
|
const safeContext = redactSecrets(context.trim());
|
|
344
415
|
return `${header}\n\n${truncate(safeContext, Math.max(0, config.maxOutputChars - header.length - 2))}`;
|
|
345
416
|
}
|
|
@@ -363,6 +434,10 @@ export async function buildHookOutput(
|
|
|
363
434
|
}
|
|
364
435
|
|
|
365
436
|
const config = getConfig(env);
|
|
437
|
+
if (!shouldSearchKnowledge(input, config)) {
|
|
438
|
+
return { continue: true };
|
|
439
|
+
}
|
|
440
|
+
|
|
366
441
|
const query = buildQuery(input, config);
|
|
367
442
|
if (!query) {
|
|
368
443
|
return { continue: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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": {
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"bin/",
|
|
74
74
|
"dist/",
|
|
75
75
|
"hooks/",
|
|
76
|
+
"!hooks/*/dist/",
|
|
76
77
|
"README.md",
|
|
77
78
|
"LICENSE"
|
|
78
79
|
],
|