@neikyun/ciel 6.10.1 → 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
|
|
@@ -695,6 +696,232 @@ def cmd_capture(args):
|
|
|
695
696
|
print(f"Index rebuilt with memory: {mid}")
|
|
696
697
|
|
|
697
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
|
+
|
|
698
925
|
# ─── CLI ────────────────────────────────────────────────────────────────────
|
|
699
926
|
|
|
700
927
|
|
|
@@ -731,6 +958,10 @@ def main():
|
|
|
731
958
|
cp.add_argument('--cwd', default=None)
|
|
732
959
|
cp.set_defaults(func=cmd_capture)
|
|
733
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
|
+
|
|
734
965
|
args = p.parse_args()
|
|
735
966
|
args.func(args)
|
|
736
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
|
|
|
@@ -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 |
|
|
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.
|
|
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",
|