@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 +26 -4
- package/dist/hook.d.ts +11 -1
- package/dist/hook.js +125 -24
- package/package.json +1 -1
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
|
|
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`
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
|
|
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]
|
|
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