autosnippet 3.3.3 → 3.3.4

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
@@ -93,13 +93,17 @@ Want to know the blast radius before refactoring a function? Static call graph a
93
93
 
94
94
  Keyword search only finds literal matches. With an LLM API Key, search upgrades to vector + BM25 hybrid retrieval — asking "how to manage memory" finds Recipes about garbage collection, semantically similar results rank first.
95
95
 
96
- ### Knowledge Graph
96
+ ### Intent-Aware Search (Prime)
97
97
 
98
- Recipes have relationships. Query impact paths, dependency depth, and related Recipes for any module once you've accumulated enough knowledge, it helps you see the structure behind it.
98
+ At the start of every conversation, the Agent auto-triggers prime to intelligently inject knowledge based on the user query and current file. IntentExtractor extracts tech terms, infers language and module, performs cross-language (EN↔CJK) synonym expansion; PrimeSearchPipeline executes multi-query parallel search (raw query + term query + file context + focused synonyms), returning precise results after 3-layer quality filtering. Supports long natural-language sentences, short exact matches, and mixed-language queries.
99
+
100
+ ### Recipe Source Evidence (sourceRefs)
99
101
 
100
- ### TaskGraph Orchestration
102
+ Recipes carry the project file paths analyzed during creation as evidence. The 📍 sourceRefs in search results point to real project files — the Agent can trust and reference them without self-verification. Path validity is monitored automatically, with git rename auto-repair.
101
103
 
102
- Break a large task into steps, declare dependencies between them, and each step auto-injects relevant Recipes as context. Team decisions (rationale, confidence) persist alongside tasks — they don't vanish with the conversation.
104
+ ### Knowledge Graph
105
+
106
+ Recipes have relationships. Query impact paths, dependency depth, and related Recipes for any module — once you've accumulated enough knowledge, it helps you see the structure behind it.
103
107
 
104
108
  ### Self-Cycling Signal Mechanism
105
109
 
@@ -44,6 +44,10 @@ const _taskRules = {
44
44
  * Unified entry point
45
45
  */
46
46
  export async function taskHandler(ctx, args) {
47
+ // Normalize taskId → id (schema accepts both for convenience)
48
+ if (!args.id && typeof args.taskId === 'string') {
49
+ args.id = args.taskId;
50
+ }
47
51
  let result;
48
52
  switch (args.operation) {
49
53
  case 'prime':
@@ -88,11 +92,20 @@ async function _prime(ctx, args) {
88
92
  if (pipeline && extracted.queries[0]?.trim()) {
89
93
  try {
90
94
  searchResult = await pipeline.search(extracted);
95
+ if (!searchResult) {
96
+ process.stderr.write('[MCP/Task] prime: pipeline.search returned null (all filtered)\n');
97
+ }
91
98
  }
92
- catch {
93
- // search failure is non-fatal
99
+ catch (err) {
100
+ process.stderr.write(`[MCP/Task] prime search error: ${err instanceof Error ? err.stack || err.message : String(err)}\n`);
94
101
  }
95
102
  }
103
+ else if (!pipeline) {
104
+ process.stderr.write('[MCP/Task] prime: pipeline is null, skipping search\n');
105
+ }
106
+ else {
107
+ process.stderr.write(`[MCP/Task] prime: queries empty, skipping search. queries=${JSON.stringify(extracted.queries)}\n`);
108
+ }
96
109
  // ─── Lifecycle: initialize IntentState ───
97
110
  const freshIntent = createIdleIntent();
98
111
  freshIntent.phase = 'active';
@@ -175,14 +188,16 @@ async function _create(ctx, args) {
175
188
  }
176
189
  // ═══ close ══════════════════════════════════════════════
177
190
  async function _close(ctx, args) {
178
- if (!args.id) {
191
+ const intent = ctx.session?.intent;
192
+ // Resolve id: explicit arg > session intent > fail
193
+ const id = args.id || (intent?.taskId ?? '');
194
+ if (!id) {
179
195
  return envelope({
180
196
  success: false,
181
- message: 'id is required',
197
+ message: 'id is required (pass id or ensure a task was created in this session)',
182
198
  meta: { tool: 'autosnippet_task' },
183
199
  });
184
200
  }
185
- const intent = ctx.session?.intent;
186
201
  const reason = args.reason || 'Completed';
187
202
  // Persist intent chain via SignalBus
188
203
  if (intent && intent.phase === 'active') {
@@ -192,13 +207,13 @@ async function _close(ctx, args) {
192
207
  if (ctx.session) {
193
208
  ctx.session.intent = createIdleIntent();
194
209
  }
195
- const lines = [`✅ Closed: ${args.id} — ${reason}`];
210
+ const lines = [`✅ Closed: ${id} — ${reason}`];
196
211
  lines.push('');
197
212
  lines.push('⚠️ REQUIRED: You MUST call autosnippet_guard (no args) NOW to review changed files for compliance violations.');
198
213
  return envelope({
199
214
  success: true,
200
215
  data: {
201
- closed: { id: args.id, reason, closedAt: Date.now() },
216
+ closed: { id, reason, closedAt: Date.now() },
202
217
  nextAction: {
203
218
  tool: 'autosnippet_guard',
204
219
  args: {},
@@ -212,14 +227,16 @@ async function _close(ctx, args) {
212
227
  }
213
228
  // ═══ fail ═══════════════════════════════════════════════
214
229
  async function _fail(ctx, args) {
215
- if (!args.id) {
230
+ const intent = ctx.session?.intent;
231
+ // Resolve id: explicit arg > session intent > fail
232
+ const id = args.id || (intent?.taskId ?? '');
233
+ if (!id) {
216
234
  return envelope({
217
235
  success: false,
218
- message: 'id is required',
236
+ message: 'id is required (pass id or ensure a task was created in this session)',
219
237
  meta: { tool: 'autosnippet_task' },
220
238
  });
221
239
  }
222
- const intent = ctx.session?.intent;
223
240
  const reason = args.reason || 'Agent execution failed';
224
241
  // Persist intent chain via SignalBus
225
242
  if (intent && intent.phase === 'active') {
@@ -232,9 +249,9 @@ async function _fail(ctx, args) {
232
249
  return envelope({
233
250
  success: true,
234
251
  data: {
235
- failed: { id: args.id, reason, failedAt: Date.now() },
252
+ failed: { id, reason, failedAt: Date.now() },
236
253
  },
237
- message: `❌ Failed: ${args.id} — ${reason}`,
254
+ message: `❌ Failed: ${id} — ${reason}`,
238
255
  meta: { tool: 'autosnippet_task' },
239
256
  });
240
257
  }
@@ -323,9 +340,14 @@ function _computeDriftScore(intent) {
323
340
  }
324
341
  function _getPipeline(container) {
325
342
  try {
326
- return container.get('primeSearchPipeline');
343
+ const p = container.get('primeSearchPipeline');
344
+ if (!p) {
345
+ process.stderr.write('[MCP/Task] _getPipeline: container returned null/undefined\n');
346
+ }
347
+ return p;
327
348
  }
328
- catch {
349
+ catch (err) {
350
+ process.stderr.write(`[MCP/Task] _getPipeline failed: ${err instanceof Error ? err.message : String(err)}\n`);
329
351
  return null;
330
352
  }
331
353
  }
@@ -38,8 +38,10 @@ export interface TechTermOptions {
38
38
  export declare function extract(userQuery: string, activeFile?: string, language?: string, termOpts?: TechTermOptions): ExtractedIntent;
39
39
  /**
40
40
  * Build multi-query set from user query + active file.
41
- * Q1: raw query, Q2: extracted tech terms, Q3: file context.
41
+ * Q1: raw query, Q2: extracted tech terms, Q3: file context, Q4: synonym focus.
42
42
  * Q1 is enriched with cross-language synonyms to bridge EN↔CJK matching.
43
+ * Q4 (long queries only): synonym expansion as a separate focused query
44
+ * to prevent BM25 dilution in verbose natural language inputs.
43
45
  */
44
46
  export declare function buildQueries(userQuery: string, activeFile?: string, termOpts?: TechTermOptions): string[];
45
47
  /**
@@ -70,6 +70,13 @@ const SYNONYM_GROUPS = [
70
70
  ['sync', 'synchronous', '同步'],
71
71
  ['thread', 'threading', '线程'],
72
72
  ['concur', 'concurrency', '并发'],
73
+ // Memory management
74
+ ['memory', '内存'],
75
+ ['leak', 'leakage', '泄漏'],
76
+ ['weak', '弱引用'],
77
+ ['retain', '持有', '保留'],
78
+ ['release', '释放'],
79
+ ['reference', '引用'],
73
80
  // Common concepts
74
81
  ['network', '网络'],
75
82
  ['cache', 'caching', '缓存'],
@@ -120,8 +127,10 @@ export function extract(userQuery, activeFile, language, termOpts) {
120
127
  }
121
128
  /**
122
129
  * Build multi-query set from user query + active file.
123
- * Q1: raw query, Q2: extracted tech terms, Q3: file context.
130
+ * Q1: raw query, Q2: extracted tech terms, Q3: file context, Q4: synonym focus.
124
131
  * Q1 is enriched with cross-language synonyms to bridge EN↔CJK matching.
132
+ * Q4 (long queries only): synonym expansion as a separate focused query
133
+ * to prevent BM25 dilution in verbose natural language inputs.
125
134
  */
126
135
  export function buildQueries(userQuery, activeFile, termOpts) {
127
136
  // Enrich raw query with cross-language synonyms
@@ -132,6 +141,14 @@ export function buildQueries(userQuery, activeFile, termOpts) {
132
141
  if (terms.length > 0) {
133
142
  queries.push(terms.join(' '));
134
143
  }
144
+ // Q4: For long queries (> 50 chars), add cross-language synonyms as a
145
+ // separate focused query. In long sentences, synonym terms appended to Q1
146
+ // get diluted by common words ("ViewController", "ViewModel"), causing
147
+ // BM25 to miss the user's actual intent. A short focused query matches
148
+ // domain-specific terms (e.g. "singleton 单例 inject 注入") directly.
149
+ if (synonyms && userQuery.length > 50) {
150
+ queries.push(synonyms);
151
+ }
135
152
  if (activeFile) {
136
153
  const ctx = inferFileContext(activeFile);
137
154
  if (ctx) {
@@ -203,7 +220,7 @@ export function inferLanguage(filePath) {
203
220
  */
204
221
  export function classifyScenario(userQuery) {
205
222
  const q = userQuery.toLowerCase();
206
- if (/帮我[加写做实现创建]|implement|add|create|新[增加建]/.test(q)) {
223
+ if (/帮我[加写做实现创建]|implement|add|create|新[增加建]|添加|修改|删除|实现|开发|编写|创建|初始化/.test(q)) {
207
224
  return 'generate';
208
225
  }
209
226
  if (/检查|review|lint|合规|违规|guard|规[则范]/.test(q)) {
@@ -220,23 +237,26 @@ export function classifyScenario(userQuery) {
220
237
  * Tokenizes query, looks up each token in the synonym table,
221
238
  * returns a query string of synonym expansions for cross-language matching.
222
239
  *
223
- * Strategy: return only cross-script synonyms (EN→CJK or CJK→EN).
224
- * This keeps the expansion focused the original script tokens are already in Q1.
240
+ * Strategy: per-token cross-script expansion. Each token's script is checked
241
+ * individually, and only synonyms in the OPPOSITE script are added.
242
+ * This correctly handles mixed EN/CJK queries (e.g. "在 module 里用 singleton")
243
+ * where both EN→CJK and CJK→EN expansions are needed.
225
244
  */
226
245
  function expandWithSynonyms(query) {
227
246
  const tokens = tokenize(query);
228
247
  const crossScriptTerms = new Set();
229
- // Detect query script: does it contain CJK?
230
- const hasCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/.test(query);
248
+ const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/;
231
249
  for (const token of tokens) {
232
250
  const synonyms = SYNONYM_LOOKUP.get(token.toLowerCase());
233
251
  if (!synonyms) {
234
252
  continue;
235
253
  }
254
+ // Determine THIS token's script, not the whole query's
255
+ const tokenIsCJK = CJK_RE.test(token);
236
256
  for (const syn of synonyms) {
237
- const synIsCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/.test(syn);
238
- // Cross-script: EN query → add CJK synonyms; CJK query → add EN synonyms
239
- if (hasCJK !== synIsCJK) {
257
+ const synIsCJK = CJK_RE.test(syn);
258
+ // Cross-script: EN token → add CJK synonyms; CJK token → add EN synonyms
259
+ if (tokenIsCJK !== synIsCJK) {
240
260
  crossScriptTerms.add(syn);
241
261
  }
242
262
  }
@@ -244,7 +264,7 @@ function expandWithSynonyms(query) {
244
264
  if (crossScriptTerms.size === 0) {
245
265
  return null;
246
266
  }
247
- return [...crossScriptTerms].slice(0, 12).join(' ');
267
+ return [...crossScriptTerms].slice(0, 16).join(' ');
248
268
  }
249
269
  function buildPrefixPattern(prefixes) {
250
270
  if (prefixes.length === 0) {
@@ -8,7 +8,12 @@
8
8
  */
9
9
  import { slimSearchResult } from '#service/search/SearchTypes.js';
10
10
  // ── Constants ───────────────────────────────────────
11
- const RELEVANCE_THRESHOLD = 0.01;
11
+ /** Absolute minimum score — items below this are definitely noise */
12
+ const MIN_SCORE_THRESHOLD = 0.3;
13
+ /** Relative threshold — items scoring below this fraction of the best result are dropped */
14
+ const RELATIVE_SCORE_RATIO = 0.15;
15
+ /** Gap ratio — if score drops by more than this factor from the previous item, truncate */
16
+ const GAP_DROP_RATIO = 0.25;
12
17
  // ── PrimeSearchPipeline ─────────────────────────────
13
18
  export class PrimeSearchPipeline {
14
19
  #search;
@@ -31,8 +36,8 @@ export class PrimeSearchPipeline {
31
36
  };
32
37
  // Multi-query parallel search (auto mode + keyword mode for cross-language)
33
38
  const allResults = await this.#multiQuerySearch(intent.queries, intent.keywordQueries ?? [], context);
34
- // Threshold filter
35
- const filtered = allResults.filter((r) => (r.score ?? 0) >= RELEVANCE_THRESHOLD);
39
+ // Quality filter: absolute threshold + relative-to-best + score gap detection
40
+ const filtered = this.#qualityFilter(allResults);
36
41
  if (filtered.length === 0) {
37
42
  return null;
38
43
  }
@@ -62,14 +67,46 @@ export class PrimeSearchPipeline {
62
67
  }
63
68
  // ── Private ───────────────────────────────────────
64
69
  /**
65
- * Multi-query parallel search with Reciprocal Rank Fusion (RRF).
66
- * Auto-mode queries use CoarseRanker; keyword queries use raw FWS scores.
67
- * Results are fused by rank position, not absolute scores — robust across heterogeneous scorers.
70
+ * Quality filter: absolute threshold + relative-to-best + score gap detection.
71
+ * Expects items sorted by score descending.
72
+ */
73
+ #qualityFilter(items) {
74
+ if (items.length === 0) {
75
+ return [];
76
+ }
77
+ const maxScore = items[0]?.score ?? 0;
78
+ const effectiveThreshold = Math.max(MIN_SCORE_THRESHOLD, maxScore * RELATIVE_SCORE_RATIO);
79
+ const result = [];
80
+ let prevScore = maxScore;
81
+ for (const item of items) {
82
+ const score = item.score;
83
+ if (score < effectiveThreshold) {
84
+ break;
85
+ }
86
+ // Gap detection: if score drops sharply from previous item, stop
87
+ if (result.length > 0 && score < prevScore * GAP_DROP_RATIO) {
88
+ break;
89
+ }
90
+ result.push(item);
91
+ prevScore = score;
92
+ }
93
+ return result;
94
+ }
95
+ /**
96
+ * Multi-query parallel search with optional Reciprocal Rank Fusion (RRF).
97
+ *
98
+ * Single-query: preserves original search engine scores (BM25/CoarseRanker).
99
+ * Multi-query: uses RRF to fuse results, but weights by original score to
100
+ * retain magnitude information.
68
101
  */
69
102
  async #multiQuerySearch(autoQueries, keywordQueries, context) {
70
- // Auto-mode searches (full CoarseRanker pipeline)
103
+ // Auto-mode searches (BM25 without CoarseRanker ranking)
104
+ // Using rank: false preserves raw BM25/FWS score magnitude,
105
+ // which the quality filter needs for effective discrimination.
106
+ // CoarseRanker's max-normalization + freshness/popularity signals
107
+ // would cluster scores around 0.35–0.41, defeating the filter.
71
108
  const autoPromises = autoQueries.map((q) => this.#search
72
- .search(q, { mode: 'auto', limit: 8, rank: true, context })
109
+ .search(q, { mode: 'auto', limit: 8, rank: false, context })
73
110
  .catch(() => ({ items: [] })));
74
111
  // Keyword-mode searches (raw FWS scores — for cross-language synonym matching)
75
112
  const kwPromises = keywordQueries.map((q) => this.#search
@@ -80,15 +117,25 @@ export class PrimeSearchPipeline {
80
117
  Promise.all(kwPromises),
81
118
  ]);
82
119
  const allResponses = [...autoResponses, ...kwResponses];
83
- // Reciprocal Rank Fusion: RRF(d) = Σ 1/(k + rank)
120
+ // Single-query shortcut: preserve original scores from search engine.
121
+ // RRF is pointless with one response — it just converts rank to score,
122
+ // discarding the magnitude information from BM25/CoarseRanker.
123
+ if (allResponses.length === 1) {
124
+ const items = (allResponses[0]?.items || []);
125
+ return items.map(slimSearchResult).sort((a, b) => b.score - a.score);
126
+ }
127
+ // Multi-query: Weighted RRF — RRF(d) = Σ origScore / (k + rank)
128
+ // Retains original score magnitude while still boosting cross-query overlap.
84
129
  const RRF_K = 60;
85
130
  const rrfScores = new Map();
86
131
  const itemById = new Map();
87
132
  for (const resp of allResponses) {
88
133
  const items = (resp.items || []);
89
134
  for (let rank = 0; rank < items.length; rank++) {
90
- const item = slimSearchResult(items[rank]);
91
- rrfScores.set(item.id, (rrfScores.get(item.id) ?? 0) + 1 / (RRF_K + rank));
135
+ const raw = items[rank];
136
+ const origScore = Math.max(raw.score || 0, 0.01);
137
+ const item = slimSearchResult(raw);
138
+ rrfScores.set(item.id, (rrfScores.get(item.id) ?? 0) + origScore / (RRF_K + rank));
92
139
  // Keep the richest metadata version
93
140
  if (!itemById.has(item.id)) {
94
141
  itemById.set(item.id, item);
@@ -96,10 +143,18 @@ export class PrimeSearchPipeline {
96
143
  }
97
144
  }
98
145
  // Assign fused scores and sort
146
+ // Rescale: RRF_K division crushes scores to ~0.003–0.02 range,
147
+ // which falls below qualityFilter's MIN_SCORE_THRESHOLD (0.1).
148
+ // Multiply by RRF_K to restore original score magnitude.
149
+ // Effective formula: Σ origScore / (1 + rank/K), preserving magnitude
150
+ // while still giving a gentle rank-based discount.
99
151
  const results = [];
100
152
  for (const [id, rrfScore] of rrfScores) {
101
153
  const item = itemById.get(id);
102
- item.score = rrfScore;
154
+ if (!item) {
155
+ continue;
156
+ }
157
+ item.score = Math.round(rrfScore * RRF_K * 1000) / 1000;
103
158
  results.push(item);
104
159
  }
105
160
  return results.sort((a, b) => b.score - a.score);
@@ -247,6 +247,7 @@ export declare const TaskInput: z.ZodObject<{
247
247
  title: z.ZodOptional<z.ZodString>;
248
248
  description: z.ZodOptional<z.ZodString>;
249
249
  id: z.ZodOptional<z.ZodString>;
250
+ taskId: z.ZodOptional<z.ZodString>;
250
251
  reason: z.ZodOptional<z.ZodString>;
251
252
  rationale: z.ZodOptional<z.ZodString>;
252
253
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -235,7 +235,11 @@ export const TaskInput = z.object({
235
235
  .describe('prime=加载知识上下文 | create=创建任务锚点 | close=完成+Guard | fail=放弃 | record_decision=记录用户偏好'),
236
236
  title: z.string().optional().describe('Task or decision title (create / record_decision)'),
237
237
  description: z.string().optional().describe('Decision description (record_decision)'),
238
- id: z.string().optional().describe('Task ID (close / fail)'),
238
+ id: z
239
+ .string()
240
+ .optional()
241
+ .describe('Task ID (close / fail). Optional if a task was created in the current session.'),
242
+ taskId: z.string().optional().describe('Alias for id (accepted for convenience)'),
239
243
  reason: z.string().optional().describe('Close reason or fail reason'),
240
244
  rationale: z.string().optional().describe('Decision rationale (record_decision)'),
241
245
  tags: z.array(z.string()).optional().describe('Decision tags (record_decision)'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autosnippet",
3
- "version": "3.3.3",
3
+ "version": "3.3.4",
4
4
  "description": "Extract code patterns into a knowledge base for AI coding assistants",
5
5
  "type": "module",
6
6
  "main": "dist/lib/bootstrap.js",
@@ -13,11 +13,13 @@ Users speak naturally; you translate to task operations. Never tell users to cal
13
13
 
14
14
  | User Says | You Run |
15
15
  |---|---|
16
- | "fix bug" / "implement" | `create` → code → `close` |
17
- | "continue" | resume in-progress → `close` |
16
+ | "fix bug" / "implement" | `create` → code → `close` → `autosnippet_guard()` |
17
+ | "continue" | resume in-progress → `close` → `autosnippet_guard()` |
18
18
  | "pause" / "abandon" | `fail(id, reason)` |
19
19
  | "agreed" | `record_decision(...)` |
20
20
 
21
+ 7. **After close** — MUST call `autosnippet_guard()` (no args) for compliance review before moving on. Never skip.
22
+
21
23
  ## Knowledge Rules
22
24
 
23
25
  - **Do NOT modify** `AutoSnippet/recipes/` or `.autosnippet/` directly.