@aiwayds/dsh-topics-memory 0.15.0 → 0.16.0

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/lib/commands.d.ts CHANGED
@@ -26,3 +26,13 @@ export type ManualConsolidate = (invocation: CommandInvocation) => Promise<Conso
26
26
  export declare const HELP: string;
27
27
  export declare function buildTopicsCommand(service: TopicsService, mutate: MutateFn, resolveAsk?: AskServiceResolver, resolveLlm?: LlmDirectoryResolver, distillNow?: ManualDistill, consolidateNow?: ManualConsolidate): CommandDefinition;
28
28
  export declare function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold: number): string | undefined;
29
+ /**
30
+ * Render the near-miss histogram as a two-line horizontal sparkline: tick
31
+ * labels over a log-scaled (count+1) strip, so a live session's 200:1 count
32
+ * range stays legible. Zero-count buckets inside the observed range draw as
33
+ * '·' to keep the score axis honest. Returns [] for empty or unparsable input.
34
+ */
35
+ export declare function renderNearMissSparkline(buckets: {
36
+ bucket: string;
37
+ count: number;
38
+ }[]): string[];
package/lib/commands.js CHANGED
@@ -267,9 +267,7 @@ async function renderStats(service) {
267
267
  lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`);
268
268
  }
269
269
  if (stats.nearMissHistogram.length > 0) {
270
- lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |');
271
- for (const b of stats.nearMissHistogram)
272
- lines.push(`| ${cell(b.bucket)} | ${b.count} |`);
270
+ lines.push('', `Near-miss 分布(低于阈值 ${service.cfg.matchThreshold.toFixed(2)} 或被结构门挡下,对数刻度):`, '', '```', ...renderNearMissSparkline(stats.nearMissHistogram), '```');
273
271
  const hint = tuningHint(stats, service.cfg.matchThreshold);
274
272
  if (hint !== undefined)
275
273
  lines.push('', `💡 ${hint}`);
@@ -286,6 +284,59 @@ export function tuningHint(stats, threshold) {
286
284
  }
287
285
  return undefined;
288
286
  }
287
+ const SPARK_LEVELS = '▁▂▃▄▅▆▇█';
288
+ const SPARK_EMPTY = '·';
289
+ /** Columns per histogram bucket; 2 keeps a 25-bucket range at 50 cols so the
290
+ * TUI renders both lines without wrapping. */
291
+ const SPARK_COLS_PER_BUCKET = 2;
292
+ /** Axis tick every 0.30 score units. */
293
+ const SPARK_TICK_STEP = 0.3;
294
+ /** Bucket width of the histogram built by nmBucket() in ilog.ts. */
295
+ const SPARK_BUCKET_WIDTH = 0.05;
296
+ /**
297
+ * Render the near-miss histogram as a two-line horizontal sparkline: tick
298
+ * labels over a log-scaled (count+1) strip, so a live session's 200:1 count
299
+ * range stays legible. Zero-count buckets inside the observed range draw as
300
+ * '·' to keep the score axis honest. Returns [] for empty or unparsable input.
301
+ */
302
+ export function renderNearMissSparkline(buckets) {
303
+ const starts = buckets.map((b) => Number(b.bucket.split('–')[0]));
304
+ if (buckets.length === 0 || starts.some((s) => Number.isNaN(s)))
305
+ return [];
306
+ const first = Math.min(...starts);
307
+ const last = Math.max(...starts);
308
+ const n = Math.round((last - first) / SPARK_BUCKET_WIDTH) + 1;
309
+ const counts = new Array(n).fill(0);
310
+ for (let i = 0; i < buckets.length; i++) {
311
+ counts[Math.round((starts[i] - first) / SPARK_BUCKET_WIDTH)] += buckets[i].count;
312
+ }
313
+ const max = Math.max(...counts);
314
+ const denom = Math.log10(max + 1);
315
+ const strip = counts
316
+ .map((c) => (c === 0 || denom === 0 ? SPARK_EMPTY : SPARK_LEVELS[Math.round((Math.log10(c + 1) / denom) * (SPARK_LEVELS.length - 1))]))
317
+ .map((ch) => ch.repeat(SPARK_COLS_PER_BUCKET))
318
+ .join('');
319
+ const width = n * SPARK_COLS_PER_BUCKET;
320
+ const line = new Array(width).fill(' ');
321
+ const used = new Array(width).fill(false);
322
+ const put = (at, label) => {
323
+ if (at < 0 || at + label.length > width)
324
+ return;
325
+ if (used.slice(at, at + label.length).some(Boolean))
326
+ return;
327
+ for (let k = 0; k < label.length; k++) {
328
+ line[at + k] = label[k];
329
+ used[at + k] = true;
330
+ }
331
+ };
332
+ for (let i = 0;; i += Math.round(SPARK_TICK_STEP / SPARK_BUCKET_WIDTH)) {
333
+ if (i > 0 && i >= n - 2)
334
+ break;
335
+ put(i * SPARK_COLS_PER_BUCKET, (first + i * SPARK_BUCKET_WIDTH).toFixed(2));
336
+ }
337
+ put(width - 4, (first + n * SPARK_BUCKET_WIDTH).toFixed(2));
338
+ return [line.join('').replace(/ +$/, ''), strip];
339
+ }
289
340
  /** /topics list caps the table at this many newest topics. */
290
341
  const LIST_LIMIT = 100;
291
342
  async function renderList(service) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/commands.ts CHANGED
@@ -306,8 +306,14 @@ async function renderStats(service: TopicsService): Promise<string> {
306
306
  for (const t of stats.topTopics.slice(0, 5)) lines.push(`| \`${cell(t.slug)}\` | ${t.count} |`)
307
307
  }
308
308
  if (stats.nearMissHistogram.length > 0) {
309
- lines.push('', 'Near-miss 分布(低于阈值或被结构门挡下):', '', '| 分数段 | 次数 |', '| --- | --- |')
310
- for (const b of stats.nearMissHistogram) lines.push(`| ${cell(b.bucket)} | ${b.count} |`)
309
+ lines.push(
310
+ '',
311
+ `Near-miss 分布(低于阈值 ${service.cfg.matchThreshold.toFixed(2)} 或被结构门挡下,对数刻度):`,
312
+ '',
313
+ '```',
314
+ ...renderNearMissSparkline(stats.nearMissHistogram),
315
+ '```',
316
+ )
311
317
  const hint = tuningHint(stats, service.cfg.matchThreshold)
312
318
  if (hint !== undefined) lines.push('', `💡 ${hint}`)
313
319
  }
@@ -325,6 +331,57 @@ export function tuningHint(stats: ReturnType<typeof aggregateStats>, threshold:
325
331
  return undefined
326
332
  }
327
333
 
334
+ const SPARK_LEVELS = '▁▂▃▄▅▆▇█'
335
+ const SPARK_EMPTY = '·'
336
+ /** Columns per histogram bucket; 2 keeps a 25-bucket range at 50 cols so the
337
+ * TUI renders both lines without wrapping. */
338
+ const SPARK_COLS_PER_BUCKET = 2
339
+ /** Axis tick every 0.30 score units. */
340
+ const SPARK_TICK_STEP = 0.3
341
+ /** Bucket width of the histogram built by nmBucket() in ilog.ts. */
342
+ const SPARK_BUCKET_WIDTH = 0.05
343
+
344
+ /**
345
+ * Render the near-miss histogram as a two-line horizontal sparkline: tick
346
+ * labels over a log-scaled (count+1) strip, so a live session's 200:1 count
347
+ * range stays legible. Zero-count buckets inside the observed range draw as
348
+ * '·' to keep the score axis honest. Returns [] for empty or unparsable input.
349
+ */
350
+ export function renderNearMissSparkline(buckets: { bucket: string; count: number }[]): string[] {
351
+ const starts = buckets.map((b) => Number(b.bucket.split('–')[0]))
352
+ if (buckets.length === 0 || starts.some((s) => Number.isNaN(s))) return []
353
+ const first = Math.min(...starts)
354
+ const last = Math.max(...starts)
355
+ const n = Math.round((last - first) / SPARK_BUCKET_WIDTH) + 1
356
+ const counts = new Array<number>(n).fill(0)
357
+ for (let i = 0; i < buckets.length; i++) {
358
+ counts[Math.round((starts[i] - first) / SPARK_BUCKET_WIDTH)] += buckets[i].count
359
+ }
360
+ const max = Math.max(...counts)
361
+ const denom = Math.log10(max + 1)
362
+ const strip = counts
363
+ .map((c) => (c === 0 || denom === 0 ? SPARK_EMPTY : SPARK_LEVELS[Math.round((Math.log10(c + 1) / denom) * (SPARK_LEVELS.length - 1))]))
364
+ .map((ch) => ch.repeat(SPARK_COLS_PER_BUCKET))
365
+ .join('')
366
+ const width = n * SPARK_COLS_PER_BUCKET
367
+ const line = new Array<string>(width).fill(' ')
368
+ const used = new Array<boolean>(width).fill(false)
369
+ const put = (at: number, label: string) => {
370
+ if (at < 0 || at + label.length > width) return
371
+ if (used.slice(at, at + label.length).some(Boolean)) return
372
+ for (let k = 0; k < label.length; k++) {
373
+ line[at + k] = label[k]
374
+ used[at + k] = true
375
+ }
376
+ }
377
+ for (let i = 0; ; i += Math.round(SPARK_TICK_STEP / SPARK_BUCKET_WIDTH)) {
378
+ if (i > 0 && i >= n - 2) break
379
+ put(i * SPARK_COLS_PER_BUCKET, (first + i * SPARK_BUCKET_WIDTH).toFixed(2))
380
+ }
381
+ put(width - 4, (first + n * SPARK_BUCKET_WIDTH).toFixed(2))
382
+ return [line.join('').replace(/ +$/, ''), strip]
383
+ }
384
+
328
385
  /** /topics list caps the table at this many newest topics. */
329
386
  const LIST_LIMIT = 100
330
387