@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 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.4",
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.4",
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 a bounded preview and a
34
- `knowledge get --id ... --json` read hint so agents can decide which full items
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 # Context pack item budget
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`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hook-knowledge-context",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Codewith hook that injects deterministic Knowledge context packs into lifecycle events",
5
5
  "type": "module",
6
6
  "main": "./dist/hook.js",
@@ -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
- "3",
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("knowledge context pack --from search");
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 this with token=demo",
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 previews as useful progressive blurbs with compact open commands", () => {
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: "cite_123",
180
- source_ref: "knowledge://item/k_example",
181
- quote_preview: "A short preview that tells the agent why this Knowledge item might matter.",
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("- k_example (cite_123): A short preview");
187
- expect(text).toContain("cite_123");
188
- expect(text).toContain("A short preview");
189
- expect(text).toContain("open: knowledge get --id k_example --json");
190
- expect(text).not.toContain("source: knowledge://item/k_example");
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: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20),
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.maxItems),
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 match = source?.match(/^knowledge:\/\/item\/([^/?#\s]+)$/);
262
- return match?.[1] ?? null;
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
- lines.push(`- ${truncate(item.trim(), 900)}`);
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
- lines.push(` open: knowledge get --id ${itemId} --json`);
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
- return lines.length > 0 ? lines.join("\n") : null;
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
- export function extractContextText(pack: unknown): string | null {
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
- return pack.trim() || null;
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] Deterministic Knowledge context (${event}; knowledge context pack --from search):`;
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.5",
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
  ],