@hasna/hook-knowledge-context 0.1.4 → 0.1.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/README.md CHANGED
@@ -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`
package/dist/hook.d.ts CHANGED
@@ -31,9 +31,13 @@ export interface KnowledgeContextConfig {
31
31
  command: string;
32
32
  timeoutMs: number;
33
33
  maxItems: number;
34
+ candidateItems: number;
34
35
  maxTokens: number;
36
+ minPromptChars: number;
37
+ minSignalScore: number;
35
38
  maxQueryChars: number;
36
39
  maxOutputChars: number;
40
+ requireHighSignal: boolean;
37
41
  }
38
42
  export interface KnowledgeExecResult {
39
43
  ok: boolean;
@@ -45,11 +49,17 @@ export declare function getConfig(env?: NodeJS.ProcessEnv): KnowledgeContextConf
45
49
  export declare function isKnowledgeContextEvent(value: string | undefined): value is KnowledgeContextEvent;
46
50
  export declare function redactSecrets(text: string): string;
47
51
  export declare function sanitizeQuery(text: string, maxChars: number): string;
52
+ export declare function promptSignalScore(prompt: string, cwd?: string): number;
53
+ export declare function shouldSearchKnowledge(input: HookInput, config: KnowledgeContextConfig): boolean;
48
54
  export declare function buildQuery(input: HookInput, config: KnowledgeContextConfig): string | null;
49
55
  export declare function buildKnowledgeArgs(query: string, config: KnowledgeContextConfig): string[];
50
56
  export declare function spawnKnowledgeContextPack(query: string, config: KnowledgeContextConfig): Promise<KnowledgeExecResult>;
51
57
  export declare function truncate(text: string, max: number): string;
52
- export declare function extractContextText(pack: unknown): string | null;
58
+ interface ExtractContextOptions {
59
+ maxItems?: number;
60
+ includeHistorical?: boolean;
61
+ }
62
+ export declare function extractContextText(pack: unknown, options?: ExtractContextOptions): string | null;
53
63
  export declare function formatAdditionalContext(event: KnowledgeContextEvent, context: string, config: KnowledgeContextConfig): string;
54
64
  export declare function buildHookOutput(input: HookInput | null, executor?: KnowledgeExecutor, env?: NodeJS.ProcessEnv): Promise<HookOutput>;
55
65
  export declare function run(): Promise<void>;
package/dist/hook.js CHANGED
@@ -7,6 +7,8 @@ import { spawn } from "child_process";
7
7
  var DEFAULT_TIMEOUT_MS = 5000;
8
8
  var DEFAULT_MAX_ITEMS = 3;
9
9
  var DEFAULT_MAX_TOKENS = 1200;
10
+ var DEFAULT_MIN_PROMPT_CHARS = 6;
11
+ var DEFAULT_MIN_SIGNAL_SCORE = 3;
10
12
  var DEFAULT_MAX_QUERY_CHARS = 1200;
11
13
  var DEFAULT_MAX_OUTPUT_CHARS = 8000;
12
14
  var SUPPORTED_EVENTS = new Set([
@@ -39,13 +41,18 @@ function commandFromEnv(raw) {
39
41
  return "knowledge";
40
42
  }
41
43
  function getConfig(env = process.env) {
44
+ const maxItems = boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20);
42
45
  return {
43
46
  command: commandFromEnv(env.HOOKS_KNOWLEDGE_COMMAND),
44
47
  timeoutMs: boundedNumber(env.HOOKS_KNOWLEDGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100, 1e4),
45
- maxItems: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20),
48
+ maxItems,
49
+ candidateItems: boundedNumber(env.HOOKS_KNOWLEDGE_CANDIDATE_ITEMS, Math.min(20, Math.max(maxItems, maxItems * 4)), maxItems, 20),
46
50
  maxTokens: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_TOKENS, DEFAULT_MAX_TOKENS, 100, 8000),
51
+ minPromptChars: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS, DEFAULT_MIN_PROMPT_CHARS, 0, 4000),
52
+ minSignalScore: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE, DEFAULT_MIN_SIGNAL_SCORE, 1, 10),
47
53
  maxQueryChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_QUERY_CHARS, DEFAULT_MAX_QUERY_CHARS, 80, 4000),
48
- maxOutputChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS, 500, 20000)
54
+ maxOutputChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS, 500, 20000),
55
+ requireHighSignal: env.HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL !== "0"
49
56
  };
50
57
  }
51
58
  function isKnowledgeContextEvent(value) {
@@ -71,6 +78,50 @@ function sanitizeQuery(text, maxChars) {
71
78
  const redacted = redactSecrets(normalized);
72
79
  return redacted.length > maxChars ? redacted.slice(0, maxChars).trim() : redacted;
73
80
  }
81
+ function cleanLegacyEmptyBullet(text) {
82
+ return text.replace(/^(\s*[-*])\s*:\s*/gm, "$1 ");
83
+ }
84
+ function cleanStringItem(text) {
85
+ return cleanLegacyEmptyBullet(text).replace(/^[-*]\s*/, "").trim();
86
+ }
87
+ var DOMAIN_SIGNAL_RE = /\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;
88
+ var ACTION_SIGNAL_RE = /\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;
89
+ var STRUCTURAL_SIGNAL_RE = /(`[^`]+`|(?:^|\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;
90
+ var PROJECT_CWD_SIGNAL_RE = /\/(?:open|platform|iapp)-[a-z0-9-]+(?:\/|$)|\/hasna\/(?:opensource|infra|community)\//i;
91
+ function promptSignalScore(prompt, cwd = "") {
92
+ const text = compactText(prompt);
93
+ if (!text)
94
+ return 0;
95
+ let score = 0;
96
+ if (text.length >= 80)
97
+ score += 3;
98
+ else if (text.length >= 40)
99
+ score += 1;
100
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
101
+ if (wordCount >= 12)
102
+ score += 3;
103
+ if (DOMAIN_SIGNAL_RE.test(text))
104
+ score += 3;
105
+ if (ACTION_SIGNAL_RE.test(text))
106
+ score += 2;
107
+ if (STRUCTURAL_SIGNAL_RE.test(text))
108
+ score += 3;
109
+ if (cwd && PROJECT_CWD_SIGNAL_RE.test(cwd) && text.length >= 20)
110
+ score += 2;
111
+ if (/\b(error|exception|failed|failure|regression|stack trace|traceback)\b/i.test(text))
112
+ score += 2;
113
+ return score;
114
+ }
115
+ function shouldSearchKnowledge(input, config) {
116
+ if (!config.requireHighSignal)
117
+ return true;
118
+ if (input.hook_event_name !== "UserPromptSubmit")
119
+ return true;
120
+ const prompt = stringField(input, "prompt") || stringField(input, "user_prompt");
121
+ const score = promptSignalScore(prompt, stringField(input, "cwd"));
122
+ const promptChars = compactText(prompt).length;
123
+ return promptChars >= config.minPromptChars && score >= config.minSignalScore;
124
+ }
74
125
  function buildQuery(input, config) {
75
126
  const cwd = stringField(input, "cwd");
76
127
  const event = stringField(input, "hook_event_name");
@@ -98,7 +149,7 @@ function buildKnowledgeArgs(query, config) {
98
149
  "--from",
99
150
  "search",
100
151
  "--max-items",
101
- String(config.maxItems),
152
+ String(config.candidateItems),
102
153
  "--max-tokens",
103
154
  String(config.maxTokens),
104
155
  "--json"
@@ -177,14 +228,31 @@ function compactText(text) {
177
228
  return text.replace(/\s+/g, " ").trim();
178
229
  }
179
230
  function knowledgeItemId(source) {
180
- const match = source?.match(/^knowledge:\/\/item\/([^/?#\s]+)$/);
181
- return match?.[1] ?? null;
231
+ const direct = source?.match(/^knowledge:\/\/item\/([^/?#\s]+)$/);
232
+ if (direct)
233
+ return direct[1];
234
+ const legacyPath = source?.match(/(?:^|\/)(k_[A-Za-z0-9]+_[A-Za-z0-9]+)(?:[?#].*)?$/);
235
+ return legacyPath?.[1] ?? null;
236
+ }
237
+ var NON_AUTOLOADABLE_HISTORY_RE = /\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;
238
+ var ARCHIVED_STARTUP_RE = /^Archived on \d{4}-\d{2}-\d{2}\b.*\b(startup context|startup files|CLAUDE\.md)\b/i;
239
+ function shouldSuppressKnowledgeItem(text, options) {
240
+ if (options.includeHistorical)
241
+ return false;
242
+ const compact = compactText(text);
243
+ return NON_AUTOLOADABLE_HISTORY_RE.test(compact) || ARCHIVED_STARTUP_RE.test(compact);
182
244
  }
183
- function formatPackItems(items) {
245
+ function formatPackItems(items, options = {}) {
184
246
  const lines = [];
247
+ let hasKnowledgeItems = false;
248
+ const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
185
249
  for (const [index, item] of items.entries()) {
250
+ if (lines.length >= maxItems)
251
+ break;
186
252
  if (typeof item === "string" && item.trim()) {
187
- lines.push(`- ${truncate(item.trim(), 900)}`);
253
+ const text = cleanStringItem(item);
254
+ if (text && !shouldSuppressKnowledgeItem(text, options))
255
+ lines.push(`- ${truncate(text, 900)}`);
188
256
  continue;
189
257
  }
190
258
  if (!item || typeof item !== "object")
@@ -208,25 +276,50 @@ function formatPackItems(items) {
208
276
  ]);
209
277
  if (!body && !source)
210
278
  continue;
211
- const suffixParts = [
212
- citationId && citationId !== title ? citationId : null,
213
- source && !itemId ? `source: ${truncate(source, 180)}` : null
214
- ].filter(Boolean);
215
- const suffix = suffixParts.length > 0 ? ` (${suffixParts.join("; ")})` : "";
216
- lines.push(`- ${truncate(title, 160)}${suffix}${body ? `: ${truncate(compactText(body), 520)}` : ""}`);
279
+ const itemText = [title, body, source].filter(Boolean).join(" ");
280
+ if (shouldSuppressKnowledgeItem(itemText, options))
281
+ continue;
217
282
  if (itemId) {
218
- lines.push(` open: knowledge get --id ${itemId} --json`);
283
+ hasKnowledgeItems = true;
284
+ const labelParts = [`item_id=${itemId}`];
285
+ if (citationId)
286
+ labelParts.push(`cite=${citationId}`);
287
+ const preview = body ? compactText(body) : title !== itemId ? title : "";
288
+ lines.push(`- ${labelParts.join(" ")}${preview ? `: ${truncate(preview, 520)}` : ""}`);
289
+ continue;
219
290
  }
291
+ const suffixParts = [
292
+ citationId ? `cite=${citationId}` : null,
293
+ source ? `source=${truncate(source, 180)}` : null
294
+ ].filter(Boolean);
295
+ const label = suffixParts.length > 0 ? suffixParts.join(" ") : truncate(title, 160);
296
+ lines.push(`- ${label}${body ? `: ${truncate(compactText(body), 520)}` : ""}`);
220
297
  }
221
- return lines.length > 0 ? lines.join(`
222
- `) : null;
298
+ if (lines.length === 0)
299
+ return null;
300
+ if (!hasKnowledgeItems)
301
+ return lines.join(`
302
+ `);
303
+ return [
304
+ "If a match looks relevant, read it with: knowledge get --id <item_id> --json",
305
+ "",
306
+ ...lines
307
+ ].join(`
308
+ `);
223
309
  }
224
- function extractContextText(pack) {
310
+ function shouldIncludeHistoricalItems(input) {
311
+ const prompt = `${stringField(input, "prompt")} ${stringField(input, "user_prompt")}`;
312
+ return /\b(archive|archived|historical|history|reference-only|reference only|old startup|startup files|CLAUDE\.md)\b/i.test(prompt);
313
+ }
314
+ function extractContextText(pack, options = {}) {
225
315
  if (typeof pack === "string") {
226
- return pack.trim() || null;
316
+ const text = cleanLegacyEmptyBullet(pack.trim());
317
+ if (!text || shouldSuppressKnowledgeItem(text, options))
318
+ return null;
319
+ return text;
227
320
  }
228
321
  if (Array.isArray(pack)) {
229
- return formatPackItems(pack);
322
+ return formatPackItems(pack, options);
230
323
  }
231
324
  if (!pack || typeof pack !== "object") {
232
325
  return null;
@@ -234,18 +327,18 @@ function extractContextText(pack) {
234
327
  const obj = pack;
235
328
  const direct = firstString(obj, ["additionalContext", "context", "markdown", "content", "text", "summary"]);
236
329
  if (direct)
237
- return direct;
330
+ return shouldSuppressKnowledgeItem(direct, options) ? null : direct;
238
331
  for (const key of ["pack", "contextPack", "context_pack", "data", "result"]) {
239
332
  const nested = obj[key];
240
- const text = extractContextText(nested);
333
+ const text = extractContextText(nested, options);
241
334
  if (text)
242
335
  return text;
243
336
  }
244
337
  const items = firstArray(obj, ["items", "results", "sources", "citations"]);
245
- return items ? formatPackItems(items) : null;
338
+ return items ? formatPackItems(items, options) : null;
246
339
  }
247
340
  function formatAdditionalContext(event, context, config) {
248
- const header = `[hook-knowledge-context] Deterministic Knowledge context (${event}; knowledge context pack --from search):`;
341
+ const header = `[hook-knowledge-context] Knowledge matches (${event}; top ${config.maxItems}, deterministic search):`;
249
342
  const safeContext = redactSecrets(context.trim());
250
343
  return `${header}
251
344
 
@@ -263,6 +356,9 @@ async function buildHookOutput(input, executor = spawnKnowledgeContextPack, env
263
356
  return { continue: true };
264
357
  }
265
358
  const config = getConfig(env);
359
+ if (!shouldSearchKnowledge(input, config)) {
360
+ return { continue: true };
361
+ }
266
362
  const query = buildQuery(input, config);
267
363
  if (!query) {
268
364
  return { continue: true };
@@ -273,7 +369,10 @@ async function buildHookOutput(input, executor = spawnKnowledgeContextPack, env
273
369
  return { continue: true };
274
370
  }
275
371
  const parsed = JSON.parse(result.stdout);
276
- const context = extractContextText(parsed);
372
+ const context = extractContextText(parsed, {
373
+ maxItems: config.maxItems,
374
+ includeHistorical: shouldIncludeHistoricalItems(input)
375
+ });
277
376
  if (!context) {
278
377
  return { continue: true };
279
378
  }
@@ -297,9 +396,11 @@ if (import.meta.main) {
297
396
  export {
298
397
  truncate,
299
398
  spawnKnowledgeContextPack,
399
+ shouldSearchKnowledge,
300
400
  sanitizeQuery,
301
401
  run,
302
402
  redactSecrets,
403
+ promptSignalScore,
303
404
  isKnowledgeContextEvent,
304
405
  getConfig,
305
406
  formatAdditionalContext,
package/package.json CHANGED
@@ -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",