@0dai-dev/cli 4.3.6 → 4.3.7
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/README.md +12 -11
- package/bin/0dai.js +127 -30
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +506 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +209 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +38 -10
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +934 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +95 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
package/scripts/build-tui.js
CHANGED
|
@@ -66,7 +66,20 @@ esbuild
|
|
|
66
66
|
logLevel: "warning",
|
|
67
67
|
minify: false,
|
|
68
68
|
sourcemap: false,
|
|
69
|
-
|
|
69
|
+
// ESM output for node: define require/__filename/__dirname so esbuild's
|
|
70
|
+
// __require shim resolves real node builtins instead of throwing
|
|
71
|
+
// "Dynamic require of \"assert\" is not supported" at import time (#4359).
|
|
72
|
+
banner: {
|
|
73
|
+
js: [
|
|
74
|
+
"// @generated — do not edit. Source: lib/tui/src/",
|
|
75
|
+
"import { createRequire as __odaiCreateRequire } from 'node:module';",
|
|
76
|
+
"import { fileURLToPath as __odaiFileURLToPath } from 'node:url';",
|
|
77
|
+
"import { dirname as __odaiDirname } from 'node:path';",
|
|
78
|
+
"const require = __odaiCreateRequire(import.meta.url);",
|
|
79
|
+
"const __filename = __odaiFileURLToPath(import.meta.url);",
|
|
80
|
+
"const __dirname = __odaiDirname(__filename);",
|
|
81
|
+
].join("\n"),
|
|
82
|
+
},
|
|
70
83
|
})
|
|
71
84
|
.then(() => {
|
|
72
85
|
console.log("[tui-build] wrote " + path.relative(ROOT, OUT));
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""0dai Harvest — transform experience events into scored candidates.
|
|
3
|
+
|
|
4
|
+
Reads fallback events from ai/experience/events/*.json and *.jsonl,
|
|
5
|
+
groups by (tool, task_type) patterns, and creates candidate markdown files
|
|
6
|
+
in ai/experience/candidates/ for review and potential promotion.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 scripts/harvest_experience.py [--target <path>] [--explain]
|
|
10
|
+
0dai harvest --target . [--explain]
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import pathlib
|
|
17
|
+
import time
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
|
|
20
|
+
REQUIRED_EVENT_FIELDS = (
|
|
21
|
+
"schema",
|
|
22
|
+
"event_id",
|
|
23
|
+
"timestamp",
|
|
24
|
+
"event_type",
|
|
25
|
+
"tool",
|
|
26
|
+
"task_type",
|
|
27
|
+
"summary",
|
|
28
|
+
"paths",
|
|
29
|
+
"ci_passed",
|
|
30
|
+
"source",
|
|
31
|
+
)
|
|
32
|
+
STRING_FIELDS = (
|
|
33
|
+
"event_id",
|
|
34
|
+
"timestamp",
|
|
35
|
+
"event_type",
|
|
36
|
+
"tool",
|
|
37
|
+
"task_type",
|
|
38
|
+
"summary",
|
|
39
|
+
"source",
|
|
40
|
+
)
|
|
41
|
+
IGNORED_EVENT_FILENAMES = {"example.jsonl"}
|
|
42
|
+
HIGH_CONFIDENCE_EVENTS = 3
|
|
43
|
+
MEDIUM_CONFIDENCE_EVENTS = 2
|
|
44
|
+
SUCCESS_RESULTS = {"success", "partial", "passed", "ok", "done"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rel(target: pathlib.Path, path: pathlib.Path) -> str:
|
|
48
|
+
try:
|
|
49
|
+
return str(path.relative_to(target))
|
|
50
|
+
except ValueError:
|
|
51
|
+
return str(path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _diagnostic(
|
|
55
|
+
path: str,
|
|
56
|
+
status: str,
|
|
57
|
+
reason: str,
|
|
58
|
+
*,
|
|
59
|
+
category: str = "discovery",
|
|
60
|
+
) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"path": path,
|
|
63
|
+
"status": status,
|
|
64
|
+
"reason": reason,
|
|
65
|
+
"category": category,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_event(event: object) -> list[str]:
|
|
70
|
+
"""Return deterministic fallback-contract validation issues."""
|
|
71
|
+
if not isinstance(event, dict):
|
|
72
|
+
return ["event must be a JSON object"]
|
|
73
|
+
|
|
74
|
+
issues: list[str] = []
|
|
75
|
+
missing = [
|
|
76
|
+
field
|
|
77
|
+
for field in REQUIRED_EVENT_FIELDS
|
|
78
|
+
if field not in event or event.get(field) in (None, "")
|
|
79
|
+
]
|
|
80
|
+
for field in missing:
|
|
81
|
+
issues.append(f"missing required field: {field}")
|
|
82
|
+
|
|
83
|
+
if "schema" not in missing and event.get("schema") != 1:
|
|
84
|
+
issues.append("schema must be 1")
|
|
85
|
+
|
|
86
|
+
for field in STRING_FIELDS:
|
|
87
|
+
if field not in missing and not isinstance(event.get(field), str):
|
|
88
|
+
issues.append(f"{field} must be a string")
|
|
89
|
+
|
|
90
|
+
if "paths" not in missing:
|
|
91
|
+
paths = event.get("paths")
|
|
92
|
+
if not isinstance(paths, list) or any(not isinstance(item, str) for item in paths):
|
|
93
|
+
issues.append("paths must be an array of strings")
|
|
94
|
+
|
|
95
|
+
if "ci_passed" not in missing and not isinstance(event.get("ci_passed"), bool):
|
|
96
|
+
issues.append("ci_passed must be a boolean")
|
|
97
|
+
|
|
98
|
+
return issues
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _string_or_empty(value: object) -> str:
|
|
102
|
+
if value is None:
|
|
103
|
+
return ""
|
|
104
|
+
text = str(value).strip()
|
|
105
|
+
return text
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _first_string(*values: object) -> str:
|
|
109
|
+
for value in values:
|
|
110
|
+
text = _string_or_empty(value)
|
|
111
|
+
if text:
|
|
112
|
+
return text
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _string_list(value: object) -> list[str]:
|
|
117
|
+
if isinstance(value, str):
|
|
118
|
+
return [value] if value else []
|
|
119
|
+
if not isinstance(value, list):
|
|
120
|
+
return []
|
|
121
|
+
return [item for item in value if isinstance(item, str) and item]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _legacy_ci_passed(event: dict, task: dict, context: dict, quality: dict) -> bool:
|
|
125
|
+
for value in (
|
|
126
|
+
event.get("ci_passed"),
|
|
127
|
+
context.get("tests_passed"),
|
|
128
|
+
task.get("tests_passed"),
|
|
129
|
+
quality.get("acceptance_criteria_met"),
|
|
130
|
+
):
|
|
131
|
+
if isinstance(value, bool):
|
|
132
|
+
return value
|
|
133
|
+
result = _first_string(task.get("result"), event.get("result"), event.get("status"))
|
|
134
|
+
return result.lower() in SUCCESS_RESULTS
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _looks_like_structured_experience(event: dict) -> bool:
|
|
138
|
+
if "schema" in event:
|
|
139
|
+
return False
|
|
140
|
+
task = event.get("task")
|
|
141
|
+
if isinstance(task, dict) and (
|
|
142
|
+
"agent" in event
|
|
143
|
+
or "model" in event
|
|
144
|
+
or "project_id" in event
|
|
145
|
+
or "session_id" in event
|
|
146
|
+
or "context" in event
|
|
147
|
+
or "quality" in event
|
|
148
|
+
):
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _normalize_structured_experience(event: object) -> tuple[object, str | None]:
|
|
154
|
+
"""Adapt experience_pipeline JSONL records to the harvest fallback schema.
|
|
155
|
+
|
|
156
|
+
`experience_pipeline.py` writes structured events with `agent` and nested
|
|
157
|
+
`task` blocks. Older harvest versions treated those as invalid fallback
|
|
158
|
+
records because they do not carry the MCP fallback fields directly.
|
|
159
|
+
"""
|
|
160
|
+
if not isinstance(event, dict) or not _looks_like_structured_experience(event):
|
|
161
|
+
return event, None
|
|
162
|
+
|
|
163
|
+
task = event.get("task") if isinstance(event.get("task"), dict) else {}
|
|
164
|
+
context = event.get("context") if isinstance(event.get("context"), dict) else {}
|
|
165
|
+
quality = event.get("quality") if isinstance(event.get("quality"), dict) else {}
|
|
166
|
+
|
|
167
|
+
event_type = _first_string(event.get("event_type"), "task_completed")
|
|
168
|
+
tool = _first_string(event.get("tool"), event.get("agent"), task.get("agent"), "unknown")
|
|
169
|
+
task_type = _first_string(
|
|
170
|
+
event.get("task_type"),
|
|
171
|
+
task.get("task_type"),
|
|
172
|
+
task.get("type"),
|
|
173
|
+
"unknown",
|
|
174
|
+
)
|
|
175
|
+
summary = _first_string(
|
|
176
|
+
event.get("summary"),
|
|
177
|
+
task.get("goal"),
|
|
178
|
+
event.get("goal"),
|
|
179
|
+
event.get("title"),
|
|
180
|
+
f"{event_type} event from {tool}",
|
|
181
|
+
)
|
|
182
|
+
paths = (
|
|
183
|
+
_string_list(event.get("paths"))
|
|
184
|
+
or _string_list(task.get("paths"))
|
|
185
|
+
or _string_list(task.get("files"))
|
|
186
|
+
or _string_list(context.get("paths"))
|
|
187
|
+
or _string_list(context.get("files"))
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
normalized = {
|
|
191
|
+
"schema": 1,
|
|
192
|
+
"event_id": _first_string(event.get("event_id"), event.get("id")),
|
|
193
|
+
"timestamp": _first_string(event.get("timestamp"), event.get("ts")),
|
|
194
|
+
"event_type": event_type,
|
|
195
|
+
"tool": tool,
|
|
196
|
+
"task_type": task_type,
|
|
197
|
+
"summary": summary,
|
|
198
|
+
"paths": paths,
|
|
199
|
+
"ci_passed": _legacy_ci_passed(event, task, context, quality),
|
|
200
|
+
"source": _first_string(event.get("source"), "experience-pipeline-compat"),
|
|
201
|
+
}
|
|
202
|
+
return normalized, "compatible structured experience event (normalized to harvest schema)"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _append_event_or_diagnostic(
|
|
206
|
+
event: object,
|
|
207
|
+
rel_path: str,
|
|
208
|
+
events: list[dict],
|
|
209
|
+
diagnostics: list[dict],
|
|
210
|
+
) -> None:
|
|
211
|
+
normalized, compatibility_reason = _normalize_structured_experience(event)
|
|
212
|
+
issues = _validate_event(normalized)
|
|
213
|
+
if issues:
|
|
214
|
+
diagnostics.append(
|
|
215
|
+
_diagnostic(
|
|
216
|
+
rel_path,
|
|
217
|
+
"skipped",
|
|
218
|
+
"; ".join(issues),
|
|
219
|
+
category="validation",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
events.append(normalized)
|
|
225
|
+
diagnostics.append(
|
|
226
|
+
_diagnostic(
|
|
227
|
+
rel_path,
|
|
228
|
+
"kept",
|
|
229
|
+
compatibility_reason or "valid experience event",
|
|
230
|
+
category="compatibility" if compatibility_reason else "discovery",
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _discover_events(target: pathlib.Path) -> tuple[list[dict], list[dict], int]:
|
|
236
|
+
"""Walk ai/experience/events + ai/experience/outbox; return valid events plus explain records.
|
|
237
|
+
|
|
238
|
+
Note: `0dai task run` writes events to `ai/experience/outbox/` while
|
|
239
|
+
`record_experience` MCP tool writes to `ai/experience/events/`. Both are
|
|
240
|
+
legitimate event sources; harvest scans both so the smoke-test pipeline
|
|
241
|
+
(init → task run → harvest → candidate) works end-to-end. Mirrors the
|
|
242
|
+
pattern in `scripts/score_experience.py:86` which reads both directories.
|
|
243
|
+
"""
|
|
244
|
+
experience_root = target / "ai" / "experience"
|
|
245
|
+
event_dirs = [
|
|
246
|
+
experience_root / "events",
|
|
247
|
+
experience_root / "outbox",
|
|
248
|
+
]
|
|
249
|
+
existing_dirs = [d for d in event_dirs if d.is_dir()]
|
|
250
|
+
if not existing_dirs:
|
|
251
|
+
return (
|
|
252
|
+
[],
|
|
253
|
+
[
|
|
254
|
+
_diagnostic(
|
|
255
|
+
"ai/experience/events/",
|
|
256
|
+
"skipped",
|
|
257
|
+
"directory missing (and outbox/ also missing)",
|
|
258
|
+
)
|
|
259
|
+
],
|
|
260
|
+
0,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
events: list[dict] = []
|
|
264
|
+
diagnostics: list[dict] = []
|
|
265
|
+
walked: set[str] = set()
|
|
266
|
+
|
|
267
|
+
iter_paths = sorted(p for d in existing_dirs for p in d.iterdir())
|
|
268
|
+
for path in iter_paths:
|
|
269
|
+
rel_path = _rel(target, path)
|
|
270
|
+
walked.add(rel_path)
|
|
271
|
+
|
|
272
|
+
if path.name in IGNORED_EVENT_FILENAMES:
|
|
273
|
+
diagnostics.append(
|
|
274
|
+
_diagnostic(rel_path, "skipped", "template example file")
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
if path.is_dir():
|
|
278
|
+
diagnostics.append(_diagnostic(rel_path, "skipped", "directory"))
|
|
279
|
+
continue
|
|
280
|
+
if path.suffix not in {".json", ".jsonl"}:
|
|
281
|
+
diagnostics.append(
|
|
282
|
+
_diagnostic(rel_path, "skipped", "unsupported extension")
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
raw = path.read_text("utf-8")
|
|
288
|
+
except OSError as exc:
|
|
289
|
+
diagnostics.append(
|
|
290
|
+
_diagnostic(rel_path, "skipped", f"unreadable: {exc}", category="io")
|
|
291
|
+
)
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
if path.suffix == ".json":
|
|
295
|
+
try:
|
|
296
|
+
event = json.loads(raw)
|
|
297
|
+
except json.JSONDecodeError as exc:
|
|
298
|
+
diagnostics.append(
|
|
299
|
+
_diagnostic(
|
|
300
|
+
rel_path,
|
|
301
|
+
"skipped",
|
|
302
|
+
f"invalid JSON: {exc.msg}",
|
|
303
|
+
category="parse",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
continue
|
|
307
|
+
_append_event_or_diagnostic(event, rel_path, events, diagnostics)
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
nonempty_lines = 0
|
|
311
|
+
for idx, raw_line in enumerate(raw.splitlines(), start=1):
|
|
312
|
+
line = raw_line.strip()
|
|
313
|
+
if not line:
|
|
314
|
+
continue
|
|
315
|
+
nonempty_lines += 1
|
|
316
|
+
line_path = f"{rel_path}:{idx}"
|
|
317
|
+
try:
|
|
318
|
+
event = json.loads(line)
|
|
319
|
+
except json.JSONDecodeError as exc:
|
|
320
|
+
diagnostics.append(
|
|
321
|
+
_diagnostic(
|
|
322
|
+
line_path,
|
|
323
|
+
"skipped",
|
|
324
|
+
f"invalid JSON: {exc.msg}",
|
|
325
|
+
category="parse",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
continue
|
|
329
|
+
_append_event_or_diagnostic(event, line_path, events, diagnostics)
|
|
330
|
+
if nonempty_lines == 0:
|
|
331
|
+
diagnostics.append(_diagnostic(rel_path, "skipped", "empty JSONL file"))
|
|
332
|
+
|
|
333
|
+
return events, diagnostics, len(walked)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _load_events(target: pathlib.Path) -> list[dict]:
|
|
337
|
+
"""Load all valid experience events (JSON files + JSONL lines)."""
|
|
338
|
+
events, _diagnostics, _walked = _discover_events(target)
|
|
339
|
+
return events
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _group_events(events: list[dict]) -> dict[str, list[dict]]:
|
|
343
|
+
"""Group events by (tool, task_type) pattern for candidate creation."""
|
|
344
|
+
groups: dict[str, list[dict]] = defaultdict(list)
|
|
345
|
+
for event in events:
|
|
346
|
+
tool = event.get("tool", "unknown")
|
|
347
|
+
task_type = event.get("task_type", "unknown")
|
|
348
|
+
key = f"{tool}-{task_type}"
|
|
349
|
+
groups[key].append(event)
|
|
350
|
+
return dict(groups)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _create_candidate(
|
|
354
|
+
key: str, events: list[dict], candidates_dir: pathlib.Path, *, dry_run: bool = False
|
|
355
|
+
) -> pathlib.Path | None:
|
|
356
|
+
"""Create a candidate markdown file from grouped events."""
|
|
357
|
+
if len(events) < 1:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
tool = events[0].get("tool", "unknown")
|
|
361
|
+
task_type = events[0].get("task_type", "unknown")
|
|
362
|
+
summaries = [e.get("summary", "") for e in events if e.get("summary")]
|
|
363
|
+
paths_seen: set[str] = set()
|
|
364
|
+
for e in events:
|
|
365
|
+
for p in e.get("paths", []):
|
|
366
|
+
paths_seen.add(p)
|
|
367
|
+
ci_passed = sum(1 for e in events if e.get("ci_passed", False))
|
|
368
|
+
event_ids = [e.get("event_id", "") for e in events if e.get("event_id")]
|
|
369
|
+
|
|
370
|
+
candidate_id = f"candidate.{key}"
|
|
371
|
+
filename = f"{candidate_id}.md"
|
|
372
|
+
dest = candidates_dir / filename
|
|
373
|
+
|
|
374
|
+
# Don't overwrite existing candidates — append new sources
|
|
375
|
+
if dest.is_file():
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
today = time.strftime("%Y-%m-%d", time.gmtime())
|
|
379
|
+
scope_paths = sorted(paths_seen)[:10]
|
|
380
|
+
|
|
381
|
+
confidence = (
|
|
382
|
+
"high"
|
|
383
|
+
if len(events) >= HIGH_CONFIDENCE_EVENTS
|
|
384
|
+
else "medium" if len(events) >= MEDIUM_CONFIDENCE_EVENTS else "low"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
content = f"""---
|
|
388
|
+
id: {candidate_id}
|
|
389
|
+
type: skill
|
|
390
|
+
status: candidate
|
|
391
|
+
tool: {tool}
|
|
392
|
+
task_type: {task_type}
|
|
393
|
+
confidence: {confidence}
|
|
394
|
+
sources: [{', '.join(event_ids[:10])}]
|
|
395
|
+
scope_paths: {json.dumps(scope_paths)}
|
|
396
|
+
created: {today}
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
# Candidate: {tool} — {task_type}
|
|
400
|
+
|
|
401
|
+
## Pattern
|
|
402
|
+
|
|
403
|
+
Agent `{tool}` performed `{task_type}` tasks {len(events)} time(s).
|
|
404
|
+
CI passed: {ci_passed}/{len(events)}.
|
|
405
|
+
|
|
406
|
+
## Evidence
|
|
407
|
+
|
|
408
|
+
{chr(10).join(f'- {s[:200]}' for s in summaries[:5])}
|
|
409
|
+
|
|
410
|
+
## Affected Files
|
|
411
|
+
|
|
412
|
+
{chr(10).join(f'- `{p}`' for p in scope_paths[:10])}
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
if not dry_run:
|
|
416
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
dest.write_text(content, "utf-8")
|
|
418
|
+
return dest
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def harvest(target: pathlib.Path, *, dry_run: bool = False) -> dict:
|
|
422
|
+
"""Main harvest: events → candidates."""
|
|
423
|
+
events, diagnostics, files_walked = _discover_events(target)
|
|
424
|
+
events_rejected = _count_rejections(diagnostics)
|
|
425
|
+
if not events:
|
|
426
|
+
return {
|
|
427
|
+
"harvested": 0,
|
|
428
|
+
"events_read": 0,
|
|
429
|
+
"events_rejected": events_rejected,
|
|
430
|
+
"files_walked": files_walked,
|
|
431
|
+
"candidates_created": [],
|
|
432
|
+
"diagnostics": diagnostics,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
groups = _group_events(events)
|
|
436
|
+
candidates_dir = target / "ai" / "experience" / "candidates"
|
|
437
|
+
created: list[str] = []
|
|
438
|
+
|
|
439
|
+
for key, group_events in groups.items():
|
|
440
|
+
path = _create_candidate(key, group_events, candidates_dir, dry_run=dry_run)
|
|
441
|
+
if path:
|
|
442
|
+
created.append(str(path.relative_to(target)))
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
"harvested": len(created),
|
|
446
|
+
"events_read": len(events),
|
|
447
|
+
"events_rejected": events_rejected,
|
|
448
|
+
"files_walked": files_walked,
|
|
449
|
+
"groups": len(groups),
|
|
450
|
+
"candidates_created": created,
|
|
451
|
+
"diagnostics": diagnostics,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _count_rejections(diagnostics: list[dict]) -> int:
|
|
456
|
+
return sum(1 for item in diagnostics if item.get("category") in {"parse", "validation", "io"})
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _print_explain(diagnostics: list[dict]) -> None:
|
|
460
|
+
for item in diagnostics:
|
|
461
|
+
print(
|
|
462
|
+
f"[harvest:explain] {item['path']} "
|
|
463
|
+
f"{item['status']}: {item['reason']}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _print_rejections(diagnostics: list[dict], *, limit: int = 5) -> None:
|
|
468
|
+
rejected = [
|
|
469
|
+
item
|
|
470
|
+
for item in diagnostics
|
|
471
|
+
if item.get("category") in {"parse", "validation", "io"}
|
|
472
|
+
]
|
|
473
|
+
if not rejected:
|
|
474
|
+
return
|
|
475
|
+
print(f"[harvest] skipped {len(rejected)} invalid event(s):")
|
|
476
|
+
for item in rejected[:limit]:
|
|
477
|
+
print(f" {item['path']}: {item['reason']}")
|
|
478
|
+
if len(rejected) > limit:
|
|
479
|
+
print(" run with --explain for the full file list")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def main(argv: list[str] | None = None) -> int:
|
|
483
|
+
parser = argparse.ArgumentParser(description="Harvest fallback experience events.")
|
|
484
|
+
parser.add_argument("--target", default=".")
|
|
485
|
+
parser.add_argument(
|
|
486
|
+
"--explain",
|
|
487
|
+
action="store_true",
|
|
488
|
+
help="List every walked file/line and why it was kept or skipped.",
|
|
489
|
+
)
|
|
490
|
+
parser.add_argument("--dry-run", action="store_true", help="Report candidates without writing them.")
|
|
491
|
+
parser.add_argument("--json", action="store_true", help="Emit machine-readable harvest results.")
|
|
492
|
+
args = parser.parse_args(argv)
|
|
493
|
+
|
|
494
|
+
target = pathlib.Path(args.target).resolve()
|
|
495
|
+
result = harvest(target, dry_run=args.dry_run)
|
|
496
|
+
|
|
497
|
+
if args.json:
|
|
498
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
499
|
+
return 0
|
|
500
|
+
|
|
501
|
+
if args.explain:
|
|
502
|
+
_print_explain(result["diagnostics"])
|
|
503
|
+
|
|
504
|
+
_print_rejections(result["diagnostics"])
|
|
505
|
+
|
|
506
|
+
if result["harvested"] == 0:
|
|
507
|
+
if result["events_read"] == 0:
|
|
508
|
+
print("[harvest] no valid events found in ai/experience/events/")
|
|
509
|
+
print(" Record events with: 0dai MCP tool record_experience")
|
|
510
|
+
else:
|
|
511
|
+
print(f"[harvest] {result['events_read']} event(s) read, no new candidates to create")
|
|
512
|
+
print(" Existing candidates already cover these patterns")
|
|
513
|
+
else:
|
|
514
|
+
for path in result["candidates_created"]:
|
|
515
|
+
prefix = "[harvest] would create" if args.dry_run else "[harvest] created"
|
|
516
|
+
print(f"{prefix} {path}")
|
|
517
|
+
print(f"\n{result['harvested']} candidate(s) created from {result['events_read']} event(s)")
|
|
518
|
+
print("Next: run 'python3 scripts/score_experience.py' to score and promote")
|
|
519
|
+
return 0
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if __name__ == "__main__":
|
|
523
|
+
raise SystemExit(main())
|
package/scripts/postinstall.js
CHANGED
|
@@ -12,17 +12,23 @@ try {
|
|
|
12
12
|
|
|
13
13
|
const pkg = require("../package.json");
|
|
14
14
|
const v = pkg.version || "?";
|
|
15
|
+
const innerWidth = 41;
|
|
16
|
+
const horizontal = "\u2500".repeat(innerWidth);
|
|
17
|
+
const boxLine = (text = "") => {
|
|
18
|
+
const clipped = String(text).slice(0, innerWidth);
|
|
19
|
+
return " \u2502" + clipped.padEnd(innerWidth) + "\u2502";
|
|
20
|
+
};
|
|
15
21
|
|
|
16
22
|
console.log("");
|
|
17
|
-
console.log(" \u250c\
|
|
18
|
-
console.log("
|
|
19
|
-
console.log(
|
|
20
|
-
console.log("
|
|
21
|
-
console.log("
|
|
22
|
-
console.log("
|
|
23
|
-
console.log(
|
|
24
|
-
console.log("
|
|
25
|
-
console.log(" \u2514\
|
|
23
|
+
console.log(" \u250c" + horizontal + "\u2510");
|
|
24
|
+
console.log(boxLine(" 0dai installed successfully! v" + v));
|
|
25
|
+
console.log(boxLine());
|
|
26
|
+
console.log(boxLine(" Get started:"));
|
|
27
|
+
console.log(boxLine(" cd your-project"));
|
|
28
|
+
console.log(boxLine(" 0dai init"));
|
|
29
|
+
console.log(boxLine());
|
|
30
|
+
console.log(boxLine(" Docs: https://0dai.dev/docs"));
|
|
31
|
+
console.log(" \u2514" + horizontal + "\u2518");
|
|
26
32
|
console.log("");
|
|
27
33
|
} catch (_) {
|
|
28
34
|
// Never fail
|