delimit-cli 4.6.0 → 4.6.2

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.
@@ -14,11 +14,131 @@ logger = logging.getLogger("delimit.ai.memory_bridge")
14
14
 
15
15
  MEMORY_DIR = Path.home() / ".delimit" / "memory"
16
16
 
17
+ # Legacy CLI store filename. The npm CLI historically wrote memories as
18
+ # newline-delimited JSON (`memories.jsonl`) using a `text`/`created`/`source`
19
+ # schema, while the MCP store writes one `mem-*.json` file per entry using
20
+ # `content`/`created_at`/`context`. The readers below reconcile both so a
21
+ # customer who created memories via the old CLI still sees them through the
22
+ # MCP tools (FIX C — non-destructive; the .jsonl is never rewritten here).
23
+ LEGACY_JSONL_NAME = "memories.jsonl"
24
+
17
25
 
18
26
  def _ensure_dir():
19
27
  MEMORY_DIR.mkdir(parents=True, exist_ok=True)
20
28
 
21
29
 
30
+ def _tokenize(query: str) -> List[str]:
31
+ """Split a search query into lowercased whitespace-delimited tokens.
32
+
33
+ Used by search() for OR-semantics keyword matching: an entry is a hit
34
+ if it contains at least one token. Empty / whitespace-only queries
35
+ yield no tokens (callers preserve their own empty-query behavior).
36
+ """
37
+ return [t for t in (query or "").lower().split() if t]
38
+
39
+
40
+ def _normalize_legacy_entry(raw: Dict[str, Any]) -> Dict[str, Any]:
41
+ """Normalize a legacy `memories.jsonl` record to the MCP entry shape.
42
+
43
+ Legacy CLI schema: {id, text, tags, created, source}
44
+ MCP schema: {id, content, tags, context, created_at, hot_load}
45
+
46
+ Maps text->content and created->created_at without dropping the
47
+ original keys, and synthesizes a context from `source` when absent so
48
+ downstream readers behave uniformly. Mirrors the CLI's readMemories
49
+ normalization (npm-delimit/bin/delimit-cli.js) for cross-tool parity.
50
+ """
51
+ entry = dict(raw)
52
+ if entry.get("text") and not entry.get("content"):
53
+ entry["content"] = entry["text"]
54
+ if entry.get("content") and not entry.get("text"):
55
+ entry["text"] = entry["content"]
56
+ if entry.get("created") and not entry.get("created_at"):
57
+ entry["created_at"] = entry["created"]
58
+ if entry.get("created_at") and not entry.get("created"):
59
+ entry["created"] = entry["created_at"]
60
+ if not entry.get("context") and entry.get("source"):
61
+ entry["context"] = entry["source"]
62
+ return entry
63
+
64
+
65
+ def _read_legacy_jsonl() -> List[Dict[str, Any]]:
66
+ """Read and normalize legacy `memories.jsonl` entries, if present.
67
+
68
+ Defensive by contract: a missing or malformed file yields an empty
69
+ list and never raises. Malformed individual lines are skipped so one
70
+ bad line does not lose the rest of the file.
71
+ """
72
+ path = MEMORY_DIR / LEGACY_JSONL_NAME
73
+ entries: List[Dict[str, Any]] = []
74
+ try:
75
+ if not path.exists():
76
+ return entries
77
+ text = path.read_text()
78
+ except OSError:
79
+ return entries
80
+ for line in text.splitlines():
81
+ line = line.strip()
82
+ if not line:
83
+ continue
84
+ try:
85
+ raw = json.loads(line)
86
+ except (json.JSONDecodeError, ValueError):
87
+ continue
88
+ if isinstance(raw, dict):
89
+ entries.append(_normalize_legacy_entry(raw))
90
+ return entries
91
+
92
+
93
+ def _load_all_entries() -> List[Dict[str, Any]]:
94
+ """Load every memory entry from both stores, deduped by id.
95
+
96
+ Reads the per-entry `mem-*.json` files (MCP, primary) and the legacy
97
+ `memories.jsonl` (CLI, backwards-compat). On an id collision the
98
+ `mem-*.json` entry wins — it is the authoritative MCP store and may
99
+ carry fields (e.g. hot_load) the legacy record lacks. Entries are
100
+ returned newest-first by created_at so callers that slice keep the
101
+ most recent. Fully defensive: unreadable files are skipped.
102
+
103
+ FIX C: the legacy `memories.jsonl` is read-only here — never deleted
104
+ or rewritten — preserving a customer's existing CLI-authored memories.
105
+ """
106
+ by_id: Dict[str, Dict[str, Any]] = {}
107
+ order: List[str] = []
108
+
109
+ def _add(entry: Dict[str, Any], key: str, *, overwrite: bool) -> None:
110
+ if key not in by_id:
111
+ by_id[key] = entry
112
+ order.append(key)
113
+ elif overwrite:
114
+ by_id[key] = entry
115
+
116
+ # Primary store: mem-*.json (authoritative, wins on conflict).
117
+ for f in MEMORY_DIR.glob("*.json"):
118
+ try:
119
+ entry = json.loads(f.read_text())
120
+ except (OSError, json.JSONDecodeError, ValueError):
121
+ continue
122
+ if not isinstance(entry, dict):
123
+ continue
124
+ entry.setdefault("id", f.stem)
125
+ _add(entry, entry.get("id") or f.stem, overwrite=True)
126
+
127
+ # Legacy jsonl: only fills ids the primary store does not already have.
128
+ for entry in _read_legacy_jsonl():
129
+ key = entry.get("id")
130
+ if not key:
131
+ # No id to dedupe on — keep it, it cannot collide.
132
+ order.append(id(entry)) # unique sentinel key
133
+ by_id[id(entry)] = entry
134
+ continue
135
+ _add(entry, key, overwrite=False)
136
+
137
+ entries = [by_id[k] for k in order]
138
+ entries.sort(key=lambda e: e.get("created_at") or e.get("created") or "", reverse=True)
139
+ return entries
140
+
141
+
22
142
  def store(
23
143
  content: str,
24
144
  tags: Optional[list] = None,
@@ -68,56 +188,97 @@ def store(
68
188
 
69
189
 
70
190
  def search(query: str, limit: int = 10) -> Dict[str, Any]:
71
- """Search memories by keyword matching."""
191
+ """Search memories by keyword matching.
192
+
193
+ FIX A: the query is tokenized on whitespace and matched with OR
194
+ semantics — an entry is a hit if it contains at least one token in its
195
+ content, tags, or context. Previously the entire query had to appear as
196
+ one contiguous substring, so any multi-word query returned zero hits.
197
+
198
+ Results are ranked by the number of distinct query tokens matched
199
+ (descending), tie-broken by recency (created_at descending). The
200
+ `relevance` field is preserved in the return schema and now carries the
201
+ matched-token count, the primary ranking signal.
202
+
203
+ An empty (or whitespace-only) query preserves the previous behavior of
204
+ returning no results.
205
+
206
+ FIX C: reads both the per-entry `mem-*.json` MCP store and the legacy
207
+ `memories.jsonl` CLI store (deduped, MCP wins on id conflict).
208
+ """
72
209
  _ensure_dir()
73
- query_lower = query.lower()
210
+ tokens = _tokenize(query)
74
211
  results = []
75
212
 
76
- for f in sorted(MEMORY_DIR.glob("*.json"), reverse=True):
77
- try:
78
- entry = json.loads(f.read_text())
79
- content = entry.get("content", "").lower()
80
- tags = " ".join(entry.get("tags", [])).lower()
81
- context = entry.get("context", "").lower()
82
-
83
- # Simple keyword matching
84
- if query_lower in content or query_lower in tags or query_lower in context:
85
- results.append({
86
- "id": entry.get("id", f.stem),
87
- "content": entry.get("content", "")[:500],
88
- "tags": entry.get("tags", []),
89
- "created_at": entry.get("created_at", ""),
90
- "relevance": content.count(query_lower),
91
- })
92
-
93
- if len(results) >= limit:
94
- break
95
- except Exception:
96
- pass
97
-
98
- results.sort(key=lambda r: r.get("relevance", 0), reverse=True)
213
+ # Empty / whitespace-only query: preserve prior behavior (no hits).
214
+ if not tokens:
215
+ return {"query": query, "results": results, "count": 0}
216
+
217
+ for entry in _load_all_entries():
218
+ content = (entry.get("content") or "").lower()
219
+ tags = " ".join(entry.get("tags") or []).lower()
220
+ context = (entry.get("context") or "").lower()
221
+ haystacks = (content, tags, context)
222
+
223
+ matched_tokens = 0
224
+ total_occurrences = 0
225
+ for tok in tokens:
226
+ hit = False
227
+ for hay in haystacks:
228
+ c = hay.count(tok)
229
+ if c:
230
+ hit = True
231
+ total_occurrences += c
232
+ if hit:
233
+ matched_tokens += 1
234
+
235
+ if matched_tokens >= 1:
236
+ results.append({
237
+ "id": entry.get("id", ""),
238
+ "content": (entry.get("content") or "")[:500],
239
+ "tags": entry.get("tags") or [],
240
+ "created_at": entry.get("created_at") or entry.get("created") or "",
241
+ # `relevance` preserved in schema; now = matched-token count
242
+ # (primary ranking signal). _occurrences is an internal
243
+ # tie-break aid, dropped before return.
244
+ "relevance": matched_tokens,
245
+ "_occurrences": total_occurrences,
246
+ })
247
+
248
+ # Rank: most tokens matched first, then most occurrences, then recency.
249
+ results.sort(
250
+ key=lambda r: (r["relevance"], r["_occurrences"], r.get("created_at") or ""),
251
+ reverse=True,
252
+ )
253
+ for r in results:
254
+ r.pop("_occurrences", None)
255
+
256
+ results = results[:limit]
99
257
  return {"query": query, "results": results, "count": len(results)}
100
258
 
101
259
 
102
260
  def get_recent(limit: int = 5) -> Dict[str, Any]:
103
- """Get recent memory entries."""
261
+ """Get recent memory entries.
262
+
263
+ FIX C: reads both the per-entry `mem-*.json` MCP store and the legacy
264
+ `memories.jsonl` CLI store. Entries are deduped by id (MCP wins) and
265
+ ordered newest-first by created_at (legacy `created` is normalized to
266
+ `created_at`). Legacy entries surface `hot_load=False` since the field
267
+ pre-dates that schema.
268
+ """
104
269
  _ensure_dir()
105
270
  entries = []
106
271
 
107
- for f in sorted(MEMORY_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
272
+ for entry in _load_all_entries():
108
273
  if len(entries) >= limit:
109
274
  break
110
- try:
111
- entry = json.loads(f.read_text())
112
- entries.append({
113
- "id": entry.get("id", f.stem),
114
- "content": entry.get("content", "")[:500],
115
- "tags": entry.get("tags", []),
116
- "created_at": entry.get("created_at", ""),
117
- "hot_load": bool(entry.get("hot_load", False)),
118
- })
119
- except Exception:
120
- pass
275
+ entries.append({
276
+ "id": entry.get("id", ""),
277
+ "content": (entry.get("content") or "")[:500],
278
+ "tags": entry.get("tags") or [],
279
+ "created_at": entry.get("created_at") or entry.get("created") or "",
280
+ "hot_load": bool(entry.get("hot_load", False)),
281
+ })
121
282
 
122
283
  return {"results": entries, "count": len(entries)}
123
284
 
@@ -143,23 +304,19 @@ def list_hot(limit: int = 200) -> Dict[str, Any]:
143
304
  _ensure_dir()
144
305
  entries = []
145
306
 
146
- for f in sorted(MEMORY_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
307
+ for entry in _load_all_entries():
147
308
  if len(entries) >= limit:
148
309
  break
149
- try:
150
- entry = json.loads(f.read_text())
151
- if not entry.get("hot_load"):
152
- continue
153
- entries.append({
154
- "id": entry.get("id", f.stem),
155
- "content": entry.get("content", ""),
156
- "tags": entry.get("tags", []),
157
- "context": entry.get("context", ""),
158
- "created_at": entry.get("created_at", ""),
159
- "hot_load": True,
160
- })
161
- except Exception:
162
- pass
310
+ if not entry.get("hot_load"):
311
+ continue
312
+ entries.append({
313
+ "id": entry.get("id", ""),
314
+ "content": entry.get("content") or "",
315
+ "tags": entry.get("tags") or [],
316
+ "context": entry.get("context") or "",
317
+ "created_at": entry.get("created_at") or entry.get("created") or "",
318
+ "hot_load": True,
319
+ })
163
320
 
164
321
  return {"results": entries, "count": len(entries)}
165
322
 
@@ -72,6 +72,19 @@ _CREDENTIAL_FALSE_POSITIVES = re.compile(
72
72
  r"_data\[|_result\[|"
73
73
  # LED-1278 (b): function-call RHS with leading underscore (e.g. _load_token())
74
74
  r"=\s*_\w+\(|"
75
+ # LED-1278 (c) [2026-05-22]: naked function-call RHS without leading
76
+ # underscore. Matches the common shape `const token = readCurrentToken();`
77
+ # in bin/delimit-cli.js — the token is being READ from somewhere, not
78
+ # hardcoded. Tightened with `\s*;?\s*$` to require end-of-statement so
79
+ # we don't suppress `token = realLeak("AKIAIOSFODNN7EXAMPLE")` shapes
80
+ # where the call argument is itself a literal secret.
81
+ r"=\s*\w+\([^)]{0,40}\)\s*;?\s*$|"
82
+ # LED-1278 (c) [2026-05-22]: parenthesized property-access fallback chain
83
+ # like `const token = (options.token || process.env.TOKEN)`. Common shape
84
+ # for CLI option parsing where the RHS reads from a known input source,
85
+ # never a literal. Requires the open-paren to be followed by a word + dot
86
+ # (property access) so we don't match `token = ("AKIA..." || "")` shapes.
87
+ r"=\s*\(\s*\w+\.\w+|"
75
88
  # LED-1278 (b): documentation/example placeholders in angle brackets
76
89
  r"<[^>]*?(?:long|same|random|your|placeholder|example|secret|token|key)[^>]*?>|"
77
90
  # Bare `if not <var>:` and similar control-flow lines that mention
@@ -149,6 +162,73 @@ KNOWN_DUMMY_PATTERNS = [
149
162
  ]
150
163
 
151
164
 
165
+ # LED-2278 [2026-05-27]: positive value-shape gate for generic_secret.
166
+ #
167
+ # The generic_secret regex (`\b(?:secret|password|passwd|token)\b\s*[=:]\s*
168
+ # ['\"]?[^\s'\"]{8,}`) fires on ANY assignment/key whose trigger word is
169
+ # followed by 8+ non-space chars — including ordinary code where the RHS is
170
+ # an identifier, a function call, or a subscript expression, not a hardcoded
171
+ # literal. Examples that recurrently false-positive in this very repo:
172
+ #
173
+ # token = self._unescape_json_pointer_token(raw_token) # method call
174
+ # scheme, token = parts[0].strip().lower(), parts[1] # tuple/subscript
175
+ #
176
+ # The pre-existing `_CREDENTIAL_FALSE_POSITIVES` negative list is whack-a-mole
177
+ # (one alternation per observed shape). This positive gate inverts the logic:
178
+ # a `generic_secret` hit is only credible when the VALUE is a *quoted string
179
+ # literal* with secret-like entropy/length. If the value is an unquoted
180
+ # identifier / call / expression, it is code, not a leaked secret — suppress.
181
+ #
182
+ # Conservative by construction: this gate only ever SUPPRESSES generic_secret
183
+ # hits whose value is non-literal. It never suppresses a quoted literal, so
184
+ # real hardcoded secrets (and all the existing detection tests) still fire.
185
+ # Applies to generic_secret only — aws_secret_key / github_token / etc. keep
186
+ # their own format-specific regexes untouched.
187
+
188
+ # A value (after the = or :) that begins with a quote is a string literal.
189
+ _GENERIC_SECRET_VALUE_RE = re.compile(
190
+ r"""\b(?:secret|password|passwd|token)\b\s*[=:]\s*(?P<q>['\"])(?P<val>[^'\"]*)"""
191
+ )
192
+
193
+
194
+ def _generic_secret_value_is_literal(matched_text: str) -> bool:
195
+ """True only if the generic_secret match assigns a *quoted string literal*.
196
+
197
+ The generic_secret regex tolerates an optional opening quote, so it also
198
+ matches `token = some_call()` (unquoted RHS). A real hardcoded secret is a
199
+ quoted literal with entropy; an unquoted RHS is an identifier/expression
200
+ (variable ref, function call, subscript, attribute access) and is code, not
201
+ a leak. Return False for the unquoted/expression case so the caller can
202
+ suppress it, True for a credible quoted-literal value.
203
+ """
204
+ m = _GENERIC_SECRET_VALUE_RE.search(matched_text)
205
+ if not m:
206
+ # No opening quote captured → RHS is a bare identifier / expression
207
+ # (e.g. `token = self._make(...)`, `scheme, token = parts[0]`). Not a
208
+ # hardcoded literal; suppress.
209
+ return False
210
+ val = m.group("val")
211
+ # A quoted literal with too little content is not secret-shaped. The outer
212
+ # regex already required 8+ chars total, but the quote may sit mid-match;
213
+ # require the literal body itself to be reasonably long.
214
+ if len(val) < 6:
215
+ return False
216
+ # Pure-identifier literals inside quotes (e.g. a quoted dict KEY like
217
+ # "access_token") that are all word chars + separators and read like an
218
+ # English/identifier token rather than a high-entropy secret: require at
219
+ # least some character-class mixing OR sufficient length to look secret-y.
220
+ has_lower = any(c.islower() for c in val)
221
+ has_upper = any(c.isupper() for c in val)
222
+ has_digit = any(c.isdigit() for c in val)
223
+ # Treat underscore/hyphen as word chars (not entropy): a quoted
224
+ # identifier-shaped value like "access_token" should NOT count as a
225
+ # multi-class high-entropy secret on the strength of its separators alone.
226
+ has_symbol = any(not c.isalnum() and c not in (" ", "_", "-") for c in val)
227
+ classes = sum([has_lower, has_upper, has_digit, has_symbol])
228
+ # Credible secret: multi-class entropy, OR a long single-class blob.
229
+ return classes >= 2 or len(val) >= 16
230
+
231
+
152
232
  def _looks_like_known_dummy(secret_name: str, matched_text: str) -> Optional[str]:
153
233
  """Return a label if matched_text is a known-dummy/fixture value, else None.
154
234
 
@@ -422,6 +502,19 @@ def security_audit(target: str = ".", include_tests: bool = False) -> Dict[str,
422
502
  # Skip false positives only for generic patterns (not specific token formats)
423
503
  if secret_name in _FP_FILTERED and _CREDENTIAL_FALSE_POSITIVES.search(matched_text):
424
504
  continue
505
+ # LED-2278: positive value-shape gate for generic_secret. Only
506
+ # flag when the assigned value is a quoted string literal with
507
+ # secret-like entropy; an unquoted identifier/call/expression
508
+ # RHS (`token = self._make(...)`, `scheme, token = parts[0]`)
509
+ # is code, not a leaked secret. Conservative: never suppresses
510
+ # a quoted literal, so real hardcoded secrets still fire.
511
+ if secret_name == "generic_secret" and not _generic_secret_value_is_literal(matched_text):
512
+ continue
513
+ # LED-2278: the scanner's own source embeds the trigger words in
514
+ # regex/doc comments (e.g. the `token = realLeak(...)` example in
515
+ # this module). Those are pattern DEFINITIONS, not secrets.
516
+ if secret_name == "generic_secret" and rel.endswith("ai/backends/tools_infra.py"):
517
+ continue
425
518
  line_num = content[:match.start()].count("\n") + 1
426
519
  # LED-1278 (b): well-known dummy/placeholder values get
427
520
  # suppressed to info-level rather than raised as critical.
@@ -10,6 +10,7 @@ import json
10
10
  import logging
11
11
  import os
12
12
  import re
13
+ import shutil
13
14
  import subprocess
14
15
  from datetime import datetime, timezone
15
16
  from pathlib import Path
@@ -364,18 +365,63 @@ def test_smoke(project_path: str, test_suite: Optional[str] = None) -> Dict[str,
364
365
  return {"tool": "test.smoke", "status": "error", "error": f"Invalid test_suite: {test_suite}"}
365
366
  cmd_list.append(test_suite)
366
367
 
367
- # Detect the right Python executable
368
+ # Detect the right Python executable.
369
+ #
370
+ # Resolution order (LED-1564 follow-up, 2026-05-22):
371
+ # 1. Project's own venv (most isolated; honors project's own deps).
372
+ # 2. System python3 on PATH — where projects typically install deps
373
+ # when they don't ship a local venv. Tested for pytest availability
374
+ # so we don't fall through to a Python that can't run pytest.
375
+ # 3. sys.executable (= MCP server's runner venv) as last resort.
376
+ #
377
+ # The pre-fix order was (1) → (3), which broke for projects that have
378
+ # their deps installed system-wide but no project-local venv: pytest
379
+ # itself might exist in the delimit venv, but project-specific imports
380
+ # like `pika` (caught by codex against wirereport 2026-05-22) raise
381
+ # ModuleNotFoundError because the delimit venv is stripped to the MCP
382
+ # server's deps only.
368
383
  if framework == "pytest":
369
- python_found = False
384
+ import sys as _sys
385
+
386
+ chosen = None
387
+ # (1) Project-local venv.
370
388
  for venv_dir in ["venv", ".venv", "env"]:
371
389
  venv_python = project / venv_dir / "bin" / "python"
372
390
  if venv_python.exists():
373
- cmd_list[0] = str(venv_python)
374
- python_found = True
391
+ chosen = str(venv_python)
375
392
  break
376
- if not python_found:
377
- import sys as _sys
378
- cmd_list[0] = _sys.executable
393
+
394
+ # (2) System python3 if it has pytest. Probe with a fast import-
395
+ # check so we don't pick a python that can't actually run pytest.
396
+ if chosen is None:
397
+ for candidate in ("python3", "python"):
398
+ exe = shutil.which(candidate)
399
+ if not exe:
400
+ continue
401
+ # Skip only when the candidate path is literally the same
402
+ # interpreter entrypoint as the MCP runner. In deployments
403
+ # where the venv python is a symlink to /usr/bin/python3,
404
+ # comparing resolved paths collapses the system interpreter
405
+ # and the venv interpreter into the same target and prevents
406
+ # the intended fallback to system python3.
407
+ if Path(exe) == Path(_sys.executable):
408
+ continue
409
+ try:
410
+ probe = subprocess.run(
411
+ [exe, "-c", "import pytest"],
412
+ capture_output=True, timeout=10,
413
+ )
414
+ if probe.returncode == 0:
415
+ chosen = exe
416
+ break
417
+ except (subprocess.TimeoutExpired, OSError):
418
+ continue
419
+
420
+ # (3) sys.executable (= MCP server's runner venv) as last resort.
421
+ if chosen is None:
422
+ chosen = _sys.executable
423
+
424
+ cmd_list[0] = chosen
379
425
 
380
426
  try:
381
427
  result = subprocess.run(