@goodandready/dsh-context-lens 0.1.23 → 0.1.25

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/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to `@goodandready/dsh-context-lens` will be documented in th
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.25] - 2026-09-25
9
+
10
+ ### Fixed
11
+ - **SettingsForms modern API adapter (#66)**: removed legacy call to nonexistent `sctx.settings.register` preventing crash on DSH 0.1.7-rc.1 core. Implemented `SettingsForms` adapter with `configure({ auto: false })`, live reading via `describe()` / `get()`, dynamic updates via `settings/document-updated`, and full cleanup on effect disposal.
12
+
13
+ ## [0.1.24] - 2026-09-24
14
+
15
+ ### Performance
16
+ - **Optimized log auto-compression**: replaced string array allocation `split('\n').length > 100` with zero-allocation early-exit `hasAtLeastLines(text, 101)` scan, delivering ~55x faster checks on large log outputs (#85).
17
+ - **Fast-path ANSI stripping**: added immediate fast-path return in `cleanAnsi` for strings without `\u001b` and cached cleaned lines during log compression to eliminate duplicate regex execution (#88).
18
+
19
+ ### Security
20
+ - **Hardened `/dsh-context-lens/status` route**: enforced HTTP `GET` method (`405 Method Not Allowed` on other methods) and verified trusted source via `isTrustedRequest` (`403 Forbidden` on untrusted origins), closing cross-site telemetry leak (#87).
21
+
22
+ ### Fixed
23
+ - **Prevented session memory leak**: `clearFocus(key)` now explicitly deletes the session key from `focusBySession`, and enforced a maximum capacity limit (`MAX_FOCUS_SESSIONS = 500`) with LRU eviction (#86).
24
+ - **Sanitized focus paths and fixed matching**: filtered out empty and whitespace strings in `context_lens_focus`, and implemented strict path segment matching in `isPathFocused()` to prevent false compression bypass (#89).
25
+
8
26
  ## 0.1.23
9
27
 
10
28
  ### Fixed
@@ -1,7 +1,23 @@
1
+ /**
2
+ * Fast check whether a text blob contains at least minLines lines
3
+ * without allocating substring arrays in the V8 heap.
4
+ */
5
+ export function hasAtLeastLines(text, minLines) {
6
+ if (!text || typeof text !== 'string') return false;
7
+ if (minLines <= 1) return text.length > 0;
8
+ let count = 1;
9
+ let pos = -1;
10
+ while ((pos = text.indexOf('\n', pos + 1)) !== -1) {
11
+ count++;
12
+ if (count >= minLines) return true;
13
+ }
14
+ return false;
15
+ }
16
+
1
17
  /** Decide whether auto-compress should trigger for a log blob. */
2
18
  export function shouldAutoCompress(text, threshold) {
3
19
  if (!threshold || threshold <= 0) return false;
4
- return (text || '').length > threshold || (text || '').split('\n').length > 100;
20
+ return (text || '').length > threshold || hasAtLeastLines(text, 101);
5
21
  }
6
22
 
7
- export default { shouldAutoCompress };
23
+ export default { shouldAutoCompress, hasAtLeastLines };
@@ -7,13 +7,12 @@ const NOISE_RE = /^\s*(npm (notice|warn|info)|Browserslist|cached|Downloading|Do
7
7
  const STACK_RE = /^\s*(at\s+|File ".*", line \d+|#\d+\s+0x|goroutine \d+ \[|thread '.*' panicked|note: run with)/;
8
8
 
9
9
  function cleanAnsi(str) {
10
- return (str || '').replace(ANSI_RE, '');
10
+ if (!str || !str.includes('\u001b')) return str || '';
11
+ return str.replace(ANSI_RE, '');
11
12
  }
12
13
 
13
- function keepLine(line) {
14
- const clean = cleanAnsi(line);
15
- if (KEEP_RE.test(clean) || STACK_RE.test(clean)) return true;
16
- return false;
14
+ function keepLine(clean) {
15
+ return KEEP_RE.test(clean) || STACK_RE.test(clean);
17
16
  }
18
17
 
19
18
  export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
@@ -24,7 +23,7 @@ export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
24
23
  // raw: drop only obvious noise and non-failure passes, keep rest
25
24
  const filtered = lines.filter((l) => {
26
25
  const clean = cleanAnsi(l);
27
- if (KEEP_RE.test(clean) || STACK_RE.test(clean)) return true;
26
+ if (keepLine(clean)) return true;
28
27
  if (NOISE_RE.test(clean)) return false;
29
28
  return true;
30
29
  });
@@ -36,9 +35,12 @@ export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
36
35
  }
37
36
 
38
37
  const keep = new Array(lines.length).fill(false);
38
+ const cleanLines = new Array(lines.length);
39
39
  const context = mode === 'aggressive' ? 1 : 2;
40
40
  for (let i = 0; i < lines.length; i++) {
41
- if (keepLine(lines[i])) {
41
+ const clean = cleanAnsi(lines[i]);
42
+ cleanLines[i] = clean;
43
+ if (keepLine(clean)) {
42
44
  const s = Math.max(0, i - context);
43
45
  const e = Math.min(lines.length - 1, i + context);
44
46
  for (let j = s; j <= e; j++) keep[j] = true;
@@ -56,9 +58,9 @@ export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
56
58
  let out = [];
57
59
  for (let i = 0; i < lines.length; i++) if (keep[i]) {
58
60
  const l = lines[i];
59
- const clean = cleanAnsi(l);
61
+ const clean = cleanLines[i];
60
62
  // aggressive drops more noise even inside window
61
- if (mode === 'aggressive' && (PASS_RE.test(clean) || NOISE_RE.test(clean)) && !KEEP_RE.test(clean) && !STACK_RE.test(clean)) continue;
63
+ if (mode === 'aggressive' && (PASS_RE.test(clean) || NOISE_RE.test(clean)) && !keepLine(clean)) continue;
62
64
  out.push(l);
63
65
  }
64
66
 
@@ -88,5 +90,5 @@ export function compressLog(text, { mode = 'balanced', maxLines = 400 } = {}) {
88
90
  return { compressed, originalTokens, compressedTokens, savedTokens: Math.max(0, originalTokens - compressedTokens), savedPercent: originalTokens ? Math.round((1 - compressedTokens / originalTokens) * 100) : 0, keptLines: out.length, totalLines };
89
91
  }
90
92
 
91
- export { estimateTokens };
92
- export default { compressLog, estimateTokens };
93
+ export { cleanAnsi, estimateTokens };
94
+ export default { compressLog, cleanAnsi, estimateTokens };
package/lib/index.js CHANGED
@@ -29,21 +29,69 @@ const NS = '@goodandready/dsh-context-lens';
29
29
  export { renderOutput };
30
30
 
31
31
  export function apply(ctx, config) {
32
- let getConfig = () => config;
32
+ let readConfig = () => config;
33
+ const getConfig = () => readConfig();
33
34
 
34
35
  ctx.inject(['settings'], (sctx) => {
36
+ if (!sctx?.settings) return;
37
+
38
+ let disposeConfigure;
39
+ try {
40
+ if (typeof sctx.settings.configure === 'function') {
41
+ disposeConfigure = sctx.settings.configure({ auto: false }, ctx.fiber);
42
+ }
43
+ } catch (error) {
44
+ ctx.logger?.warn?.('dsh-context-lens: settings configure failed: ' + (error instanceof Error ? error.message : String(error)));
45
+ }
46
+
47
+ const readLiveSettings = () => {
48
+ try {
49
+ if (typeof sctx.settings.describe === 'function') {
50
+ const desc = sctx.settings.describe()?.find?.((row) => row.ns === NS);
51
+ if (desc && desc.value && typeof desc.value === 'object') {
52
+ return Config(desc.value);
53
+ }
54
+ } else if (typeof sctx.settings.get === 'function') {
55
+ const val = sctx.settings.get(NS);
56
+ if (val && typeof val === 'object') {
57
+ return Config(val);
58
+ }
59
+ }
60
+ } catch (error) {
61
+ ctx.logger?.warn?.('dsh-context-lens: settings describe failed: ' + (error instanceof Error ? error.message : String(error)));
62
+ }
63
+ return null;
64
+ };
65
+
66
+ readConfig = () => readLiveSettings() ?? Config(ctx.fiber?.config ?? config) ?? config;
67
+
35
68
  if (typeof sctx.effect === 'function') {
36
69
  sctx.effect(() => {
37
- const scope = sctx.settings.register(NS, Config, { base: config });
38
- getConfig = () => scope.get() ?? config;
70
+ const disposeDocUpdated = typeof sctx.on === 'function'
71
+ ? sctx.on('settings/document-updated', (updatedNs) => {
72
+ if (updatedNs === NS) {
73
+ // Live settings refreshed on next readLiveSettings call
74
+ }
75
+ })
76
+ : null;
39
77
  return () => {
40
- if (typeof scope?.dispose === 'function') scope.dispose();
41
- getConfig = () => config;
78
+ if (typeof disposeConfigure === 'function') {
79
+ try {
80
+ disposeConfigure();
81
+ } catch (err) {
82
+ ctx.logger?.debug?.('dsh-context-lens: disposeConfigure failed', err);
83
+ }
84
+ }
85
+ if (typeof disposeDocUpdated === 'function') {
86
+ try {
87
+ disposeDocUpdated();
88
+ } catch (err) {
89
+ ctx.logger?.debug?.('dsh-context-lens: disposeDocUpdated failed', err);
90
+ }
91
+ }
92
+ readConfig = () => config;
42
93
  };
43
94
  }, 'dsh-context-lens: settings');
44
- } else {
45
- const scope = sctx.settings.register(NS, Config, { base: config });
46
- getConfig = () => scope.get() ?? config;
47
95
  }
48
96
  });
49
97
 
@@ -71,11 +119,21 @@ export function apply(ctx, config) {
71
119
  mountUpdater();
72
120
  }
73
121
 
74
- // Status route (#70: explicit URL parsing and validation)
122
+ // Status route (#70: explicit URL parsing and validation; #87: GET only, trusted source check)
75
123
  ctx.effect(() => ctx.webServer.register({
76
124
  kind: 'exact',
77
125
  path: '/dsh-context-lens/status',
78
126
  handler: (req, res) => {
127
+ if (req.method !== 'GET') {
128
+ res.writeHead(405, { Allow: 'GET', 'Content-Type': 'application/json' });
129
+ res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed: GET required' }));
130
+ return;
131
+ }
132
+ if (!isTrustedRequest(req)) {
133
+ writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted request origin' });
134
+ return;
135
+ }
136
+
79
137
  const cfg = getConfig();
80
138
  if (typeof cfg.budgetLimit === 'number') tracker.setBudgetLimit(cfg.budgetLimit);
81
139
  if (typeof cfg.budgetAlertPercent === 'number') tracker.setBudgetAlertPercent(cfg.budgetAlertPercent);
package/lib/tools.js CHANGED
@@ -3,6 +3,20 @@ import { skeletonize } from './ast/skeletonizer.js';
3
3
  import * as tracker from './tokens/tracker.js';
4
4
  import { shouldAutoCompress } from './auto-compress.js';
5
5
 
6
+ function normalizePath(p) {
7
+ return (p || '').replace(/\\/g, '/').replace(/\/+$/, '');
8
+ }
9
+
10
+ export function isPathFocused(pattern, filePath) {
11
+ const normP = normalizePath(pattern);
12
+ const normF = normalizePath(filePath);
13
+ if (!normP || !normF) return false;
14
+ if (normF === normP) return true;
15
+ if (normF.startsWith(normP + '/')) return true;
16
+ if (normF.endsWith('/' + normP)) return true;
17
+ return false;
18
+ }
19
+
6
20
  export const OUTPUT_SCHEMA = { type: 'object', properties: { success: { type: 'boolean' } }, additionalProperties: true };
7
21
 
8
22
  // #71 (GH #2): output.render MUST return ContentBlock[] [{ type: 'text', text: ... }]
@@ -10,6 +24,7 @@ export const renderOutput = (_args, result) => [
10
24
  { type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }
11
25
  ];
12
26
 
27
+ export const MAX_FOCUS_SESSIONS = 500;
13
28
  const focusBySession = new Map();
14
29
  let lastActiveSession = '__default__';
15
30
 
@@ -24,18 +39,34 @@ export function sessionKey(params, meta) {
24
39
  }
25
40
 
26
41
  export function getFocus(key) {
27
- if (!focusBySession.has(key)) focusBySession.set(key, { paths: [], updatedAt: null });
28
- return focusBySession.get(key);
42
+ const k = key || '__default__';
43
+ if (!focusBySession.has(k)) {
44
+ if (focusBySession.size >= MAX_FOCUS_SESSIONS) {
45
+ const oldestKey = focusBySession.keys().next().value;
46
+ if (oldestKey) focusBySession.delete(oldestKey);
47
+ }
48
+ focusBySession.set(k, { paths: [], updatedAt: null });
49
+ }
50
+ return focusBySession.get(k);
29
51
  }
30
52
 
31
53
  export function clearFocus(key) {
32
54
  if (key) {
33
- focusBySession.set(key, { paths: [], updatedAt: new Date().toISOString() });
55
+ focusBySession.delete(key);
34
56
  } else {
35
57
  focusBySession.clear();
36
58
  }
37
59
  }
38
60
 
61
+ export function resetFocusBySession() {
62
+ focusBySession.clear();
63
+ lastActiveSession = '__default__';
64
+ }
65
+
66
+ export function getFocusBySessionSize() {
67
+ return focusBySession.size;
68
+ }
69
+
39
70
  export function getLastActiveSession() {
40
71
  return lastActiveSession;
41
72
  }
@@ -57,7 +88,11 @@ export function registerTools(ctx, { getConfig, maybeTrack }) {
57
88
  output: { schema: OUTPUT_SCHEMA, render: renderOutput },
58
89
  execute: async (params, meta) => {
59
90
  const key = sessionKey(params, meta);
60
- const paths = Array.isArray(params.paths) ? params.paths : [];
91
+ const rawPaths = Array.isArray(params.paths) ? params.paths : [];
92
+ const paths = rawPaths
93
+ .filter(p => typeof p === 'string')
94
+ .map(p => p.trim())
95
+ .filter(p => p.length > 0);
61
96
  const state = getFocus(key);
62
97
  state.paths = paths;
63
98
  state.updatedAt = new Date().toISOString();
@@ -113,8 +148,9 @@ export function registerTools(ctx, { getConfig, maybeTrack }) {
113
148
  const maxDepth = params.maxDepth ?? cfg.astSkeletonMaxDepth ?? 3;
114
149
  const key = sessionKey(params, meta);
115
150
  const focusState = getFocus(key);
116
- const isFocused = params.filePath && focusState.paths.length > 0
117
- ? focusState.paths.some(p => params.filePath.includes(p) || p.includes(params.filePath))
151
+ const rawFilePath = typeof params.filePath === 'string' ? params.filePath.trim() : '';
152
+ const isFocused = rawFilePath && focusState.paths.length > 0
153
+ ? focusState.paths.some(p => isPathFocused(p, rawFilePath))
118
154
  : false;
119
155
  if (isFocused) {
120
156
  maybeTrack(params.code, params.code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-context-lens",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "DSH plugin for AST context compression, test log filtering, and token budget guard",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",