loki-mode 7.16.0 → 7.17.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.
@@ -0,0 +1,446 @@
1
+ """Shareable team-asset bundler for Loki Mode (R8).
2
+
3
+ Bundles a team's invested, reusable assets into a portable, REDACTED tarball
4
+ that can be re-imported into another project or a fresh clone. This is the
5
+ "individual setup -> org lock-in" mechanism: setup compounds into shared value.
6
+
7
+ Scope (what is and is NOT a duplicate):
8
+ - `loki export` (autonomy/loki cmd_export) produces a SESSION SNAPSHOT
9
+ (json/md/csv/timeline) of one .loki/ run. It is per-run, not portable
10
+ across teams, and not redacted for sharing.
11
+ - `loki assets` (this module) produces a PORTABLE, REDACTED team-asset
12
+ tarball: cross-project + project memory/learnings, the agent registry,
13
+ PRD templates, council config, and (optionally) the R5 wiki. Different
14
+ scope, different artifact. It REUSES the proof_redact chokepoint rather
15
+ than introducing a second redactor.
16
+
17
+ Redaction:
18
+ - Single chokepoint: autonomy/lib/proof_redact.py. JSON/JSONL go through
19
+ redact_tree(); Markdown/text go through redact_value(). set_context() is
20
+ called first so absolute home/repo paths collapse to ~ / . before the
21
+ bundle leaves the machine. No second redactor is defined here.
22
+
23
+ Asset map (source-of-truth -> bundle path -> restore root):
24
+ - ~/.loki/learnings/*.jsonl -> learnings/*.jsonl -> $HOME/.loki/learnings
25
+ - <project>/.loki/memory/** -> memory/** -> <project>/.loki/memory
26
+ - <repo>/agents/types.json -> agents/types.json -> <repo>/agents
27
+ - <repo>/templates/*.md -> templates/*.md -> <repo>/templates
28
+ - <project>/.loki/council/*.json -> council/*.json -> <project>/.loki/council
29
+ - <project>/.loki/wiki/* -> wiki/* -> <project>/.loki/wiki (opt-in)
30
+
31
+ The export and import callers pass DIFFERENT repo_root values deliberately
32
+ (see autonomy/loki cmd_assets): export reads agents/templates from the loki
33
+ install ($SKILL_DIR, where team edits live and are read at runtime); import
34
+ writes them under the caller's cwd (the target clone root), never back into
35
+ the install. This module just honors the repo_root it is handed; the
36
+ asymmetry is enforced by the bash caller.
37
+
38
+ Honest limitations (stated, not hidden):
39
+ - There is no separate per-user "custom agent" store today. "Custom agents"
40
+ == the agents/types.json registry (41 shipped types plus any team edits).
41
+ Bundling captures team edits/additions but also re-ships the defaults.
42
+ - Likewise templates/ and agents/types.json are repo-shipped defaults; a
43
+ bundle from a fresh checkout carries the stock set. That is expected: the
44
+ value is captured team DELTAS travelling with the stock baseline.
45
+ - agents/templates restore relative to the import caller's cwd. loki reads
46
+ them from its install dir at runtime, so a global-install user must copy
47
+ the restored agents/ + templates/ into their install. memory, learnings,
48
+ council, and wiki restore to $HOME/.loki or <project>/.loki and take
49
+ effect immediately.
50
+ """
51
+
52
+ import io
53
+ import json
54
+ import os
55
+ import sys
56
+ import tarfile
57
+
58
+ # proof_redact lives next to this file (autonomy/lib/). Import it as the single
59
+ # redaction chokepoint -- do NOT reimplement any redaction here.
60
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
61
+ import proof_redact # noqa: E402
62
+
63
+ SCHEMA_VERSION = "1.0"
64
+
65
+ # Asset categories included by default. "wiki" is opt-in (can be large / noisy).
66
+ DEFAULT_CATEGORIES = ["learnings", "memory", "agents", "templates", "council"]
67
+ OPTIONAL_CATEGORIES = ["wiki"]
68
+
69
+
70
+ def _redact_json_text(text):
71
+ """Redact a JSON document string. Returns (redacted_text, count).
72
+
73
+ Falls back to plain-string redaction when the text is not valid JSON.
74
+ """
75
+ try:
76
+ obj = json.loads(text)
77
+ except (ValueError, TypeError):
78
+ return proof_redact.redact_value(text), 0
79
+ red, n = proof_redact.redact_tree(obj)
80
+ return json.dumps(red, indent=2, ensure_ascii=False), n
81
+
82
+
83
+ def _redact_jsonl_text(text):
84
+ """Redact a JSONL document line-by-line. Returns (redacted_text, count).
85
+
86
+ Each non-blank line is parsed, redacted via redact_tree, reserialized.
87
+ Lines that are not valid JSON are redacted as raw strings (best effort).
88
+ """
89
+ out_lines = []
90
+ total = 0
91
+ for line in text.splitlines():
92
+ if not line.strip():
93
+ out_lines.append(line)
94
+ continue
95
+ try:
96
+ obj = json.loads(line)
97
+ red, n = proof_redact.redact_tree(obj)
98
+ out_lines.append(json.dumps(red, ensure_ascii=False))
99
+ total += n
100
+ except (ValueError, TypeError):
101
+ out_lines.append(proof_redact.redact_value(line))
102
+ return "\n".join(out_lines) + ("\n" if text.endswith("\n") else ""), total
103
+
104
+
105
+ def _redact_text(text):
106
+ """Redact a Markdown / plain-text string. Returns (redacted_text, count).
107
+
108
+ redact_value returns only the string, so we cannot get a per-call count
109
+ here; we report 0 and rely on the aggregate manifest count from structured
110
+ files. The redaction itself still happens.
111
+ """
112
+ return proof_redact.redact_value(text), 0
113
+
114
+
115
+ def _redact_for_path(rel_path, text):
116
+ """Dispatch redaction by file extension. Returns (redacted_text, count)."""
117
+ lower = rel_path.lower()
118
+ if lower.endswith(".jsonl"):
119
+ return _redact_jsonl_text(text)
120
+ if lower.endswith(".json"):
121
+ return _redact_json_text(text)
122
+ # .md, .txt, and everything else: plain string redaction.
123
+ return _redact_text(text)
124
+
125
+
126
+ def _iter_category_sources(category, home, repo_root, project_dir):
127
+ """Yield (abs_source_path, bundle_rel_path) pairs for a category.
128
+
129
+ bundle_rel_path is always relative to the bundle's category dir.
130
+ """
131
+ if category == "learnings":
132
+ src_dir = os.path.join(home, ".loki", "learnings")
133
+ if os.path.isdir(src_dir):
134
+ for name in sorted(os.listdir(src_dir)):
135
+ p = os.path.join(src_dir, name)
136
+ if os.path.isfile(p):
137
+ yield p, os.path.join("learnings", name)
138
+ elif category == "memory":
139
+ src_dir = os.path.join(project_dir, ".loki", "memory")
140
+ for abs_p, rel_p in _walk_files(src_dir, "memory"):
141
+ yield abs_p, rel_p
142
+ elif category == "agents":
143
+ p = os.path.join(repo_root, "agents", "types.json")
144
+ if os.path.isfile(p):
145
+ yield p, os.path.join("agents", "types.json")
146
+ elif category == "templates":
147
+ src_dir = os.path.join(repo_root, "templates")
148
+ if os.path.isdir(src_dir):
149
+ for name in sorted(os.listdir(src_dir)):
150
+ p = os.path.join(src_dir, name)
151
+ if os.path.isfile(p) and name.endswith(".md"):
152
+ yield p, os.path.join("templates", name)
153
+ elif category == "council":
154
+ src_dir = os.path.join(project_dir, ".loki", "council")
155
+ if os.path.isdir(src_dir):
156
+ for name in sorted(os.listdir(src_dir)):
157
+ p = os.path.join(src_dir, name)
158
+ if os.path.isfile(p) and name.endswith(".json"):
159
+ yield p, os.path.join("council", name)
160
+ elif category == "wiki":
161
+ src_dir = os.path.join(project_dir, ".loki", "wiki")
162
+ for abs_p, rel_p in _walk_files(src_dir, "wiki"):
163
+ yield abs_p, rel_p
164
+
165
+
166
+ def _walk_files(src_dir, bundle_prefix):
167
+ """Walk src_dir, yielding (abs_path, bundle_rel_path) for every file."""
168
+ if not os.path.isdir(src_dir):
169
+ return
170
+ for root, _dirs, files in os.walk(src_dir):
171
+ for name in sorted(files):
172
+ abs_p = os.path.join(root, name)
173
+ rel = os.path.relpath(abs_p, src_dir)
174
+ yield abs_p, os.path.join(bundle_prefix, rel)
175
+
176
+
177
+ def export_bundle(out_path, home, repo_root, project_dir, categories):
178
+ """Build a redacted tarball at out_path. Returns a manifest dict.
179
+
180
+ All string content is redacted through proof_redact before it is written
181
+ into the tarball; the original files on disk are never modified.
182
+ """
183
+ proof_redact.reset_context()
184
+ proof_redact.set_context(home=home, repo_root=repo_root)
185
+
186
+ manifest = {
187
+ "schema_version": SCHEMA_VERSION,
188
+ "redaction_rules_version": proof_redact.RULES_VERSION,
189
+ "categories": [],
190
+ "files": [],
191
+ "redactions": 0,
192
+ }
193
+
194
+ staged = [] # (bundle_rel_path, redacted_bytes)
195
+ for category in categories:
196
+ had_any = False
197
+ for abs_src, bundle_rel in _iter_category_sources(
198
+ category, home, repo_root, project_dir
199
+ ):
200
+ try:
201
+ with open(abs_src, "r", encoding="utf-8", errors="replace") as f:
202
+ text = f.read()
203
+ except OSError:
204
+ continue
205
+ red_text, count = _redact_for_path(bundle_rel, text)
206
+ staged.append((bundle_rel, red_text.encode("utf-8")))
207
+ manifest["files"].append(bundle_rel)
208
+ manifest["redactions"] += count
209
+ had_any = True
210
+ if had_any:
211
+ manifest["categories"].append(category)
212
+
213
+ # Write tarball: manifest.json first, then redacted assets.
214
+ parent = os.path.dirname(os.path.abspath(out_path))
215
+ if parent and not os.path.isdir(parent):
216
+ os.makedirs(parent, exist_ok=True)
217
+
218
+ manifest_bytes = json.dumps(manifest, indent=2, ensure_ascii=False).encode(
219
+ "utf-8"
220
+ )
221
+ with tarfile.open(out_path, "w:gz") as tar:
222
+ _add_bytes(tar, "manifest.json", manifest_bytes)
223
+ for bundle_rel, data in staged:
224
+ _add_bytes(tar, os.path.join("assets", bundle_rel), data)
225
+
226
+ return manifest
227
+
228
+
229
+ def _add_bytes(tar, arcname, data):
230
+ """Add an in-memory bytes blob to a tarfile under arcname."""
231
+ info = tarfile.TarInfo(name=arcname)
232
+ info.size = len(data)
233
+ info.mode = 0o644
234
+ tar.addfile(info, io.BytesIO(data))
235
+
236
+
237
+ # Restore-root mapping for each bundle category. $HOME-scoped vs project-scoped
238
+ # is deliberate (see module docstring asset map).
239
+ def _restore_root(category, home, target_repo, target_project):
240
+ if category == "learnings":
241
+ return os.path.join(home, ".loki", "learnings")
242
+ if category == "memory":
243
+ return os.path.join(target_project, ".loki", "memory")
244
+ if category == "agents":
245
+ return os.path.join(target_repo, "agents")
246
+ if category == "templates":
247
+ return os.path.join(target_repo, "templates")
248
+ if category == "council":
249
+ return os.path.join(target_project, ".loki", "council")
250
+ if category == "wiki":
251
+ return os.path.join(target_project, ".loki", "wiki")
252
+ return None
253
+
254
+
255
+ def _category_of(bundle_rel):
256
+ """Top-level bundle dir == category name."""
257
+ return bundle_rel.split("/", 1)[0]
258
+
259
+
260
+ def _safe_extract_member(member):
261
+ """Reject path-traversal members (zip-slip / tar-slip)."""
262
+ name = member.name
263
+ if name.startswith("/") or ".." in name.split("/"):
264
+ return False
265
+ return True
266
+
267
+
268
+ def _merge_jsonl(existing_text, incoming_text):
269
+ """Append-with-dedupe two JSONL bodies. Returns merged text.
270
+
271
+ Dedupe key is the canonicalized line (json round-trip with sorted keys when
272
+ parseable). Preserves existing order, then appends new unique lines.
273
+ """
274
+ def _norm_lines(text):
275
+ norm = []
276
+ for line in text.splitlines():
277
+ if not line.strip():
278
+ continue
279
+ try:
280
+ norm.append(
281
+ json.dumps(json.loads(line), sort_keys=True, ensure_ascii=False)
282
+ )
283
+ except (ValueError, TypeError):
284
+ norm.append(line.strip())
285
+ return norm
286
+
287
+ existing = _norm_lines(existing_text)
288
+ seen = set(existing)
289
+ merged = list(existing)
290
+ for line in _norm_lines(incoming_text):
291
+ if line not in seen:
292
+ seen.add(line)
293
+ merged.append(line)
294
+ return "\n".join(merged) + "\n" if merged else ""
295
+
296
+
297
+ def import_bundle(bundle_path, home, target_repo, target_project, merge=True):
298
+ """Restore a bundle's assets to their mapped roots. Returns a result dict.
299
+
300
+ merge=True: JSONL learnings are append-with-dedupe; all other files are
301
+ overwritten. merge=False: every file is overwritten.
302
+ """
303
+ result = {"restored": [], "skipped": [], "merged": [], "schema_version": None}
304
+
305
+ with tarfile.open(bundle_path, "r:gz") as tar:
306
+ # Read manifest.
307
+ try:
308
+ mf = tar.extractfile("manifest.json")
309
+ manifest = json.load(mf) if mf else {}
310
+ except KeyError:
311
+ manifest = {}
312
+ result["schema_version"] = manifest.get("schema_version")
313
+
314
+ for member in tar.getmembers():
315
+ if not member.isfile():
316
+ continue
317
+ if not _safe_extract_member(member):
318
+ result["skipped"].append(member.name)
319
+ continue
320
+ name = member.name
321
+ if name == "manifest.json":
322
+ continue
323
+ if not name.startswith("assets/"):
324
+ continue
325
+ bundle_rel = name[len("assets/"):]
326
+ category = _category_of(bundle_rel)
327
+ root = _restore_root(category, home, target_repo, target_project)
328
+ if root is None:
329
+ result["skipped"].append(name)
330
+ continue
331
+ sub_rel = (
332
+ bundle_rel.split("/", 1)[1] if "/" in bundle_rel else bundle_rel
333
+ )
334
+ # SECURITY: a malicious bundle can carry a member like
335
+ # "assets/council//abs/path" whose sub_rel is absolute (or contains
336
+ # ".."). os.path.join(root, abs) silently DISCARDS root, so validate
337
+ # the FINAL resolved destination is inside the restore root --
338
+ # member-name string checks alone are insufficient. Reject any escape;
339
+ # this is an untrusted shared bundle (R8's threat model).
340
+ root_real = os.path.realpath(root)
341
+ dest_real = os.path.realpath(os.path.join(root, sub_rel))
342
+ if dest_real != root_real and not dest_real.startswith(root_real + os.sep):
343
+ result["skipped"].append(name)
344
+ continue
345
+ dest = dest_real
346
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
347
+
348
+ fobj = tar.extractfile(member)
349
+ if fobj is None:
350
+ continue
351
+ data = fobj.read().decode("utf-8", errors="replace")
352
+
353
+ if merge and dest.lower().endswith(".jsonl") and os.path.isfile(dest):
354
+ with open(dest, "r", encoding="utf-8", errors="replace") as f:
355
+ existing = f.read()
356
+ merged = _merge_jsonl(existing, data)
357
+ with open(dest, "w", encoding="utf-8") as f:
358
+ f.write(merged)
359
+ result["merged"].append(dest)
360
+ else:
361
+ with open(dest, "w", encoding="utf-8") as f:
362
+ f.write(data)
363
+ result["restored"].append(dest)
364
+
365
+ return result
366
+
367
+
368
+ def inspect_bundle(bundle_path):
369
+ """Return the manifest dict from a bundle without extracting assets."""
370
+ with tarfile.open(bundle_path, "r:gz") as tar:
371
+ try:
372
+ mf = tar.extractfile("manifest.json")
373
+ return json.load(mf) if mf else {}
374
+ except KeyError:
375
+ return {}
376
+
377
+
378
+ def _main(argv):
379
+ """CLI shim used by autonomy/loki cmd_assets.
380
+
381
+ Subcommands:
382
+ export <out_path> [--categories a,b,c] [--wiki]
383
+ import <bundle_path> [--no-merge]
384
+ inspect <bundle_path>
385
+
386
+ Roots are taken from the environment so the bash caller controls them:
387
+ LOKI_ASSETS_HOME, LOKI_ASSETS_REPO, LOKI_ASSETS_PROJECT
388
+ """
389
+ if not argv:
390
+ sys.stderr.write("usage: assets_bundle.py <export|import|inspect> ...\n")
391
+ return 2
392
+
393
+ sub = argv[0]
394
+ rest = argv[1:]
395
+
396
+ home = os.environ.get("LOKI_ASSETS_HOME", os.path.expanduser("~"))
397
+ repo = os.environ.get("LOKI_ASSETS_REPO", os.getcwd())
398
+ project = os.environ.get("LOKI_ASSETS_PROJECT", os.getcwd())
399
+
400
+ if sub == "export":
401
+ if not rest:
402
+ sys.stderr.write("export: missing output path\n")
403
+ return 2
404
+ out_path = rest[0]
405
+ categories = list(DEFAULT_CATEGORIES)
406
+ i = 1
407
+ while i < len(rest):
408
+ if rest[i] == "--categories" and i + 1 < len(rest):
409
+ categories = [
410
+ c.strip() for c in rest[i + 1].split(",") if c.strip()
411
+ ]
412
+ i += 2
413
+ continue
414
+ if rest[i] == "--wiki":
415
+ if "wiki" not in categories:
416
+ categories.append("wiki")
417
+ i += 1
418
+ continue
419
+ i += 1
420
+ manifest = export_bundle(out_path, home, repo, project, categories)
421
+ sys.stdout.write(json.dumps(manifest, indent=2) + "\n")
422
+ return 0
423
+
424
+ if sub == "import":
425
+ if not rest:
426
+ sys.stderr.write("import: missing bundle path\n")
427
+ return 2
428
+ bundle_path = rest[0]
429
+ merge = "--no-merge" not in rest[1:]
430
+ result = import_bundle(bundle_path, home, repo, project, merge=merge)
431
+ sys.stdout.write(json.dumps(result, indent=2) + "\n")
432
+ return 0
433
+
434
+ if sub == "inspect":
435
+ if not rest:
436
+ sys.stderr.write("inspect: missing bundle path\n")
437
+ return 2
438
+ sys.stdout.write(json.dumps(inspect_bundle(rest[0]), indent=2) + "\n")
439
+ return 0
440
+
441
+ sys.stderr.write("unknown subcommand: " + sub + "\n")
442
+ return 2
443
+
444
+
445
+ if __name__ == "__main__":
446
+ sys.exit(_main(sys.argv[1:]))