@neikyun/ciel 6.10.0 → 6.11.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.
@@ -18,6 +18,7 @@ See docs/adrs/0001-cued-recall-memory.md for design rationale.
18
18
  import sys
19
19
  import os
20
20
  import json
21
+ import math
21
22
  import re
22
23
  import fnmatch
23
24
  import argparse
@@ -233,8 +234,16 @@ def match_path_pattern(pattern: str, paths) -> bool:
233
234
  return False
234
235
 
235
236
 
236
- def score_memory(mem, paths, symbols, intents, langs) -> int:
237
- """Score a memory's relevance. 0 = exclude. Positive = include, higher first."""
237
+ def score_memory(mem, paths, symbols, intents, langs, prompt_lower="") -> int:
238
+ """Score a memory's relevance. 0 = exclude. Positive = include, higher first.
239
+
240
+ Symbol and intent matching are case-insensitive and fall back to a
241
+ word-boundary search against the raw prompt. This lets a memory tagged
242
+ `symbols: [OkHttp]` fire on a prompt that mentions "okhttp" in prose,
243
+ and lets free-form intent tags (e.g. `intents: [okhttp, diagnostics]`)
244
+ match without being members of the fixed INTENT_KEYWORDS vocabulary.
245
+ Word boundaries prevent "test" intent from firing on "contest".
246
+ """
238
247
  # Hard language gate: if memory is language-specific AND prompt has language
239
248
  # cues AND no overlap → exclude. Avoids Kotlin memories firing on TS edits.
240
249
  mem_langs = mem.get('languages') or []
@@ -245,11 +254,23 @@ def score_memory(mem, paths, symbols, intents, langs) -> int:
245
254
  for pattern in mem.get('path_patterns') or []:
246
255
  if match_path_pattern(pattern, paths):
247
256
  score += 10
257
+
258
+ # Case-insensitive comparison sets — built once per memory call.
259
+ symbols_lower = {s.lower() for s in symbols}
260
+ intents_lower = {i.lower() for i in intents}
261
+
248
262
  for sym in mem.get('symbols') or []:
249
- if sym in symbols:
263
+ sym_lower = sym.lower()
264
+ if sym_lower in symbols_lower or (
265
+ prompt_lower and re.search(r'\b' + re.escape(sym_lower) + r'\b', prompt_lower)
266
+ ):
250
267
  score += 8
268
+
251
269
  for intent in mem.get('intents') or []:
252
- if intent in intents:
270
+ intent_lower = intent.lower()
271
+ if intent_lower in intents_lower or (
272
+ prompt_lower and re.search(r'\b' + re.escape(intent_lower) + r'\b', prompt_lower)
273
+ ):
253
274
  score += 5
254
275
 
255
276
  # No cue match at all → don't include (cued recall, not free recall)
@@ -399,6 +420,7 @@ def cmd_query(args):
399
420
  symbols = extract_symbol_cues(prompt)
400
421
  intents = extract_intent_cues(prompt)
401
422
  langs = extract_language_cues(prompt)
423
+ prompt_lower = prompt.lower()
402
424
  now = datetime.now(timezone.utc)
403
425
  iso_now = now.isoformat().replace('+00:00', 'Z')
404
426
 
@@ -418,7 +440,7 @@ def cmd_query(args):
418
440
  for mid, m in mems.items():
419
441
  if m.get('stale'):
420
442
  continue
421
- s = score_memory(m, paths, symbols, intents, langs)
443
+ s = score_memory(m, paths, symbols, intents, langs, prompt_lower=prompt_lower)
422
444
  if s > 0:
423
445
  scored.append((s, mid, m))
424
446
 
@@ -674,6 +696,232 @@ def cmd_capture(args):
674
696
  print(f"Index rebuilt with memory: {mid}")
675
697
 
676
698
 
699
+ def cmd_analyze(args):
700
+ """Mine recurring patterns across the corpus and emit insights.
701
+
702
+ Read-only on memories. Computes promotion candidates, dead anchors,
703
+ intent/path clusters and 7 health metrics, then writes:
704
+ - .ciel/memory/insights.json (machine; consumed by ciel-audit Dim 10)
705
+ - .ciel/memory/INSIGHTS.md (human digest)
706
+
707
+ Min-support floor (3 memories) enforced by the engine — refuses to
708
+ surface a "cluster" the model could narrate from sparse evidence.
709
+ Generation cap is computed from optional `derived_from` chains so a
710
+ future synthesizer cannot recurse on its own outputs without it
711
+ showing up as a metric.
712
+ """
713
+ cwd = resolve_cwd(args.cwd)
714
+ base = cwd / '.ciel' / 'memory'
715
+ if not base.exists():
716
+ print(f"No memory directory at {base}", file=sys.stderr)
717
+ sys.exit(1)
718
+
719
+ index_file = base / 'index.json'
720
+ if index_file.exists():
721
+ with index_file.open('r', encoding='utf-8') as f:
722
+ index = json.load(f)
723
+ else:
724
+ index = {"version": 2, "memories": {}, "by_path": {}, "by_symbol": {},
725
+ "by_intent": {}, "by_language": {}}
726
+
727
+ memories = index.get('memories', {}) or {}
728
+ by_intent = index.get('by_intent', {}) or {}
729
+ by_path = index.get('by_path', {}) or {}
730
+
731
+ MIN_PROMOTION = 5
732
+ MIN_SUPPORT = 3
733
+
734
+ episodes = {mid: m for mid, m in memories.items()
735
+ if str(m.get('file', '')).startswith('episodes/')}
736
+ concepts = {mid: m for mid, m in memories.items()
737
+ if str(m.get('file', '')).startswith('concepts/')}
738
+ guards = {mid: m for mid, m in memories.items()
739
+ if str(m.get('file', '')).startswith('guards/')}
740
+
741
+ promotion_candidates = [mid for mid, m in episodes.items()
742
+ if (m.get('trigger_count') or 0) >= MIN_PROMOTION]
743
+ promotion_candidates.sort(key=lambda mid: -(episodes[mid].get('trigger_count') or 0))
744
+
745
+ dead_anchors = []
746
+ for mid, m in memories.items():
747
+ patterns = m.get('path_patterns') or []
748
+ if not patterns:
749
+ continue
750
+ alive = False
751
+ for pat in patterns:
752
+ try:
753
+ # Path.glob raises NotImplementedError on absolute patterns
754
+ # in Python 3.13+, so route absolute paths through Path.exists
755
+ # directly. Relative patterns keep the glob (** support).
756
+ if pat.startswith('/'):
757
+ if Path(pat).exists():
758
+ alive = True
759
+ break
760
+ elif any(True for _ in cwd.glob(pat)):
761
+ alive = True
762
+ break
763
+ except (ValueError, OSError, NotImplementedError):
764
+ continue
765
+ if not alive:
766
+ dead_anchors.append(mid)
767
+ dead_anchors.sort()
768
+
769
+ intent_clusters = {k: sorted(v) for k, v in by_intent.items() if len(v) >= MIN_SUPPORT}
770
+ path_clusters = {k: sorted(v) for k, v in by_path.items() if len(v) >= MIN_SUPPORT}
771
+
772
+ now = datetime.now(timezone.utc)
773
+
774
+ def days_ago(iso_str):
775
+ if not iso_str:
776
+ return None
777
+ try:
778
+ dt = datetime.fromisoformat(str(iso_str).replace('Z', '+00:00'))
779
+ return (now - dt).days
780
+ except (ValueError, TypeError):
781
+ return None
782
+
783
+ total = len(memories)
784
+
785
+ recent = sum(1 for m in memories.values()
786
+ if (days_ago(m.get('captured_at')) or 10**6) <= 30)
787
+ recency_30d_ratio = round(recent / total, 3) if total else 0.0
788
+
789
+ intent_counts = [len(ids) for ids in by_intent.values() if ids]
790
+ intent_diversity_entropy = 0.0
791
+ if intent_counts:
792
+ total_tags = sum(intent_counts)
793
+ for c in intent_counts:
794
+ p = c / total_tags
795
+ intent_diversity_entropy -= p * math.log2(p)
796
+ intent_diversity_entropy = round(intent_diversity_entropy, 3)
797
+
798
+ dead_anchor_ratio = round(len(dead_anchors) / total, 3) if total else 0.0
799
+
800
+ # Generation depth from optional `derived_from` chains. Cycle-guarded.
801
+ def gen_depth(mid, seen):
802
+ if mid in seen:
803
+ return 0
804
+ m = memories.get(mid, {})
805
+ parents = m.get('derived_from') or []
806
+ if not parents:
807
+ return 1
808
+ return 1 + max(gen_depth(p, seen | {mid}) for p in parents)
809
+
810
+ max_generation_depth = max((gen_depth(mid, set()) for mid in memories), default=0)
811
+
812
+ flat_intents = [i for m in memories.values() for i in (m.get('intents') or [])]
813
+ tag_specificity = round(len(set(flat_intents)) / len(flat_intents), 3) if flat_intents else 0.0
814
+
815
+ promotion_ratio = round(len(concepts) / len(episodes), 3) if episodes else 0.0
816
+
817
+ corrections = sum(1 for m in episodes.values()
818
+ if m.get('captured_from') in ('user-intervention', 'intervention'))
819
+ capture_correction_ratio = round(corrections / len(episodes), 3) if episodes else 0.0
820
+
821
+ health = {
822
+ "recency_30d_ratio": recency_30d_ratio,
823
+ "intent_diversity_entropy": intent_diversity_entropy,
824
+ "dead_anchor_ratio": dead_anchor_ratio,
825
+ "max_generation_depth": max_generation_depth,
826
+ "tag_specificity": tag_specificity,
827
+ "promotion_ratio": promotion_ratio,
828
+ "capture_correction_ratio": capture_correction_ratio,
829
+ }
830
+
831
+ insights = {
832
+ "version": 1,
833
+ "generated_at": now.isoformat().replace('+00:00', 'Z'),
834
+ "corpus_size": {
835
+ "episodes": len(episodes),
836
+ "concepts": len(concepts),
837
+ "guards": len(guards),
838
+ "total": total,
839
+ },
840
+ "promotion_candidates": promotion_candidates,
841
+ "dead_anchors": dead_anchors,
842
+ "intent_clusters": intent_clusters,
843
+ "path_clusters": path_clusters,
844
+ "health": health,
845
+ "thresholds": {
846
+ "min_promotion_trigger_count": MIN_PROMOTION,
847
+ "min_support_episodes": MIN_SUPPORT,
848
+ },
849
+ }
850
+
851
+ insights_json = base / 'insights.json'
852
+ atomic_write_json(insights_json, insights)
853
+
854
+ lines = [
855
+ "# Memory insights",
856
+ "",
857
+ f"_Generated {insights['generated_at']} by `memory-engine.py analyze`. Read by `ciel-audit` Dim 10._",
858
+ "",
859
+ f"**Corpus**: {len(episodes)} episodes, {len(concepts)} concepts, {len(guards)} guards (total {total}).",
860
+ "",
861
+ "## Health metrics",
862
+ "",
863
+ ]
864
+ for key, val in health.items():
865
+ lines.append(f"- `{key}`: **{val}**")
866
+ lines.append("")
867
+
868
+ if promotion_candidates:
869
+ lines += [
870
+ "## Promotion candidates",
871
+ "",
872
+ f"Episodes triggered >= {MIN_PROMOTION} times. Promote via skill `memoire-consolidator`.",
873
+ "",
874
+ ]
875
+ for mid in promotion_candidates:
876
+ m = episodes[mid]
877
+ lines.append(f"- `{mid}` (trigger_count={m.get('trigger_count', 0)}) - {m.get('title', '?')}")
878
+ lines.append("")
879
+
880
+ if dead_anchors:
881
+ lines += [
882
+ "## Dead anchors",
883
+ "",
884
+ "Memories whose every `path_patterns` entry resolves to no file. Triage in `.ciel/memory/review-queue.md`.",
885
+ "",
886
+ ]
887
+ for mid in dead_anchors:
888
+ m = memories[mid]
889
+ patterns = ", ".join(m.get('path_patterns') or [])
890
+ lines.append(f"- `{mid}` - {m.get('title', '?')} (patterns: {patterns})")
891
+ lines.append("")
892
+
893
+ if intent_clusters:
894
+ lines += [
895
+ "## Intent clusters",
896
+ "",
897
+ f"Intents shared by >= {MIN_SUPPORT} memories - recurring topics.",
898
+ "",
899
+ ]
900
+ for intent, ids in sorted(intent_clusters.items(), key=lambda x: -len(x[1])):
901
+ lines.append(f"- `{intent}` ({len(ids)}): {', '.join(ids)}")
902
+ lines.append("")
903
+
904
+ if path_clusters:
905
+ lines += [
906
+ "## Path clusters",
907
+ "",
908
+ f"Paths referenced by >= {MIN_SUPPORT} memories - high-traffic surface.",
909
+ "",
910
+ ]
911
+ for path, ids in sorted(path_clusters.items(), key=lambda x: -len(x[1])):
912
+ lines.append(f"- `{path}` ({len(ids)}): {', '.join(ids)}")
913
+ lines.append("")
914
+
915
+ insights_md = base / 'INSIGHTS.md'
916
+ insights_md.write_text('\n'.join(lines), encoding='utf-8')
917
+
918
+ print(f"Insights written: {insights_json.relative_to(cwd)}, {insights_md.relative_to(cwd)}")
919
+ print(f" promotion_candidates: {len(promotion_candidates)}")
920
+ print(f" dead_anchors: {len(dead_anchors)}")
921
+ print(f" intent_clusters: {len(intent_clusters)}")
922
+ print(f" path_clusters: {len(path_clusters)}")
923
+
924
+
677
925
  # ─── CLI ────────────────────────────────────────────────────────────────────
678
926
 
679
927
 
@@ -710,6 +958,10 @@ def main():
710
958
  cp.add_argument('--cwd', default=None)
711
959
  cp.set_defaults(func=cmd_capture)
712
960
 
961
+ ap = sub.add_parser('analyze', help='Mine patterns + emit insights.json + INSIGHTS.md (read by ciel-audit Dim 10)')
962
+ ap.add_argument('--cwd', default=None)
963
+ ap.set_defaults(func=cmd_analyze)
964
+
713
965
  args = p.parse_args()
714
966
  args.func(args)
715
967
 
@@ -182,6 +182,47 @@ else
182
182
  fi
183
183
  ```
184
184
 
185
+ #### Dimension 10: Memory insight quality — penalty up to -10
186
+
187
+ Auto-runs the memory pattern analyzer (`python3 .claude/hooks/memory-engine.py analyze`) before scoring. The analyzer is read-only on the corpus — it scans `index.json`, computes pattern clusters and 7 health metrics, and writes `.ciel/memory/insights.json` + `.ciel/memory/INSIGHTS.md`. This dimension scores the *output* of that analysis: untreated promotion candidates, dead anchors, and structural drift in the memory corpus.
188
+
189
+ **Anti-double-counting with Dim 9.** Memories already counted as `stale` in Dim 9 must be excluded from the Dim 10 dead-anchor penalty: compute `dead_anchors_new = insights.dead_anchors - dim9_stale_ids` before scoring. A single rotted memory that is both stale (Dim 9) and a dead anchor (Dim 10) is one defect, not two — never charge -4 for what costs the user one consolidator pass.
190
+
191
+ - **Engine failed to produce insights**: `python3 .claude/hooks/memory-engine.py analyze` exited non-zero, OR `.ciel/memory/insights.json` was not written. The pattern surface is invisible — Dim 10 cannot grade. **-2**
192
+ - **Promotion candidates ignored**: `insights.json.promotion_candidates.length >= 3` AND `insights.json.health.promotion_ratio == 0` — the analyzer flagged hot episodes (>= 5 triggers) but the consolidator skill has never crystallized any of them into concepts. The corpus accumulates without distillation. **-3**
193
+ - **Dead anchors not triaged**: `insights.json.dead_anchors.length > 0` AND `.ciel/memory/review-queue.md` is missing or empty. Memories point to files that no longer exist; cued recall keeps firing on broken anchors until the user reviews. **-2**
194
+ - **Recursion drift starting**: `insights.json.health.max_generation_depth >= 3`. Synthesizer outputs are being re-derived from prior synthesizer outputs beyond depth 2, violating ADR-0001's "no self-feeding loops" principle. **-2**
195
+ - **Tag explosion**: `insights.json.health.tag_specificity > 0.9` AND total memories >= 10. Almost every tag is bespoke — clustering is impossible, recall degrades to per-memory matching. **-1**
196
+
197
+ Run these checks:
198
+ ```bash
199
+ # Auto-run analyzer (read-only on memories; writes only insights artifacts)
200
+ python3 .claude/hooks/memory-engine.py analyze 2>&1 || echo "analyze: FAILED"
201
+
202
+ # Read insights.json and emit per-check diagnostics
203
+ python3 -c "
204
+ import json, os, sys
205
+ try:
206
+ with open('.ciel/memory/insights.json') as f:
207
+ ins = json.load(f)
208
+ except FileNotFoundError:
209
+ print('insights: MISSING (engine failed?)')
210
+ sys.exit(0)
211
+ pc = ins.get('promotion_candidates', [])
212
+ da = ins.get('dead_anchors', [])
213
+ h = ins.get('health', {})
214
+ print(f'promotion_candidates: {len(pc)} (promotion_ratio={h.get(\"promotion_ratio\", 0)})')
215
+ print(f'dead_anchors: {len(da)}')
216
+ print(f'max_generation_depth: {h.get(\"max_generation_depth\", 0)}')
217
+ print(f'tag_specificity: {h.get(\"tag_specificity\", 0)}')
218
+ print(f'corpus: {ins.get(\"corpus_size\", {})}')
219
+ review = '.ciel/memory/review-queue.md'
220
+ print(f'review-queue: {\"present\" if os.path.exists(review) else \"absent\"}')
221
+ " 2>/dev/null || echo "memory insights check failed (no python3?)"
222
+ ```
223
+
224
+ The analyzer is **idempotent**: every audit run regenerates `insights.json` from the live corpus, so this dimension cannot be gamed by stale artifacts. Patches the audit recommends in this dimension should target either (a) running the consolidator skill to drain the promotion queue, or (b) populating `.ciel/memory/review-queue.md` to clear dead anchors.
225
+
185
226
  ---
186
227
 
187
228
  ### Scoring
@@ -257,6 +298,7 @@ Begin the output with the literal line `# Ciel Session Audit Report`. End with t
257
298
  | D7 — npm version | -<N> |
258
299
  | D8 — Platform health | -<N> |
259
300
  | D9 — Memory health | -<N> |
301
+ | D10 — Memory insight quality | -<N> |
260
302
  | **Total** | **-<N>** |
261
303
  | **Health Score** | **<N>/100** |
262
304
 
@@ -15,7 +15,7 @@ Usage: `/ciel-status [--check]`
15
15
  ```
16
16
  ## CIEL STATUS
17
17
 
18
- Version: v6.10.0
18
+ Version: v6.11.0
19
19
  Platform: Claude Code
20
20
  Config: .claude/settings.json — OK (4 hooks registered)
21
21
  Skills directory: skills/ — 43 skills loaded
@@ -44,7 +44,7 @@ Principe : **"Understand before generating. Verify before claiming done."**
44
44
  | 12 | ADR | Documenter decisions architecturales |
45
45
  | 13 | RELIRE | @ciel-critic MODE=RELIRE |
46
46
  | 14 | PROUVER | Preuve AVANT/APRES |
47
- | 15 | MEMOIRE | Sauver .ciel/map.json |
47
+ | 15 | MEMOIRE | Cued-recall: hooks capture interventions to .ciel/memory/episodes/ + index.json (see skill `memoire`, ADR-0001). |
48
48
  | 16 | META | 30s reflexion |
49
49
 
50
50
  ## Depth et dispatch
@@ -98,5 +98,6 @@ Pour les prompts non-anglais (francais, etc.), appliquer le matching semantique
98
98
  ## References
99
99
 
100
100
  - **Depth signals** → load `depth-classifier`
101
+ - **Memory (etape 15 MEMOIRE)** → load `memoire` (capture/recall) + `memoire-consolidator` (maintenance). Hooks `user-prompt-submit.sh` (capture trigger) + `session-start.sh` (recall injection). Engine `hooks/memory-engine.py`. Token budget by depth: Trivial 1K / Standard 3K / Critical 5K injected memory tokens. Design: `docs/adrs/0001-cued-recall-memory.md`.
101
102
  - **Utility skills** → load pr-opener, commit-writer, branch-setup, issue-creator, issue-closer
102
103
  - For full philosophy and guards, see `reference.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neikyun/ciel",
3
- "version": "6.10.0",
3
+ "version": "6.11.0",
4
4
  "description": "Ciel — Deep-reasoning pipeline for LLM-assisted development. OpenCode plugin + multi-platform CLI (OpenCode, Claude Code, more).",
5
5
  "main": "./dist/plugin/index.js",
6
6
  "types": "./dist/plugin/index.d.ts",