@hasna/hook-knowledge-context 0.1.4 → 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 +22 -3
- package/dist/hook.d.ts +5 -0
- package/dist/hook.js +90 -15
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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]
|
|
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 };
|
|
@@ -297,9 +370,11 @@ if (import.meta.main) {
|
|
|
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