@hasna/hook-knowledge-context 0.1.3 → 0.1.5

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,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 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.
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`
package/dist/hook.d.ts CHANGED
@@ -32,8 +32,11 @@ export interface KnowledgeContextConfig {
32
32
  timeoutMs: number;
33
33
  maxItems: number;
34
34
  maxTokens: number;
35
+ minPromptChars: number;
36
+ minSignalScore: number;
35
37
  maxQueryChars: number;
36
38
  maxOutputChars: number;
39
+ requireHighSignal: boolean;
37
40
  }
38
41
  export interface KnowledgeExecResult {
39
42
  ok: boolean;
@@ -45,6 +48,8 @@ export declare function getConfig(env?: NodeJS.ProcessEnv): KnowledgeContextConf
45
48
  export declare function isKnowledgeContextEvent(value: string | undefined): value is KnowledgeContextEvent;
46
49
  export declare function redactSecrets(text: string): string;
47
50
  export declare function sanitizeQuery(text: string, maxChars: number): string;
51
+ export declare function promptSignalScore(prompt: string, cwd?: string): number;
52
+ export declare function shouldSearchKnowledge(input: HookInput, config: KnowledgeContextConfig): boolean;
48
53
  export declare function buildQuery(input: HookInput, config: KnowledgeContextConfig): string | null;
49
54
  export declare function buildKnowledgeArgs(query: string, config: KnowledgeContextConfig): string[];
50
55
  export declare function spawnKnowledgeContextPack(query: string, config: KnowledgeContextConfig): Promise<KnowledgeExecResult>;
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([
@@ -44,8 +46,11 @@ function getConfig(env = process.env) {
44
46
  timeoutMs: boundedNumber(env.HOOKS_KNOWLEDGE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100, 1e4),
45
47
  maxItems: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_ITEMS, DEFAULT_MAX_ITEMS, 1, 20),
46
48
  maxTokens: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_TOKENS, DEFAULT_MAX_TOKENS, 100, 8000),
49
+ minPromptChars: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_PROMPT_CHARS, DEFAULT_MIN_PROMPT_CHARS, 0, 4000),
50
+ minSignalScore: boundedNumber(env.HOOKS_KNOWLEDGE_MIN_SIGNAL_SCORE, DEFAULT_MIN_SIGNAL_SCORE, 1, 10),
47
51
  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)
52
+ maxOutputChars: boundedNumber(env.HOOKS_KNOWLEDGE_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS, 500, 20000),
53
+ requireHighSignal: env.HOOKS_KNOWLEDGE_REQUIRE_HIGH_SIGNAL !== "0"
49
54
  };
50
55
  }
51
56
  function isKnowledgeContextEvent(value) {
@@ -71,6 +76,50 @@ function sanitizeQuery(text, maxChars) {
71
76
  const redacted = redactSecrets(normalized);
72
77
  return redacted.length > maxChars ? redacted.slice(0, maxChars).trim() : redacted;
73
78
  }
79
+ function cleanLegacyEmptyBullet(text) {
80
+ return text.replace(/^(\s*[-*])\s*:\s*/gm, "$1 ");
81
+ }
82
+ function cleanStringItem(text) {
83
+ return cleanLegacyEmptyBullet(text).replace(/^[-*]\s*/, "").trim();
84
+ }
85
+ 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;
86
+ 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;
87
+ 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;
88
+ var PROJECT_CWD_SIGNAL_RE = /\/(?:open|platform|iapp)-[a-z0-9-]+(?:\/|$)|\/hasna\/(?:opensource|infra|community)\//i;
89
+ function promptSignalScore(prompt, cwd = "") {
90
+ const text = compactText(prompt);
91
+ if (!text)
92
+ return 0;
93
+ let score = 0;
94
+ if (text.length >= 80)
95
+ score += 3;
96
+ else if (text.length >= 40)
97
+ score += 1;
98
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
99
+ if (wordCount >= 12)
100
+ score += 3;
101
+ if (DOMAIN_SIGNAL_RE.test(text))
102
+ score += 3;
103
+ if (ACTION_SIGNAL_RE.test(text))
104
+ score += 2;
105
+ if (STRUCTURAL_SIGNAL_RE.test(text))
106
+ score += 3;
107
+ if (cwd && PROJECT_CWD_SIGNAL_RE.test(cwd) && text.length >= 20)
108
+ score += 2;
109
+ if (/\b(error|exception|failed|failure|regression|stack trace|traceback)\b/i.test(text))
110
+ score += 2;
111
+ return score;
112
+ }
113
+ function shouldSearchKnowledge(input, config) {
114
+ if (!config.requireHighSignal)
115
+ return true;
116
+ if (input.hook_event_name !== "UserPromptSubmit")
117
+ return true;
118
+ const prompt = stringField(input, "prompt") || stringField(input, "user_prompt");
119
+ const score = promptSignalScore(prompt, stringField(input, "cwd"));
120
+ const promptChars = compactText(prompt).length;
121
+ return promptChars >= config.minPromptChars && score >= config.minSignalScore;
122
+ }
74
123
  function buildQuery(input, config) {
75
124
  const cwd = stringField(input, "cwd");
76
125
  const event = stringField(input, "hook_event_name");
@@ -177,14 +226,20 @@ function compactText(text) {
177
226
  return text.replace(/\s+/g, " ").trim();
178
227
  }
179
228
  function knowledgeItemId(source) {
180
- const match = source?.match(/^knowledge:\/\/item\/([^/?#\s]+)$/);
181
- return match?.[1] ?? null;
229
+ const direct = source?.match(/^knowledge:\/\/item\/([^/?#\s]+)$/);
230
+ if (direct)
231
+ return direct[1];
232
+ const legacyPath = source?.match(/(?:^|\/)(k_[A-Za-z0-9]+_[A-Za-z0-9]+)(?:[?#].*)?$/);
233
+ return legacyPath?.[1] ?? null;
182
234
  }
183
235
  function formatPackItems(items) {
184
236
  const lines = [];
237
+ let hasKnowledgeItems = false;
185
238
  for (const [index, item] of items.entries()) {
186
239
  if (typeof item === "string" && item.trim()) {
187
- lines.push(`- ${truncate(item.trim(), 900)}`);
240
+ const text = cleanStringItem(item);
241
+ if (text)
242
+ lines.push(`- ${truncate(text, 900)}`);
188
243
  continue;
189
244
  }
190
245
  if (!item || typeof item !== "object")
@@ -208,22 +263,37 @@ function formatPackItems(items) {
208
263
  ]);
209
264
  if (!body && !source)
210
265
  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)}` : ""}`);
217
266
  if (itemId) {
218
- lines.push(` open: knowledge get --id ${itemId} --json`);
267
+ hasKnowledgeItems = true;
268
+ const labelParts = [`item_id=${itemId}`];
269
+ if (citationId)
270
+ labelParts.push(`cite=${citationId}`);
271
+ const preview = body ? compactText(body) : title !== itemId ? title : "";
272
+ lines.push(`- ${labelParts.join(" ")}${preview ? `: ${truncate(preview, 520)}` : ""}`);
273
+ continue;
219
274
  }
275
+ const suffixParts = [
276
+ citationId ? `cite=${citationId}` : null,
277
+ source ? `source=${truncate(source, 180)}` : null
278
+ ].filter(Boolean);
279
+ const label = suffixParts.length > 0 ? suffixParts.join(" ") : truncate(title, 160);
280
+ lines.push(`- ${label}${body ? `: ${truncate(compactText(body), 520)}` : ""}`);
220
281
  }
221
- return lines.length > 0 ? lines.join(`
222
- `) : null;
282
+ if (lines.length === 0)
283
+ return null;
284
+ if (!hasKnowledgeItems)
285
+ return lines.join(`
286
+ `);
287
+ return [
288
+ "If a match looks relevant, read it with: knowledge get --id <item_id> --json",
289
+ "",
290
+ ...lines
291
+ ].join(`
292
+ `);
223
293
  }
224
294
  function extractContextText(pack) {
225
295
  if (typeof pack === "string") {
226
- return pack.trim() || null;
296
+ return cleanLegacyEmptyBullet(pack.trim()) || null;
227
297
  }
228
298
  if (Array.isArray(pack)) {
229
299
  return formatPackItems(pack);
@@ -245,7 +315,7 @@ function extractContextText(pack) {
245
315
  return items ? formatPackItems(items) : null;
246
316
  }
247
317
  function formatAdditionalContext(event, context, config) {
248
- const header = `[hook-knowledge-context] Deterministic Knowledge context (${event}; knowledge context pack --from search):`;
318
+ const header = `[hook-knowledge-context] Knowledge matches (${event}; top ${config.maxItems}, deterministic search):`;
249
319
  const safeContext = redactSecrets(context.trim());
250
320
  return `${header}
251
321
 
@@ -263,6 +333,9 @@ async function buildHookOutput(input, executor = spawnKnowledgeContextPack, env
263
333
  return { continue: true };
264
334
  }
265
335
  const config = getConfig(env);
336
+ if (!shouldSearchKnowledge(input, config)) {
337
+ return { continue: true };
338
+ }
266
339
  const query = buildQuery(input, config);
267
340
  if (!query) {
268
341
  return { continue: true };
@@ -291,15 +364,17 @@ async function buildHookOutput(input, executor = spawnKnowledgeContextPack, env
291
364
  async function run() {
292
365
  respond(await buildHookOutput(readStdinJson()));
293
366
  }
294
- if (__require.main == __require.module) {
367
+ if (import.meta.main) {
295
368
  run();
296
369
  }
297
370
  export {
298
371
  truncate,
299
372
  spawnKnowledgeContextPack,
373
+ shouldSearchKnowledge,
300
374
  sanitizeQuery,
301
375
  run,
302
376
  redactSecrets,
377
+ promptSignalScore,
303
378
  isKnowledgeContextEvent,
304
379
  getConfig,
305
380
  formatAdditionalContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hook-knowledge-context",
3
- "version": "0.1.3",
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 node && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false --noEmit false",
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
  },