@hasna/hooks 0.3.5 → 0.3.7
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 +26 -4
- package/hooks/hook-knowledge-context/package.json +1 -1
- package/hooks/hook-knowledge-context/src/hook.test.ts +199 -12
- package/hooks/hook-knowledge-context/src/hook.ts +134 -22
- 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.6",
|
|
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.6",
|
|
1014
1014
|
category: "Context Management",
|
|
1015
1015
|
event: "SessionStart",
|
|
1016
1016
|
events: ["SessionStart", "UserPromptSubmit", "SubagentStart"],
|
|
@@ -27,12 +27,26 @@ 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. Items that mark themselves as
|
|
38
|
+
historical/reference-only or not suitable for auto-loading are filtered out by
|
|
39
|
+
default, because they are not useful ambient context for normal agent work.
|
|
40
|
+
|
|
41
|
+
Example injected context:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
[hook-knowledge-context] Knowledge matches (UserPromptSubmit; top 3, deterministic search):
|
|
45
|
+
|
|
46
|
+
If a match looks relevant, read it with: knowledge get --id <item_id> --json
|
|
47
|
+
|
|
48
|
+
- item_id=k_example cite=cite_123: Bounded preview text...
|
|
49
|
+
```
|
|
36
50
|
|
|
37
51
|
All failures are fail-open: missing CLI, timeout, nonzero exit, malformed JSON,
|
|
38
52
|
bad stdin, or empty packs return `{"continue":true}` without context.
|
|
@@ -43,12 +57,20 @@ bad stdin, or empty packs return `{"continue":true}` without context.
|
|
|
43
57
|
export HOOKS_KNOWLEDGE_CONTEXT_DISABLE=1 # Kill switch
|
|
44
58
|
export HOOKS_KNOWLEDGE_COMMAND=knowledge # CLI command/path
|
|
45
59
|
export HOOKS_KNOWLEDGE_TIMEOUT_MS=5000 # Per-call timeout
|
|
46
|
-
export HOOKS_KNOWLEDGE_MAX_ITEMS=3 #
|
|
60
|
+
export HOOKS_KNOWLEDGE_MAX_ITEMS=3 # Rendered item budget
|
|
61
|
+
export HOOKS_KNOWLEDGE_CANDIDATE_ITEMS=12 # Internal candidate budget before filtering
|
|
47
62
|
export HOOKS_KNOWLEDGE_MAX_TOKENS=1200 # Context pack token budget
|
|
63
|
+
export HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL=1
|
|
64
|
+
export HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS=6
|
|
65
|
+
export HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE=3
|
|
48
66
|
export HOOKS_KNOWLEDGE_MAX_QUERY_CHARS=1200 # Redacted query bound
|
|
49
67
|
export HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS=8000
|
|
50
68
|
```
|
|
51
69
|
|
|
70
|
+
All numeric env overrides are bounded. `HOOKS_KNOWLEDGE_COMMAND` accepts only a
|
|
71
|
+
single command/path token; unsafe values with whitespace fall back to
|
|
72
|
+
`knowledge`.
|
|
73
|
+
|
|
52
74
|
## Events
|
|
53
75
|
|
|
54
76
|
- `SessionStart`
|
|
@@ -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,
|
|
@@ -15,16 +16,49 @@ const baseConfig: KnowledgeContextConfig = {
|
|
|
15
16
|
command: "knowledge",
|
|
16
17
|
timeoutMs: 5000,
|
|
17
18
|
maxItems: 3,
|
|
19
|
+
candidateItems: 12,
|
|
18
20
|
maxTokens: 1200,
|
|
21
|
+
minPromptChars: 6,
|
|
22
|
+
minSignalScore: 3,
|
|
19
23
|
maxQueryChars: 1200,
|
|
20
24
|
maxOutputChars: 8000,
|
|
25
|
+
requireHighSignal: true,
|
|
21
26
|
};
|
|
22
27
|
|
|
23
28
|
describe("hook-knowledge-context", () => {
|
|
24
29
|
test("defaults Knowledge CLI timeout to 5000ms and keeps env override", () => {
|
|
25
30
|
expect(getConfig({}).timeoutMs).toBe(5000);
|
|
26
31
|
expect(getConfig({}).maxItems).toBe(3);
|
|
32
|
+
expect(getConfig({}).candidateItems).toBe(12);
|
|
33
|
+
expect(getConfig({}).minPromptChars).toBe(6);
|
|
34
|
+
expect(getConfig({}).minSignalScore).toBe(3);
|
|
35
|
+
expect(getConfig({}).requireHighSignal).toBe(true);
|
|
27
36
|
expect(getConfig({ HOOKS_KNOWLEDGE_TIMEOUT_MS: "2345" }).timeoutMs).toBe(2345);
|
|
37
|
+
expect(getConfig({ HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL: "0" }).requireHighSignal).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("applies bounded env overrides and rejects unsafe command override", () => {
|
|
41
|
+
const config = getConfig({
|
|
42
|
+
HOOKS_KNOWLEDGE_COMMAND: "/tmp/knowledge",
|
|
43
|
+
HOOKS_KNOWLEDGE_MAX_ITEMS: "9",
|
|
44
|
+
HOOKS_KNOWLEDGE_CANDIDATE_ITEMS: "15",
|
|
45
|
+
HOOKS_KNOWLEDGE_MAX_TOKENS: "2345",
|
|
46
|
+
HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS: "12",
|
|
47
|
+
HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE: "4",
|
|
48
|
+
HOOKS_KNOWLEDGE_MAX_QUERY_CHARS: "200",
|
|
49
|
+
HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS: "9999",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(config.command).toBe("/tmp/knowledge");
|
|
53
|
+
expect(config.maxItems).toBe(9);
|
|
54
|
+
expect(config.candidateItems).toBe(15);
|
|
55
|
+
expect(config.maxTokens).toBe(2345);
|
|
56
|
+
expect(config.minPromptChars).toBe(12);
|
|
57
|
+
expect(config.minSignalScore).toBe(4);
|
|
58
|
+
expect(config.maxQueryChars).toBe(200);
|
|
59
|
+
expect(config.maxOutputChars).toBe(9999);
|
|
60
|
+
expect(buildKnowledgeArgs("q", config)).toContain("15");
|
|
61
|
+
expect(getConfig({ HOOKS_KNOWLEDGE_COMMAND: "bad command" }).command).toBe("knowledge");
|
|
28
62
|
});
|
|
29
63
|
|
|
30
64
|
test("builds deterministic knowledge context pack args without semantic, web, or generation flags", () => {
|
|
@@ -37,7 +71,7 @@ describe("hook-knowledge-context", () => {
|
|
|
37
71
|
"--from",
|
|
38
72
|
"search",
|
|
39
73
|
"--max-items",
|
|
40
|
-
"
|
|
74
|
+
"12",
|
|
41
75
|
"--max-tokens",
|
|
42
76
|
"1200",
|
|
43
77
|
"--json",
|
|
@@ -67,7 +101,7 @@ describe("hook-knowledge-context", () => {
|
|
|
67
101
|
|
|
68
102
|
expect(output.continue).toBe(true);
|
|
69
103
|
expect(output.hookSpecificOutput?.hookEventName).toBe("SessionStart");
|
|
70
|
-
expect(output.hookSpecificOutput?.additionalContext).toContain("
|
|
104
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Knowledge matches");
|
|
71
105
|
expect(output.hookSpecificOutput?.additionalContext).toContain("Use hooks.json");
|
|
72
106
|
});
|
|
73
107
|
|
|
@@ -87,7 +121,7 @@ describe("hook-knowledge-context", () => {
|
|
|
87
121
|
{
|
|
88
122
|
hook_event_name: "UserPromptSubmit",
|
|
89
123
|
cwd: "/repo",
|
|
90
|
-
prompt: "implement
|
|
124
|
+
prompt: "implement the knowledge hook context output with token=demo",
|
|
91
125
|
},
|
|
92
126
|
executor
|
|
93
127
|
);
|
|
@@ -151,6 +185,46 @@ describe("hook-knowledge-context", () => {
|
|
|
151
185
|
).toEqual({ continue: true });
|
|
152
186
|
});
|
|
153
187
|
|
|
188
|
+
test("fails open for low-signal UserPromptSubmit prompts before calling Knowledge", async () => {
|
|
189
|
+
let executorCalls = 0;
|
|
190
|
+
const executor: KnowledgeExecutor = async () => {
|
|
191
|
+
executorCalls += 1;
|
|
192
|
+
throw new Error("executor should not run for low-signal prompts");
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
for (const prompt of ["", "ok", "thanks", "?", "continue", "tell me again"]) {
|
|
196
|
+
expect(
|
|
197
|
+
await buildHookOutput({ hook_event_name: "UserPromptSubmit", cwd: "/repo", prompt }, executor)
|
|
198
|
+
).toEqual({ continue: true });
|
|
199
|
+
}
|
|
200
|
+
expect(executorCalls).toBe(0);
|
|
201
|
+
for (const prompt of [
|
|
202
|
+
"fix authz",
|
|
203
|
+
"release",
|
|
204
|
+
"publish npm",
|
|
205
|
+
"check src/hook.ts",
|
|
206
|
+
"@hasna/hooks",
|
|
207
|
+
"merge PR 12",
|
|
208
|
+
"review staged diff",
|
|
209
|
+
"rerun tests",
|
|
210
|
+
]) {
|
|
211
|
+
expect(promptSignalScore(prompt, "/home/hasna/workspace/hasna/opensource/open-hooks")).toBeGreaterThanOrEqual(3);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("can opt out of high-signal gating for UserPromptSubmit", async () => {
|
|
216
|
+
const output = await buildHookOutput(
|
|
217
|
+
{ hook_event_name: "UserPromptSubmit", cwd: "/repo", prompt: "ok" },
|
|
218
|
+
async () => ({
|
|
219
|
+
ok: true,
|
|
220
|
+
stdout: JSON.stringify({ context: "Forced low-signal context." }),
|
|
221
|
+
}),
|
|
222
|
+
{ HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL: "0" }
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(output.hookSpecificOutput?.additionalContext).toContain("Forced low-signal context");
|
|
226
|
+
});
|
|
227
|
+
|
|
154
228
|
test("sanitizes and bounds queries", () => {
|
|
155
229
|
expect(redactSecrets("password=hunter2 token=demo")).toBe(
|
|
156
230
|
"password=<redacted> token=<redacted>"
|
|
@@ -172,21 +246,134 @@ describe("hook-knowledge-context", () => {
|
|
|
172
246
|
expect(extractContextText([{ title: "One", text: "Body", uri: "knowledge://item/one" }])).toContain("Body");
|
|
173
247
|
});
|
|
174
248
|
|
|
175
|
-
test("formats citation
|
|
249
|
+
test("formats citation-only packs as item_id/cite bullets with one read hint", () => {
|
|
250
|
+
const text = extractContextText({
|
|
251
|
+
citations: [
|
|
252
|
+
{
|
|
253
|
+
id: "cite_1",
|
|
254
|
+
source_ref: "knowledge://item/k_one",
|
|
255
|
+
quote_preview: "First preview that tells the agent why this Knowledge item might matter.",
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "cite_2",
|
|
259
|
+
source_ref: "knowledge://item/k_two",
|
|
260
|
+
quote_preview: "Second preview that tells the agent why this Knowledge item might matter.",
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(text).toContain("If a match looks relevant, read it with: knowledge get --id <item_id> --json");
|
|
266
|
+
expect(text?.match(/knowledge get --id/g) ?? []).toHaveLength(1);
|
|
267
|
+
expect(text).toContain("- item_id=k_one cite=cite_1: First preview");
|
|
268
|
+
expect(text).toContain("- item_id=k_two cite=cite_2: Second preview");
|
|
269
|
+
expect(text).not.toContain("open: knowledge get --id k_one --json");
|
|
270
|
+
expect(text).not.toContain("(cite_1)");
|
|
271
|
+
expect(text).not.toContain("source: knowledge://item/k_one");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("extracts item_id from legacy Knowledge source paths", () => {
|
|
275
|
+
const text = extractContextText({
|
|
276
|
+
citations: [
|
|
277
|
+
{
|
|
278
|
+
id: "cite_abc",
|
|
279
|
+
source_ref: "open-files://source/legacy-json/path/k_mqyrz7gt_olq82e",
|
|
280
|
+
quote_preview: "Legacy file source preview.",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(text).toContain("item_id=k_mqyrz7gt_olq82e");
|
|
286
|
+
expect(text).toContain("cite=cite_abc");
|
|
287
|
+
expect(text).not.toContain("source=open-files://source/legacy-json/path/k_mqyrz7gt_olq82e");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("keeps source fallback for non-Knowledge citations without item ids", () => {
|
|
176
291
|
const text = extractContextText({
|
|
177
292
|
citations: [
|
|
178
293
|
{
|
|
179
|
-
id: "
|
|
180
|
-
source_ref: "
|
|
181
|
-
quote_preview: "
|
|
294
|
+
id: "cite_url",
|
|
295
|
+
source_ref: "https://example.test/doc",
|
|
296
|
+
quote_preview: "External preview.",
|
|
182
297
|
},
|
|
183
298
|
],
|
|
184
299
|
});
|
|
185
300
|
|
|
186
|
-
expect(text).toContain("-
|
|
187
|
-
expect(text).toContain("
|
|
188
|
-
expect(text).toContain("
|
|
189
|
-
|
|
190
|
-
|
|
301
|
+
expect(text).toContain("- cite=cite_url source=https://example.test/doc: External preview.");
|
|
302
|
+
expect(text).not.toContain("item_id=");
|
|
303
|
+
expect(text).not.toContain("knowledge get --id");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("cleans legacy empty-title bullet artifacts in item text", () => {
|
|
307
|
+
expect(extractContextText(["- : legacy empty title preview"])).toBe("- legacy empty title preview");
|
|
308
|
+
expect(extractContextText("- : legacy direct context")).toBe("- legacy direct context");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("filters historical reference-only archive items from injected context", () => {
|
|
312
|
+
const text = extractContextText(
|
|
313
|
+
{
|
|
314
|
+
citations: [
|
|
315
|
+
{
|
|
316
|
+
id: "cite_archived",
|
|
317
|
+
source_ref: "knowledge://item/k_archived",
|
|
318
|
+
quote_preview:
|
|
319
|
+
"Archived on 2026-06-25 before removing brain orchestration startup files from spark01. Historical/reference only; not an active startup instruction.",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "cite_active",
|
|
323
|
+
source_ref: "knowledge://item/k_active",
|
|
324
|
+
quote_preview: "Current npm publish convention: use scoped granular tokens through the secrets CLI.",
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
{ maxItems: 3 }
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(text).toContain("item_id=k_active cite=cite_active");
|
|
332
|
+
expect(text).toContain("Current npm publish convention");
|
|
333
|
+
expect(text).not.toContain("k_archived");
|
|
334
|
+
expect(text).not.toContain("Archived on 2026-06-25");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("fails open when all citation candidates are non-autoloadable archive items", () => {
|
|
338
|
+
expect(
|
|
339
|
+
extractContextText(
|
|
340
|
+
{
|
|
341
|
+
citations: [
|
|
342
|
+
{
|
|
343
|
+
id: "cite_archived",
|
|
344
|
+
source_ref: "knowledge://item/k_archived",
|
|
345
|
+
quote_preview:
|
|
346
|
+
"Archived on 2026-06-25 from local Claude startup context. These instructions are historical/reference only and should not be auto-loaded for normal agents.",
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
{ maxItems: 3 }
|
|
351
|
+
)
|
|
352
|
+
).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("caps emitted items after filtering extra search candidates", () => {
|
|
356
|
+
const text = extractContextText(
|
|
357
|
+
{
|
|
358
|
+
citations: [
|
|
359
|
+
{
|
|
360
|
+
id: "cite_archived",
|
|
361
|
+
source_ref: "knowledge://item/k_archived",
|
|
362
|
+
quote_preview:
|
|
363
|
+
"Archived on 2026-06-25 before removing brain orchestration startup files. Historical/reference only; not an active startup instruction.",
|
|
364
|
+
},
|
|
365
|
+
{ id: "cite_one", source_ref: "knowledge://item/k_one", quote_preview: "First active item." },
|
|
366
|
+
{ id: "cite_two", source_ref: "knowledge://item/k_two", quote_preview: "Second active item." },
|
|
367
|
+
{ id: "cite_three", source_ref: "knowledge://item/k_three", quote_preview: "Third active item." },
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
{ maxItems: 2 }
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(text?.match(/^- item_id=/gm) ?? []).toHaveLength(2);
|
|
374
|
+
expect(text).toContain("item_id=k_one");
|
|
375
|
+
expect(text).toContain("item_id=k_two");
|
|
376
|
+
expect(text).not.toContain("item_id=k_three");
|
|
377
|
+
expect(text).not.toContain("item_id=k_archived");
|
|
191
378
|
});
|
|
192
379
|
});
|
|
@@ -39,9 +39,13 @@ export interface KnowledgeContextConfig {
|
|
|
39
39
|
command: string;
|
|
40
40
|
timeoutMs: number;
|
|
41
41
|
maxItems: number;
|
|
42
|
+
candidateItems: number;
|
|
42
43
|
maxTokens: number;
|
|
44
|
+
minPromptChars: number;
|
|
45
|
+
minSignalScore: number;
|
|
43
46
|
maxQueryChars: number;
|
|
44
47
|
maxOutputChars: number;
|
|
48
|
+
requireHighSignal: boolean;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
export interface KnowledgeExecResult {
|
|
@@ -58,6 +62,8 @@ export type KnowledgeExecutor = (
|
|
|
58
62
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
59
63
|
const DEFAULT_MAX_ITEMS = 3;
|
|
60
64
|
const DEFAULT_MAX_TOKENS = 1200;
|
|
65
|
+
const DEFAULT_MIN_PROMPT_CHARS = 6;
|
|
66
|
+
const DEFAULT_MIN_SIGNAL_SCORE = 3;
|
|
61
67
|
const DEFAULT_MAX_QUERY_CHARS = 1200;
|
|
62
68
|
const DEFAULT_MAX_OUTPUT_CHARS = 8000;
|
|
63
69
|
const SUPPORTED_EVENTS = new Set<KnowledgeContextEvent>([
|
|
@@ -92,13 +98,23 @@ function commandFromEnv(raw: string | undefined): string {
|
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
export function getConfig(env: NodeJS.ProcessEnv = process.env): KnowledgeContextConfig {
|
|
101
|
+
const maxItems = boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20);
|
|
95
102
|
return {
|
|
96
103
|
command: commandFromEnv(env.HOOKS_KNOWLEDGE_COMMAND),
|
|
97
104
|
timeoutMs: boundedNumber(env.HOOKS_KNOWLEDGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100, 10_000),
|
|
98
|
-
maxItems
|
|
105
|
+
maxItems,
|
|
106
|
+
candidateItems: boundedNumber(
|
|
107
|
+
env.HOOKS_KNOWLEDGE_CANDIDATE_ITEMS,
|
|
108
|
+
Math.min(20, Math.max(maxItems, maxItems * 4)),
|
|
109
|
+
maxItems,
|
|
110
|
+
20
|
|
111
|
+
),
|
|
99
112
|
maxTokens: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_TOKENS, DEFAULT_MAX_TOKENS, 100, 8_000),
|
|
113
|
+
minPromptChars: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS, DEFAULT_MIN_PROMPT_CHARS, 0, 4_000),
|
|
114
|
+
minSignalScore: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE, DEFAULT_MIN_SIGNAL_SCORE, 1, 10),
|
|
100
115
|
maxQueryChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_QUERY_CHARS, DEFAULT_MAX_QUERY_CHARS, 80, 4_000),
|
|
101
116
|
maxOutputChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS, 500, 20_000),
|
|
117
|
+
requireHighSignal: env.HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL !== "0",
|
|
102
118
|
};
|
|
103
119
|
}
|
|
104
120
|
|
|
@@ -135,6 +151,52 @@ export function sanitizeQuery(text: string, maxChars: number): string {
|
|
|
135
151
|
return redacted.length > maxChars ? redacted.slice(0, maxChars).trim() : redacted;
|
|
136
152
|
}
|
|
137
153
|
|
|
154
|
+
function cleanLegacyEmptyBullet(text: string): string {
|
|
155
|
+
return text.replace(/^(\s*[-*])\s*:\s*/gm, "$1 ");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cleanStringItem(text: string): string {
|
|
159
|
+
return cleanLegacyEmptyBullet(text).replace(/^[-*]\s*/, "").trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const DOMAIN_SIGNAL_RE =
|
|
163
|
+
/\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;
|
|
164
|
+
const ACTION_SIGNAL_RE =
|
|
165
|
+
/\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;
|
|
166
|
+
const STRUCTURAL_SIGNAL_RE =
|
|
167
|
+
/(`[^`]+`|(?:^|\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;
|
|
168
|
+
const PROJECT_CWD_SIGNAL_RE = /\/(?:open|platform|iapp)-[a-z0-9-]+(?:\/|$)|\/hasna\/(?:opensource|infra|community)\//i;
|
|
169
|
+
|
|
170
|
+
export function promptSignalScore(prompt: string, cwd = ""): number {
|
|
171
|
+
const text = compactText(prompt);
|
|
172
|
+
if (!text) return 0;
|
|
173
|
+
|
|
174
|
+
let score = 0;
|
|
175
|
+
if (text.length >= 80) score += 3;
|
|
176
|
+
else if (text.length >= 40) score += 1;
|
|
177
|
+
|
|
178
|
+
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
179
|
+
if (wordCount >= 12) score += 3;
|
|
180
|
+
if (DOMAIN_SIGNAL_RE.test(text)) score += 3;
|
|
181
|
+
if (ACTION_SIGNAL_RE.test(text)) score += 2;
|
|
182
|
+
if (STRUCTURAL_SIGNAL_RE.test(text)) score += 3;
|
|
183
|
+
if (cwd && PROJECT_CWD_SIGNAL_RE.test(cwd) && text.length >= 20) score += 2;
|
|
184
|
+
if (/\b(error|exception|failed|failure|regression|stack trace|traceback)\b/i.test(text)) score += 2;
|
|
185
|
+
|
|
186
|
+
return score;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function shouldSearchKnowledge(input: HookInput, config: KnowledgeContextConfig): boolean {
|
|
190
|
+
if (!config.requireHighSignal) return true;
|
|
191
|
+
if (input.hook_event_name !== "UserPromptSubmit") return true;
|
|
192
|
+
|
|
193
|
+
const prompt = stringField(input, "prompt") || stringField(input, "user_prompt");
|
|
194
|
+
const score = promptSignalScore(prompt, stringField(input, "cwd"));
|
|
195
|
+
const promptChars = compactText(prompt).length;
|
|
196
|
+
|
|
197
|
+
return promptChars >= config.minPromptChars && score >= config.minSignalScore;
|
|
198
|
+
}
|
|
199
|
+
|
|
138
200
|
export function buildQuery(input: HookInput, config: KnowledgeContextConfig): string | null {
|
|
139
201
|
const cwd = stringField(input, "cwd");
|
|
140
202
|
const event = stringField(input, "hook_event_name");
|
|
@@ -170,7 +232,7 @@ export function buildKnowledgeArgs(query: string, config: KnowledgeContextConfig
|
|
|
170
232
|
"--from",
|
|
171
233
|
"search",
|
|
172
234
|
"--max-items",
|
|
173
|
-
String(config.
|
|
235
|
+
String(config.candidateItems),
|
|
174
236
|
"--max-tokens",
|
|
175
237
|
String(config.maxTokens),
|
|
176
238
|
"--json",
|
|
@@ -258,16 +320,38 @@ function compactText(text: string): string {
|
|
|
258
320
|
}
|
|
259
321
|
|
|
260
322
|
function knowledgeItemId(source: string | null): string | null {
|
|
261
|
-
const
|
|
262
|
-
return
|
|
323
|
+
const direct = source?.match(/^knowledge:\/\/item\/([^/?#\s]+)$/);
|
|
324
|
+
if (direct) return direct[1];
|
|
325
|
+
|
|
326
|
+
const legacyPath = source?.match(/(?:^|\/)(k_[A-Za-z0-9]+_[A-Za-z0-9]+)(?:[?#].*)?$/);
|
|
327
|
+
return legacyPath?.[1] ?? null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface ExtractContextOptions {
|
|
331
|
+
maxItems?: number;
|
|
332
|
+
includeHistorical?: boolean;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const NON_AUTOLOADABLE_HISTORY_RE =
|
|
336
|
+
/\b(historical\/reference only|historical reference only|should not be auto-?loaded|do not auto-?load|not an active startup instruction|brain orchestration startup files)\b/i;
|
|
337
|
+
const ARCHIVED_STARTUP_RE = /^Archived on \d{4}-\d{2}-\d{2}\b.*\b(startup context|startup files|CLAUDE\.md)\b/i;
|
|
338
|
+
|
|
339
|
+
function shouldSuppressKnowledgeItem(text: string, options: ExtractContextOptions): boolean {
|
|
340
|
+
if (options.includeHistorical) return false;
|
|
341
|
+
const compact = compactText(text);
|
|
342
|
+
return NON_AUTOLOADABLE_HISTORY_RE.test(compact) || ARCHIVED_STARTUP_RE.test(compact);
|
|
263
343
|
}
|
|
264
344
|
|
|
265
|
-
function formatPackItems(items: unknown[]): string | null {
|
|
345
|
+
function formatPackItems(items: unknown[], options: ExtractContextOptions = {}): string | null {
|
|
266
346
|
const lines: string[] = [];
|
|
347
|
+
let hasKnowledgeItems = false;
|
|
348
|
+
const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
|
|
267
349
|
|
|
268
350
|
for (const [index, item] of items.entries()) {
|
|
351
|
+
if (lines.length >= maxItems) break;
|
|
269
352
|
if (typeof item === "string" && item.trim()) {
|
|
270
|
-
|
|
353
|
+
const text = cleanStringItem(item);
|
|
354
|
+
if (text && !shouldSuppressKnowledgeItem(text, options)) lines.push(`- ${truncate(text, 900)}`);
|
|
271
355
|
continue;
|
|
272
356
|
}
|
|
273
357
|
if (!item || typeof item !== "object") continue;
|
|
@@ -294,27 +378,48 @@ function formatPackItems(items: unknown[]): string | null {
|
|
|
294
378
|
"description",
|
|
295
379
|
]);
|
|
296
380
|
if (!body && !source) continue;
|
|
381
|
+
const itemText = [title, body, source].filter(Boolean).join(" ");
|
|
382
|
+
if (shouldSuppressKnowledgeItem(itemText, options)) continue;
|
|
297
383
|
|
|
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
384
|
if (itemId) {
|
|
305
|
-
|
|
385
|
+
hasKnowledgeItems = true;
|
|
386
|
+
const labelParts = [`item_id=${itemId}`];
|
|
387
|
+
if (citationId) labelParts.push(`cite=${citationId}`);
|
|
388
|
+
const preview = body ? compactText(body) : title !== itemId ? title : "";
|
|
389
|
+
lines.push(`- ${labelParts.join(" ")}${preview ? `: ${truncate(preview, 520)}` : ""}`);
|
|
390
|
+
continue;
|
|
306
391
|
}
|
|
392
|
+
|
|
393
|
+
const suffixParts = [
|
|
394
|
+
citationId ? `cite=${citationId}` : null,
|
|
395
|
+
source ? `source=${truncate(source, 180)}` : null,
|
|
396
|
+
].filter(Boolean);
|
|
397
|
+
const label = suffixParts.length > 0 ? suffixParts.join(" ") : truncate(title, 160);
|
|
398
|
+
lines.push(`- ${label}${body ? `: ${truncate(compactText(body), 520)}` : ""}`);
|
|
307
399
|
}
|
|
308
400
|
|
|
309
|
-
|
|
401
|
+
if (lines.length === 0) return null;
|
|
402
|
+
if (!hasKnowledgeItems) return lines.join("\n");
|
|
403
|
+
return [
|
|
404
|
+
"If a match looks relevant, read it with: knowledge get --id <item_id> --json",
|
|
405
|
+
"",
|
|
406
|
+
...lines,
|
|
407
|
+
].join("\n");
|
|
310
408
|
}
|
|
311
409
|
|
|
312
|
-
|
|
410
|
+
function shouldIncludeHistoricalItems(input: HookInput): boolean {
|
|
411
|
+
const prompt = `${stringField(input, "prompt")} ${stringField(input, "user_prompt")}`;
|
|
412
|
+
return /\b(archive|archived|historical|history|reference-only|reference only|old startup|startup files|CLAUDE\.md)\b/i.test(prompt);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function extractContextText(pack: unknown, options: ExtractContextOptions = {}): string | null {
|
|
313
416
|
if (typeof pack === "string") {
|
|
314
|
-
|
|
417
|
+
const text = cleanLegacyEmptyBullet(pack.trim());
|
|
418
|
+
if (!text || shouldSuppressKnowledgeItem(text, options)) return null;
|
|
419
|
+
return text;
|
|
315
420
|
}
|
|
316
421
|
if (Array.isArray(pack)) {
|
|
317
|
-
return formatPackItems(pack);
|
|
422
|
+
return formatPackItems(pack, options);
|
|
318
423
|
}
|
|
319
424
|
if (!pack || typeof pack !== "object") {
|
|
320
425
|
return null;
|
|
@@ -322,16 +427,16 @@ export function extractContextText(pack: unknown): string | null {
|
|
|
322
427
|
|
|
323
428
|
const obj = pack as Record<string, unknown>;
|
|
324
429
|
const direct = firstString(obj, ["additionalContext", "context", "markdown", "content", "text", "summary"]);
|
|
325
|
-
if (direct) return direct;
|
|
430
|
+
if (direct) return shouldSuppressKnowledgeItem(direct, options) ? null : direct;
|
|
326
431
|
|
|
327
432
|
for (const key of ["pack", "contextPack", "context_pack", "data", "result"]) {
|
|
328
433
|
const nested = obj[key];
|
|
329
|
-
const text = extractContextText(nested);
|
|
434
|
+
const text = extractContextText(nested, options);
|
|
330
435
|
if (text) return text;
|
|
331
436
|
}
|
|
332
437
|
|
|
333
438
|
const items = firstArray(obj, ["items", "results", "sources", "citations"]);
|
|
334
|
-
return items ? formatPackItems(items) : null;
|
|
439
|
+
return items ? formatPackItems(items, options) : null;
|
|
335
440
|
}
|
|
336
441
|
|
|
337
442
|
export function formatAdditionalContext(
|
|
@@ -339,7 +444,7 @@ export function formatAdditionalContext(
|
|
|
339
444
|
context: string,
|
|
340
445
|
config: KnowledgeContextConfig
|
|
341
446
|
): string {
|
|
342
|
-
const header = `[hook-knowledge-context]
|
|
447
|
+
const header = `[hook-knowledge-context] Knowledge matches (${event}; top ${config.maxItems}, deterministic search):`;
|
|
343
448
|
const safeContext = redactSecrets(context.trim());
|
|
344
449
|
return `${header}\n\n${truncate(safeContext, Math.max(0, config.maxOutputChars - header.length - 2))}`;
|
|
345
450
|
}
|
|
@@ -363,6 +468,10 @@ export async function buildHookOutput(
|
|
|
363
468
|
}
|
|
364
469
|
|
|
365
470
|
const config = getConfig(env);
|
|
471
|
+
if (!shouldSearchKnowledge(input, config)) {
|
|
472
|
+
return { continue: true };
|
|
473
|
+
}
|
|
474
|
+
|
|
366
475
|
const query = buildQuery(input, config);
|
|
367
476
|
if (!query) {
|
|
368
477
|
return { continue: true };
|
|
@@ -375,7 +484,10 @@ export async function buildHookOutput(
|
|
|
375
484
|
}
|
|
376
485
|
|
|
377
486
|
const parsed = JSON.parse(result.stdout);
|
|
378
|
-
const context = extractContextText(parsed
|
|
487
|
+
const context = extractContextText(parsed, {
|
|
488
|
+
maxItems: config.maxItems,
|
|
489
|
+
includeHistorical: shouldIncludeHistoricalItems(input),
|
|
490
|
+
});
|
|
379
491
|
if (!context) {
|
|
380
492
|
return { continue: true };
|
|
381
493
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hooks",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
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
|
],
|