@jaguilar87/gaia 5.1.0 → 5.1.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/agents/gaia-orchestrator.md +4 -10
- package/bin/README.md +1 -1
- package/bin/cli/approvals.py +0 -144
- package/bin/cli/cleanup.py +1 -30
- package/bin/cli/context.py +379 -6
- package/bin/cli/doctor.py +1 -1
- package/bin/cli/history.py +18 -6
- package/bin/cli/install.py +4 -2
- package/bin/cli/memory.py +86 -10
- package/bin/cli/metrics.py +1216 -275
- package/bin/cli/query.py +89 -0
- package/bin/cli/scan.py +68 -2
- package/bin/cli/uninstall.py +9 -5
- package/bin/pre-publish-validate.js +7 -4
- package/gaia/project.py +49 -20
- package/gaia/store/reader.py +175 -11
- package/gaia/store/schema.sql +180 -1
- package/gaia/store/writer.py +1146 -165
- package/hooks/README.md +2 -2
- package/hooks/adapters/claude_code.py +6 -12
- package/hooks/elicitation_result.py +4 -9
- package/hooks/modules/agents/contract_validator.py +2 -1
- package/hooks/modules/agents/handoff_persister.py +5 -220
- package/hooks/modules/agents/task_info_builder.py +9 -2
- package/hooks/modules/agents/transcript_reader.py +41 -21
- package/hooks/modules/audit/logger.py +8 -3
- package/hooks/modules/context/context_injector.py +10 -3
- package/hooks/modules/core/__init__.py +5 -2
- package/hooks/modules/core/logging_setup.py +60 -0
- package/hooks/modules/core/paths.py +0 -12
- package/hooks/modules/security/approval_grants.py +24 -92
- package/hooks/modules/security/gaia_db_write_guard.py +7 -2
- package/hooks/modules/security/mutative_verbs.py +194 -11
- package/hooks/modules/session/pending_scanner.py +24 -222
- package/hooks/modules/session/session_manifest.py +13 -288
- package/hooks/post_compact.py +4 -9
- package/hooks/post_tool_use.py +4 -9
- package/hooks/pre_compact.py +5 -10
- package/hooks/pre_tool_use.py +4 -11
- package/hooks/session_end_hook.py +4 -9
- package/hooks/session_start.py +18 -21
- package/hooks/stop_hook.py +5 -10
- package/hooks/subagent_start.py +5 -10
- package/hooks/subagent_stop.py +9 -23
- package/hooks/task_completed.py +5 -10
- package/hooks/user_prompt_submit.py +4 -9
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v21_to_v22.sql +80 -0
- package/scripts/migrations/v22_to_v23.sql +20 -0
- package/scripts/migrations/v23_to_v24.sql +30 -0
- package/scripts/migrations/v24_to_v25.sql +77 -0
- package/scripts/migrations/v25_to_v26.sql +116 -0
- package/skills/README.md +8 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -27
- package/skills/agent-approval-protocol/reference.md +20 -12
- package/skills/agent-protocol/SKILL.md +1 -1
- package/skills/agent-protocol/examples.md +17 -9
- package/skills/agent-response/SKILL.md +1 -1
- package/skills/diagram-builder/GLOSSARY.md +77 -0
- package/skills/diagram-builder/SKILL.md +156 -0
- package/skills/diagram-builder/assets/README.md +45 -0
- package/skills/diagram-builder/assets/data/data.generated.js +77 -0
- package/skills/diagram-builder/assets/data/document.yaml +12 -0
- package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
- package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
- package/skills/diagram-builder/assets/engine/engine.js +863 -0
- package/skills/diagram-builder/assets/index.html +638 -0
- package/skills/diagram-builder/assets/package.json +15 -0
- package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
- package/skills/diagram-builder/reference.md +444 -0
- package/skills/execution/SKILL.md +3 -3
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/memory/SKILL.md +49 -0
- package/skills/orchestrator-present-approval/SKILL.md +90 -79
- package/skills/orchestrator-present-approval/reference.md +59 -42
- package/skills/orchestrator-present-approval/template.md +16 -14
- package/skills/pending-approvals/SKILL.md +80 -29
- package/skills/pending-approvals/reference.md +48 -60
- package/skills/security-tiers/SKILL.md +1 -1
- package/skills/subagent-request-approval/SKILL.md +65 -68
- package/skills/subagent-request-approval/reference.md +63 -63
- package/skills/visual-verify/SKILL.md +107 -0
- package/skills/visual-verify/scripts/screenshot.js +210 -0
- package/tools/scan/classify.py +584 -15
- package/tools/scan/migrate_workspace.py +9 -4
- package/tools/scan/store_populator.py +231 -2
- package/tools/scan/tests/conftest.py +31 -0
package/bin/cli/query.py
CHANGED
|
@@ -105,6 +105,57 @@ def _render_grouped_count(rows: list[dict], group_by: str | None) -> None:
|
|
|
105
105
|
print(f"{(str(r.get(group_by) or '')):<{key_w}} {r['count']}")
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _fmt_int(n) -> str:
|
|
109
|
+
return "-" if n is None else f"{int(n):,}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _render_metrics_agg(rows: list[dict], group_by: str | None) -> None:
|
|
113
|
+
"""Render aggregate_metrics() output: avg compliance + token sums per group."""
|
|
114
|
+
if not rows:
|
|
115
|
+
print("(no results)")
|
|
116
|
+
return
|
|
117
|
+
key = group_by or "group"
|
|
118
|
+
key_vals = [str(r.get(group_by) or "(all)") if group_by else "(all)" for r in rows]
|
|
119
|
+
key_w = max(len(key.upper()), max(len(v) for v in key_vals))
|
|
120
|
+
header = (f"{key.upper():<{key_w}} {'COUNT':>6} {'AVG_COMPL':>9} "
|
|
121
|
+
f"{'AVG_MS':>8} {'IN_TOK':>12} {'OUT_TOK':>12} {'CACHE_RD':>14}")
|
|
122
|
+
print(header)
|
|
123
|
+
print("-" * len(header))
|
|
124
|
+
for r, kv in zip(rows, key_vals):
|
|
125
|
+
avg_c = "-" if r.get("avg_compliance_score") is None else f"{r['avg_compliance_score']:.1f}"
|
|
126
|
+
avg_ms = _fmt_int(r.get("avg_duration_ms"))
|
|
127
|
+
print(
|
|
128
|
+
f"{kv:<{key_w}} {r['count']:>6} {avg_c:>9} {avg_ms:>8} "
|
|
129
|
+
f"{_fmt_int(r.get('input_tokens')):>12} "
|
|
130
|
+
f"{_fmt_int(r.get('output_tokens_real')):>12} "
|
|
131
|
+
f"{_fmt_int(r.get('cache_read_tokens')):>14}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _render_metrics_rows(rows: list[dict]) -> None:
|
|
136
|
+
"""Render per-episode --metrics rows (agent, compliance, tokens, model)."""
|
|
137
|
+
if not rows:
|
|
138
|
+
print("(no results)")
|
|
139
|
+
return
|
|
140
|
+
agent_w = max(len("AGENT"), max(len(r.get("agent") or "") for r in rows))
|
|
141
|
+
header = (f"{'TIMESTAMP':<19} {'AGENT':<{agent_w}} {'COMPL':>5} "
|
|
142
|
+
f"{'GR':>2} {'IN_TOK':>10} {'OUT_REAL':>10} MODEL")
|
|
143
|
+
print(header)
|
|
144
|
+
print("-" * len(header))
|
|
145
|
+
for r in rows:
|
|
146
|
+
raw = r.get("raw") or {}
|
|
147
|
+
ts = (r.get("timestamp") or "")[:19]
|
|
148
|
+
cs = raw.get("compliance_score")
|
|
149
|
+
cs_s = "-" if cs is None else str(cs)
|
|
150
|
+
grade = raw.get("compliance_grade") or "-"
|
|
151
|
+
print(
|
|
152
|
+
f"{ts:<19} {(r.get('agent') or ''):<{agent_w}} {cs_s:>5} "
|
|
153
|
+
f"{grade:>2} {_fmt_int(raw.get('input_tokens')):>10} "
|
|
154
|
+
f"{_fmt_int(raw.get('output_tokens_real')):>10} "
|
|
155
|
+
f"{raw.get('model_used') or '-'}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
108
159
|
# ---------------------------------------------------------------------------
|
|
109
160
|
# Entry point
|
|
110
161
|
# ---------------------------------------------------------------------------
|
|
@@ -114,6 +165,7 @@ def cmd_query(args) -> int:
|
|
|
114
165
|
from gaia.store.reader import (
|
|
115
166
|
cross_surface_query,
|
|
116
167
|
group_and_count,
|
|
168
|
+
aggregate_metrics,
|
|
117
169
|
_extract_text_needle,
|
|
118
170
|
_highlight_snippet,
|
|
119
171
|
_row_text_for_snippet,
|
|
@@ -127,6 +179,12 @@ def cmd_query(args) -> int:
|
|
|
127
179
|
group_by = getattr(args, "group_by", None)
|
|
128
180
|
do_count = bool(getattr(args, "count", False))
|
|
129
181
|
do_snippets = bool(getattr(args, "snippets", False))
|
|
182
|
+
do_metrics = bool(getattr(args, "metrics", False))
|
|
183
|
+
|
|
184
|
+
# --metrics projects context_metrics telemetry, which only lives on the
|
|
185
|
+
# episodes surface -- scope the query to it so the projection applies.
|
|
186
|
+
if do_metrics:
|
|
187
|
+
surface = "episodes"
|
|
130
188
|
|
|
131
189
|
try:
|
|
132
190
|
rows = cross_surface_query(
|
|
@@ -139,10 +197,32 @@ def cmd_query(args) -> int:
|
|
|
139
197
|
agent=getattr(args, "agent", None),
|
|
140
198
|
command_like=getattr(args, "command_like", None),
|
|
141
199
|
failed=getattr(args, "failed", False),
|
|
200
|
+
metrics=do_metrics,
|
|
142
201
|
)
|
|
143
202
|
except ValueError as exc:
|
|
144
203
|
return _err(str(exc), as_json=as_json)
|
|
145
204
|
|
|
205
|
+
# --metrics: project + optionally aggregate the per-turn telemetry.
|
|
206
|
+
if do_metrics:
|
|
207
|
+
if do_count or group_by:
|
|
208
|
+
try:
|
|
209
|
+
agg = aggregate_metrics(rows, group_by=group_by)
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
return _err(str(exc), as_json=as_json)
|
|
212
|
+
if as_json or fmt == "json":
|
|
213
|
+
print(json.dumps(agg, indent=2, default=str))
|
|
214
|
+
return 0
|
|
215
|
+
_render_metrics_agg(agg, group_by)
|
|
216
|
+
return 0
|
|
217
|
+
if as_json or fmt == "json":
|
|
218
|
+
print(json.dumps(rows, indent=2, default=str))
|
|
219
|
+
return 0
|
|
220
|
+
if fmt == "count":
|
|
221
|
+
print(len(rows))
|
|
222
|
+
return 0
|
|
223
|
+
_render_metrics_rows(rows)
|
|
224
|
+
return 0
|
|
225
|
+
|
|
146
226
|
# --snippets: rewrite the summary field with highlighted fragments.
|
|
147
227
|
# Applies only when there is a textual filter (command_like / type / agent).
|
|
148
228
|
if do_snippets:
|
|
@@ -189,6 +269,8 @@ _QUERY_EPILOG = """\
|
|
|
189
269
|
Examples:
|
|
190
270
|
gaia query --since=24h --failed
|
|
191
271
|
gaia query --since=7d --command-like='%git push%' --group-by=day
|
|
272
|
+
gaia query --metrics --since=7d --group-by=agent
|
|
273
|
+
gaia query --metrics --agent=developer --since=24h
|
|
192
274
|
"""
|
|
193
275
|
|
|
194
276
|
|
|
@@ -256,6 +338,13 @@ def register(subparsers) -> None:
|
|
|
256
338
|
help="Replace summary with [bracketed] fragments around the textual "
|
|
257
339
|
"filter. No-op without --command-like / --type / --agent.",
|
|
258
340
|
)
|
|
341
|
+
p.add_argument(
|
|
342
|
+
"--metrics", action="store_true", default=False,
|
|
343
|
+
help="Project per-turn telemetry from episodes.context_metrics "
|
|
344
|
+
"(compliance_score, token counts, duration_ms, tool/api calls, "
|
|
345
|
+
"model). Forces --surface=episodes. Combine with --group-by / "
|
|
346
|
+
"--count for avg-compliance + token-sum rollups per agent/day/type.",
|
|
347
|
+
)
|
|
259
348
|
p.add_argument(
|
|
260
349
|
"--format", default="table",
|
|
261
350
|
choices=("table", "json", "count"),
|
package/bin/cli/scan.py
CHANGED
|
@@ -68,9 +68,35 @@ def _render_human(report, *, dry_run: bool) -> None:
|
|
|
68
68
|
print(f"{prefix}projects:")
|
|
69
69
|
for p in report.projects:
|
|
70
70
|
applied = "applied" if p["applied"] else ("would-apply" if dry_run else "not-applied")
|
|
71
|
+
# Show the workspace -> proyecto (container) -> repo hierarchy plus
|
|
72
|
+
# the repo's own absolute path (M2-T4/T5). ``project`` (the DB
|
|
73
|
+
# storage slot) is shown only when it differs from the proyecto
|
|
74
|
+
# (i.e. when M1 collision-disambiguation renamed the slot).
|
|
75
|
+
container = p.get("container", p["project"])
|
|
76
|
+
slot = p["project"]
|
|
77
|
+
slot_note = f" slot={slot}" if slot != container else ""
|
|
71
78
|
print(
|
|
72
|
-
f" -
|
|
73
|
-
f"
|
|
79
|
+
f" - workspace={p['workspace']} project={container} "
|
|
80
|
+
f"repo={p['repo']}{slot_note} path={p.get('path')} [{applied}]"
|
|
81
|
+
)
|
|
82
|
+
# M3/T8 (AC-6): the stack fingerprint (facet rows) for this repo.
|
|
83
|
+
facets = p.get("facets") or []
|
|
84
|
+
if facets:
|
|
85
|
+
summary = ", ".join(
|
|
86
|
+
f"{f['scope']}:{f['key']}"
|
|
87
|
+
+ (f"={f['value']}" if f.get("value") else "")
|
|
88
|
+
for f in facets
|
|
89
|
+
)
|
|
90
|
+
print(f" facets: {summary}")
|
|
91
|
+
|
|
92
|
+
if report.warnings:
|
|
93
|
+
# M2-T6 (AC-5): would-be collisions are surfaced explicitly, never
|
|
94
|
+
# silently merged/renamed.
|
|
95
|
+
print(f"{prefix}WARNING -- repo collisions (would-be silent data loss):")
|
|
96
|
+
for w in report.warnings:
|
|
97
|
+
print(
|
|
98
|
+
f" ! repo={w['repo']} requested_project={w['requested_project']} "
|
|
99
|
+
f"-> assigned={w['assigned_project']} path={w.get('path')}"
|
|
74
100
|
)
|
|
75
101
|
|
|
76
102
|
if report.ambiguities:
|
|
@@ -83,6 +109,46 @@ def _render_human(report, *, dry_run: bool) -> None:
|
|
|
83
109
|
for e in report.errors:
|
|
84
110
|
print(f" - repo={e['repo']} --workspace={e['W']}: {e['suggestion']}")
|
|
85
111
|
|
|
112
|
+
# SV2: cross-DB detection blocks (alerts only -- the human adjudicates).
|
|
113
|
+
if report.move_candidates:
|
|
114
|
+
print(f"{prefix}MOVE CANDIDATES (remote match, human adjudicates):")
|
|
115
|
+
for m in report.move_candidates:
|
|
116
|
+
src, dst = m["from"], m["to"]
|
|
117
|
+
print(
|
|
118
|
+
f" ~ {src['workspace']}/{src['project']} ({src['path']}) -> "
|
|
119
|
+
f"{dst['workspace']}/{dst['project']} ({dst['path']}) "
|
|
120
|
+
f"remote={m['remote']} confidence={m['confidence']}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if report.vanished:
|
|
124
|
+
print(f"{prefix}VANISHED:")
|
|
125
|
+
for v in report.vanished:
|
|
126
|
+
since = v["missing_since"] if v["missing_since"] else ("pending" if dry_run else None)
|
|
127
|
+
print(
|
|
128
|
+
f" - {v['workspace']}/{v['project']} path={v['path']} "
|
|
129
|
+
f"remote={v['remote']} missing_since={since}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if report.rename_candidates:
|
|
133
|
+
print(f"{prefix}RENAME CANDIDATES (folder basename != stored name):")
|
|
134
|
+
for r in report.rename_candidates:
|
|
135
|
+
print(
|
|
136
|
+
f" ? {r['workspace']}/{r['project']} path={r['path']} "
|
|
137
|
+
f"expected_name={r['expected_name']}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if report.orphaned_autored:
|
|
141
|
+
print(f"{prefix}ORPHANED CONTEXT (vanished project carried authored content):")
|
|
142
|
+
for o in report.orphaned_autored:
|
|
143
|
+
print(
|
|
144
|
+
f" ! {o['workspace']}/{o['project']}: {o['description']!r} "
|
|
145
|
+
f"(workspace holds {o['memory_count']} memory note(s), "
|
|
146
|
+
f"{o['brief_count']} open brief(s))"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if report.diff:
|
|
150
|
+
print(f"{prefix}diff : {report.diff}")
|
|
151
|
+
|
|
86
152
|
if not dry_run:
|
|
87
153
|
print(f"{prefix}marked_missing : {report.marked_missing}")
|
|
88
154
|
|
package/bin/cli/uninstall.py
CHANGED
|
@@ -311,11 +311,15 @@ def _print_human(result: dict, *, preuninstall: bool, dry_run: bool) -> None:
|
|
|
311
311
|
for rel in symlinks.get("removed", []):
|
|
312
312
|
verb = "Would remove symlink" if dry_run else "Removed symlink"
|
|
313
313
|
print(f" {verb}: {rel}")
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
314
|
+
if retention:
|
|
315
|
+
# These are routine log-hygiene prunes applied on the way out, NOT
|
|
316
|
+
# part of the uninstall teardown -- the header keeps that distinct.
|
|
317
|
+
print("\n Retention policy (routine log hygiene, not part of uninstall):")
|
|
318
|
+
for action in retention:
|
|
319
|
+
verb = "Would prune" if dry_run else "Pruned"
|
|
320
|
+
path = action.get("path", "?")
|
|
321
|
+
label = action.get("label", "")
|
|
322
|
+
print(f" {verb}: {path} ({label})")
|
|
319
323
|
|
|
320
324
|
print()
|
|
321
325
|
snapshot = result.get("snapshot") or {}
|
|
@@ -208,6 +208,11 @@ class PrePublishValidator {
|
|
|
208
208
|
validateNodeModules() {
|
|
209
209
|
this.log('Step 4: Validating node_modules installation...', 'info');
|
|
210
210
|
|
|
211
|
+
if (this.dryRun) {
|
|
212
|
+
this.log('[DRY RUN] skipping node_modules version validation', 'info');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
211
216
|
if (!fs.existsSync(NODE_MODULES_INSTALL)) {
|
|
212
217
|
this.log(`⚠️ ${PKG_NAME} not found in node_modules, installing...`, 'warning');
|
|
213
218
|
this.reinstallNodeModules();
|
|
@@ -254,7 +259,7 @@ class PrePublishValidator {
|
|
|
254
259
|
|
|
255
260
|
const criticalFiles = [
|
|
256
261
|
'package.json',
|
|
257
|
-
'bin/
|
|
262
|
+
'bin/cli/scan.py',
|
|
258
263
|
'tools/context/context_provider.py',
|
|
259
264
|
'hooks/pre_tool_use.py',
|
|
260
265
|
];
|
|
@@ -527,9 +532,7 @@ class PrePublishValidator {
|
|
|
527
532
|
try {
|
|
528
533
|
// Test 1: Validate JSON files
|
|
529
534
|
this.log('Test 1: Validating JSON configuration files...', 'info');
|
|
530
|
-
const jsonFiles = [
|
|
531
|
-
'config/clarification_rules.json'
|
|
532
|
-
];
|
|
535
|
+
const jsonFiles = [];
|
|
533
536
|
|
|
534
537
|
jsonFiles.forEach(file => {
|
|
535
538
|
const filePath = path.join(baseDir, file);
|
package/gaia/project.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
"""
|
|
2
2
|
gaia.project -- Workspace identity model and consolidate operations.
|
|
3
3
|
|
|
4
|
-
Workspace identity is
|
|
5
|
-
|
|
4
|
+
Workspace identity (``current()``) is PATH-based (M2-T7, AC-9): it is derived
|
|
5
|
+
from the repository's location on disk, not from the git remote, so it is
|
|
6
|
+
stable regardless of remote state and converges across vantage points (root,
|
|
7
|
+
subdirectory, linked worktree of the same repo all resolve identically).
|
|
6
8
|
|
|
7
|
-
Three-level
|
|
8
|
-
1. Git
|
|
9
|
-
2. Directory name in lowercase (when
|
|
10
|
-
3. Literal ``"global"`` (when neither git
|
|
9
|
+
Three-level resolution:
|
|
10
|
+
1. Git repo -> basename of the repository ROOT (via git-common-dir)
|
|
11
|
+
2. Directory name in lowercase (when not a git repo)
|
|
12
|
+
3. Literal ``"global"`` (when neither a git repo nor an identifiable name)
|
|
13
|
+
|
|
14
|
+
The remote-derived canonical identity (``host/owner/repo``) is still produced
|
|
15
|
+
by :func:`_normalize_remote`, but it is captured separately in the
|
|
16
|
+
``workspaces.identity`` column by the store writer, which reads the remote
|
|
17
|
+
directly -- it no longer flows through ``current()``.
|
|
11
18
|
|
|
12
19
|
Patterns inspired by engram (https://github.com/koaning/engram), MIT License.
|
|
13
20
|
No runtime dependency on engram.
|
|
@@ -142,12 +149,28 @@ def git_common_dir(cwd: Path | str | None = None) -> str | None:
|
|
|
142
149
|
|
|
143
150
|
|
|
144
151
|
def current(cwd: Path | str | None = None) -> str:
|
|
145
|
-
"""Return the workspace identity for the given directory.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
"""Return the workspace identity for the given directory -- PATH-BASED.
|
|
153
|
+
|
|
154
|
+
Resolution is PATH-first (M2-T7, AC-9), NOT git-remote-first. The identity
|
|
155
|
+
is derived from the repository's own location on disk, so it is stable
|
|
156
|
+
regardless of remote state and converges across vantage points:
|
|
157
|
+
|
|
158
|
+
1. Git repo -> the basename of the REPOSITORY ROOT, resolved via
|
|
159
|
+
``git rev-parse --git-common-dir`` (:func:`git_common_dir`). Because
|
|
160
|
+
the common dir is identical from the repo root, any nested
|
|
161
|
+
subdirectory, and any linked worktree, two different working-directory
|
|
162
|
+
paths of the SAME repo always resolve to the SAME identity -- and the
|
|
163
|
+
git remote never decides the identity ahead of the path.
|
|
164
|
+
2. Not a git repo -> the basename of ``cwd`` in lowercase.
|
|
165
|
+
3. Literal ``"global"`` (no git, no identifiable directory name).
|
|
166
|
+
|
|
167
|
+
The git remote URL is deliberately NOT consulted here. The remote-derived
|
|
168
|
+
canonical identity still exists, but it is captured separately in the
|
|
169
|
+
``workspaces.identity`` column by the store writer
|
|
170
|
+
(``gaia/store/writer.py::_resolve_identity``), which reads the remote
|
|
171
|
+
directly -- so ``current()`` answering "which workspace am I in" stays
|
|
172
|
+
coherent with the path-anchored scan model (workspace -> proyecto -> repo)
|
|
173
|
+
while the remote identity is preserved where it belongs.
|
|
151
174
|
|
|
152
175
|
Args:
|
|
153
176
|
cwd: Directory to resolve identity for. Defaults to ``Path.cwd()``.
|
|
@@ -162,14 +185,20 @@ def current(cwd: Path | str | None = None) -> str:
|
|
|
162
185
|
# If resolve fails (broken symlink, permission), fall back to global
|
|
163
186
|
return "global"
|
|
164
187
|
|
|
165
|
-
# Level 1:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
188
|
+
# Level 1 (PATH-based): repository root basename, vantage-independent.
|
|
189
|
+
# git_common_dir collapses root / subdir / worktree of the same repo to
|
|
190
|
+
# one path, so the identity does not diverge across vantage points and
|
|
191
|
+
# does not depend on the remote.
|
|
192
|
+
common = git_common_dir(target)
|
|
193
|
+
if common:
|
|
194
|
+
# The common dir (e.g. ``/x/repo/.git``) sits inside the repo root;
|
|
195
|
+
# its parent is the repository root directory.
|
|
196
|
+
repo_root = Path(common).parent
|
|
197
|
+
name = repo_root.name.lower().strip()
|
|
198
|
+
if name:
|
|
199
|
+
return name
|
|
200
|
+
|
|
201
|
+
# Level 2: directory basename (not a git repo).
|
|
173
202
|
name = target.name.lower().strip()
|
|
174
203
|
if name:
|
|
175
204
|
return name
|
package/gaia/store/reader.py
CHANGED
|
@@ -130,6 +130,12 @@ def _query_memory(
|
|
|
130
130
|
where.append("type = ?")
|
|
131
131
|
params.append(type_filter)
|
|
132
132
|
|
|
133
|
+
# scan-v2 SV3: tombstoned rows (deleted_at non-NULL) are soft-deleted and
|
|
134
|
+
# must never surface in a query. delete_memory() stamps deleted_at instead
|
|
135
|
+
# of physically removing the row, so this filter is what keeps a tombstone
|
|
136
|
+
# invisible to `gaia query` / cross_surface_query.
|
|
137
|
+
where.append("deleted_at IS NULL")
|
|
138
|
+
|
|
133
139
|
sql = (
|
|
134
140
|
"SELECT workspace, name, type, description, body, origin_session_id, "
|
|
135
141
|
"updated_at "
|
|
@@ -164,6 +170,49 @@ def _query_memory(
|
|
|
164
170
|
return out
|
|
165
171
|
|
|
166
172
|
|
|
173
|
+
# Metric fields projected out of episodes.context_metrics when metrics=True.
|
|
174
|
+
# The workflow recorder nests these under the "metrics" key of the JSON blob
|
|
175
|
+
# (T4 episodic-workflow-to-db); a few older migrated rows store them at the
|
|
176
|
+
# top level, so each extraction COALESCEs the nested path with the flat one.
|
|
177
|
+
# json1 is compiled into SQLite by default -- no schema migration required.
|
|
178
|
+
_METRIC_JSON_FIELDS = (
|
|
179
|
+
# (output column, json leaf path under $.metrics / $)
|
|
180
|
+
("compliance_score", "compliance_score.total"),
|
|
181
|
+
("compliance_grade", "compliance_score.grade"),
|
|
182
|
+
("input_tokens", "input_tokens"),
|
|
183
|
+
("cache_creation_tokens", "cache_creation_tokens"),
|
|
184
|
+
("cache_read_tokens", "cache_read_tokens"),
|
|
185
|
+
("output_tokens_real", "output_tokens_real"),
|
|
186
|
+
("duration_ms", "duration_ms"),
|
|
187
|
+
("tool_call_count", "tool_call_count"),
|
|
188
|
+
("api_call_count", "api_call_count"),
|
|
189
|
+
("model_used", "model_used"),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Numeric metric fields summed by aggregate_metrics.
|
|
193
|
+
_METRIC_SUM_FIELDS = (
|
|
194
|
+
"input_tokens",
|
|
195
|
+
"cache_creation_tokens",
|
|
196
|
+
"cache_read_tokens",
|
|
197
|
+
"output_tokens_real",
|
|
198
|
+
"tool_call_count",
|
|
199
|
+
"api_call_count",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _metric_select_columns() -> str:
|
|
204
|
+
"""Build the json_extract projection clause for --metrics queries."""
|
|
205
|
+
cols = []
|
|
206
|
+
for out_col, leaf in _METRIC_JSON_FIELDS:
|
|
207
|
+
cols.append(
|
|
208
|
+
f"COALESCE("
|
|
209
|
+
f"json_extract(context_metrics, '$.metrics.{leaf}'), "
|
|
210
|
+
f"json_extract(context_metrics, '$.{leaf}')"
|
|
211
|
+
f") AS {out_col}"
|
|
212
|
+
)
|
|
213
|
+
return ", ".join(cols)
|
|
214
|
+
|
|
215
|
+
|
|
167
216
|
def _query_episodes(
|
|
168
217
|
con: sqlite3.Connection,
|
|
169
218
|
*,
|
|
@@ -174,6 +223,7 @@ def _query_episodes(
|
|
|
174
223
|
agent_filter: str | None,
|
|
175
224
|
failed: bool,
|
|
176
225
|
limit: int,
|
|
226
|
+
metrics: bool = False,
|
|
177
227
|
) -> list[dict]:
|
|
178
228
|
where = []
|
|
179
229
|
params: list[Any] = []
|
|
@@ -198,12 +248,18 @@ def _query_episodes(
|
|
|
198
248
|
"(plan_status IN ('BLOCKED', 'NEEDS_INPUT') "
|
|
199
249
|
"OR (outcome IS NOT NULL AND outcome NOT IN ('success', '')))"
|
|
200
250
|
)
|
|
251
|
+
if metrics:
|
|
252
|
+
# Only rows that actually carry a metrics blob can project fields.
|
|
253
|
+
where.append("context_metrics IS NOT NULL")
|
|
201
254
|
|
|
202
|
-
|
|
203
|
-
"
|
|
204
|
-
"type, title, plan_status, outcome, exit_code, duration_seconds
|
|
205
|
-
"FROM episodes"
|
|
255
|
+
base_cols = (
|
|
256
|
+
"episode_id, workspace, timestamp, session_id, task_id, agent, "
|
|
257
|
+
"type, title, plan_status, outcome, exit_code, duration_seconds"
|
|
206
258
|
)
|
|
259
|
+
if metrics:
|
|
260
|
+
sql = f"SELECT {base_cols}, {_metric_select_columns()} FROM episodes"
|
|
261
|
+
else:
|
|
262
|
+
sql = f"SELECT {base_cols} FROM episodes"
|
|
207
263
|
if where:
|
|
208
264
|
sql += " WHERE " + " AND ".join(where)
|
|
209
265
|
sql += " ORDER BY timestamp DESC LIMIT ?"
|
|
@@ -217,13 +273,30 @@ def _query_episodes(
|
|
|
217
273
|
ps = d.get("plan_status") or ""
|
|
218
274
|
oc = d.get("outcome") or ""
|
|
219
275
|
bits = [title or d.get("episode_id", "")]
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
276
|
+
if metrics:
|
|
277
|
+
# Metrics view: lead with the projected metric fields.
|
|
278
|
+
mtail = []
|
|
279
|
+
cs = d.get("compliance_score")
|
|
280
|
+
if cs is not None:
|
|
281
|
+
grade = d.get("compliance_grade") or ""
|
|
282
|
+
mtail.append(f"compliance={cs}{('/' + grade) if grade else ''}")
|
|
283
|
+
it = d.get("input_tokens")
|
|
284
|
+
ot = d.get("output_tokens_real")
|
|
285
|
+
if it is not None or ot is not None:
|
|
286
|
+
mtail.append(f"tok in={it if it is not None else '?'} out_real={ot if ot is not None else '?'}")
|
|
287
|
+
model = d.get("model_used")
|
|
288
|
+
if model:
|
|
289
|
+
mtail.append(f"model={model}")
|
|
290
|
+
if mtail:
|
|
291
|
+
bits.append("[" + ", ".join(mtail) + "]")
|
|
292
|
+
else:
|
|
293
|
+
tail = []
|
|
294
|
+
if ps:
|
|
295
|
+
tail.append(f"plan_status={ps}")
|
|
296
|
+
if oc and oc != ps:
|
|
297
|
+
tail.append(f"outcome={oc}")
|
|
298
|
+
if tail:
|
|
299
|
+
bits.append("[" + ", ".join(tail) + "]")
|
|
227
300
|
out.append({
|
|
228
301
|
"surface": "episodes",
|
|
229
302
|
"timestamp": d.get("timestamp") or "",
|
|
@@ -435,6 +508,87 @@ def group_and_count(
|
|
|
435
508
|
return out
|
|
436
509
|
|
|
437
510
|
|
|
511
|
+
def aggregate_metrics(
|
|
512
|
+
rows: list[dict],
|
|
513
|
+
*,
|
|
514
|
+
group_by: str | None,
|
|
515
|
+
) -> list[dict]:
|
|
516
|
+
"""Roll up ``--metrics`` episode rows into per-group summaries.
|
|
517
|
+
|
|
518
|
+
Each output bucket carries ``count``, ``avg_compliance_score`` (over rows
|
|
519
|
+
that have one), ``avg_duration_ms``, and the sum of every field in
|
|
520
|
+
``_METRIC_SUM_FIELDS``. When ``group_by`` is ``None`` a single ``(all)``
|
|
521
|
+
bucket is returned. Reuses the same ``VALID_GROUP_BY`` keys as
|
|
522
|
+
:func:`group_and_count`; metric values are read from each row's ``raw``.
|
|
523
|
+
Group order is descending count, ties broken alphabetically.
|
|
524
|
+
"""
|
|
525
|
+
if group_by and group_by not in VALID_GROUP_BY:
|
|
526
|
+
raise ValueError(
|
|
527
|
+
f"invalid group_by '{group_by}'; must be one of {list(VALID_GROUP_BY)}"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
buckets: dict[str, dict] = {}
|
|
531
|
+
for r in rows:
|
|
532
|
+
raw = r.get("raw") or {}
|
|
533
|
+
if group_by is None:
|
|
534
|
+
key = "(all)"
|
|
535
|
+
elif group_by == "day":
|
|
536
|
+
key = _truncate_day(r.get("timestamp"))
|
|
537
|
+
elif group_by == "surface":
|
|
538
|
+
key = r.get("surface") or ""
|
|
539
|
+
else:
|
|
540
|
+
key = r.get(group_by) or raw.get(group_by) or ""
|
|
541
|
+
|
|
542
|
+
b = buckets.get(key)
|
|
543
|
+
if b is None:
|
|
544
|
+
b = {
|
|
545
|
+
"count": 0,
|
|
546
|
+
"compliance_sum": 0.0,
|
|
547
|
+
"compliance_n": 0,
|
|
548
|
+
"duration_sum": 0.0,
|
|
549
|
+
"duration_n": 0,
|
|
550
|
+
}
|
|
551
|
+
for f in _METRIC_SUM_FIELDS:
|
|
552
|
+
b[f] = 0
|
|
553
|
+
buckets[key] = b
|
|
554
|
+
|
|
555
|
+
b["count"] += 1
|
|
556
|
+
cs = raw.get("compliance_score")
|
|
557
|
+
if isinstance(cs, (int, float)):
|
|
558
|
+
b["compliance_sum"] += cs
|
|
559
|
+
b["compliance_n"] += 1
|
|
560
|
+
dm = raw.get("duration_ms")
|
|
561
|
+
if isinstance(dm, (int, float)):
|
|
562
|
+
b["duration_sum"] += dm
|
|
563
|
+
b["duration_n"] += 1
|
|
564
|
+
for f in _METRIC_SUM_FIELDS:
|
|
565
|
+
v = raw.get(f)
|
|
566
|
+
if isinstance(v, (int, float)):
|
|
567
|
+
b[f] += v
|
|
568
|
+
|
|
569
|
+
out = []
|
|
570
|
+
for key, b in buckets.items():
|
|
571
|
+
row = {
|
|
572
|
+
"count": b["count"],
|
|
573
|
+
"avg_compliance_score": (
|
|
574
|
+
round(b["compliance_sum"] / b["compliance_n"], 1)
|
|
575
|
+
if b["compliance_n"] else None
|
|
576
|
+
),
|
|
577
|
+
"avg_duration_ms": (
|
|
578
|
+
round(b["duration_sum"] / b["duration_n"])
|
|
579
|
+
if b["duration_n"] else None
|
|
580
|
+
),
|
|
581
|
+
}
|
|
582
|
+
for f in _METRIC_SUM_FIELDS:
|
|
583
|
+
row[f] = b[f]
|
|
584
|
+
if group_by:
|
|
585
|
+
row[group_by] = key
|
|
586
|
+
out.append(row)
|
|
587
|
+
|
|
588
|
+
out.sort(key=lambda d: (-d["count"], str(d.get(group_by) if group_by else "")))
|
|
589
|
+
return out
|
|
590
|
+
|
|
591
|
+
|
|
438
592
|
def cross_surface_query(
|
|
439
593
|
*,
|
|
440
594
|
surface: str = "all",
|
|
@@ -446,6 +600,7 @@ def cross_surface_query(
|
|
|
446
600
|
agent: str | None = None,
|
|
447
601
|
command_like: str | None = None,
|
|
448
602
|
failed: bool = False,
|
|
603
|
+
metrics: bool = False,
|
|
449
604
|
db_path: Path | None = None,
|
|
450
605
|
) -> list[dict]:
|
|
451
606
|
"""Run a cross-surface analytical query against the substrate.
|
|
@@ -475,6 +630,13 @@ def cross_surface_query(
|
|
|
475
630
|
outcome != success; harness_events: severity=error or
|
|
476
631
|
result starting with fail/error). Memory surface
|
|
477
632
|
has no notion of "failed" -- ignored there.
|
|
633
|
+
metrics: When True, the episodes surface projects the per-turn
|
|
634
|
+
telemetry stored in ``episodes.context_metrics``
|
|
635
|
+
(compliance_score, token counts, duration_ms,
|
|
636
|
+
tool/api call counts, model_used) via json_extract.
|
|
637
|
+
Only affects the episodes surface. Pair with
|
|
638
|
+
:func:`aggregate_metrics` for per-agent/-surface
|
|
639
|
+
rollups.
|
|
478
640
|
db_path: Optional explicit substrate path (tests).
|
|
479
641
|
|
|
480
642
|
Returns:
|
|
@@ -511,6 +673,7 @@ def cross_surface_query(
|
|
|
511
673
|
agent_filter=agent,
|
|
512
674
|
failed=failed,
|
|
513
675
|
limit=last,
|
|
676
|
+
metrics=metrics,
|
|
514
677
|
))
|
|
515
678
|
if surface in ("harness_events", "all"):
|
|
516
679
|
results.extend(_query_harness_events(
|
|
@@ -539,6 +702,7 @@ __all__ = [
|
|
|
539
702
|
"parse_when",
|
|
540
703
|
"cross_surface_query",
|
|
541
704
|
"group_and_count",
|
|
705
|
+
"aggregate_metrics",
|
|
542
706
|
"_highlight_snippet",
|
|
543
707
|
"_extract_text_needle",
|
|
544
708
|
"_row_text_for_snippet",
|