loki-mode 7.11.0 → 7.13.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.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-to-product system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product via the RARV-C closure loop, with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.11.0
6
+ # Loki Mode v7.13.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -381,4 +381,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
381
381
 
382
382
  ---
383
383
 
384
- **v7.11.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
384
+ **v7.13.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.11.0
1
+ 7.13.0
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """wiki-ask.py -- grounded, cited codebase Q&A for the R5 auto-wiki.
3
+
4
+ Answers a natural-language question about the project, grounded in the indexed
5
+ codebase. Citations are file:line and ALWAYS point at real code:
6
+
7
+ 1. Deterministically retrieve the top-K relevant chunks (token overlap).
8
+ 2. Show the LLM NUMBERED chunks and tell it to cite by index only ([1]..[K]).
9
+ 3. Map each [n] back to the real {file,start_line} of the chunk we supplied.
10
+ 4. Validate every citation against the filesystem; drop non-resolving ones.
11
+
12
+ A fabricated citation is structurally impossible: the model can only reference
13
+ chunks we handed it, and only those that resolve on disk survive.
14
+
15
+ LLM is mocked via LOKI_WIKI_LLM_STUB (see wiki_llm.py) so CI makes no paid calls.
16
+ With no provider and no stub, an EXTRACTIVE answer is returned (top chunk
17
+ snippets with their real citations).
18
+
19
+ Invoked as a subprocess (hyphen in filename):
20
+ python3 wiki-ask.py --root <project> --question "..." [--k N] [--json] [--quiet]
21
+ Exit codes: 0 answered, 2 usage/error, 3 no relevant code found.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ _HERE = os.path.dirname(os.path.abspath(__file__))
33
+ if _HERE not in sys.path:
34
+ sys.path.insert(0, _HERE)
35
+
36
+ import wiki_index # noqa: E402
37
+ from wiki_llm import invoke_llm, map_and_validate_citations # noqa: E402
38
+
39
+
40
+ def _load_or_build_index(root):
41
+ """Reuse the persisted code-index when present; else build in-memory.
42
+
43
+ The persisted index lacks chunk text (kept small), so for retrieval we
44
+ rebuild the full in-memory index. This stays cheap because build_index is
45
+ dependency-free and capped.
46
+ """
47
+ return wiki_index.build_index(root)
48
+
49
+
50
+ def _build_prompt(question, chunks):
51
+ parts = [
52
+ "Answer the question using ONLY the numbered code excerpts below. "
53
+ "Cite the excerpts you use with their index in square brackets, like "
54
+ "[1] or [3]. Cite by index ONLY. Do NOT write file paths yourself. Do "
55
+ "NOT use emojis. Do NOT use em dashes or en dashes. If the excerpts do "
56
+ "not contain the answer, say so plainly.",
57
+ "",
58
+ "QUESTION: " + question,
59
+ "",
60
+ "CODE EXCERPTS:",
61
+ ]
62
+ for i, ch in enumerate(chunks, start=1):
63
+ parts.append("[%d] %s (lines %d-%d):" % (
64
+ i, ch["file"], ch["start_line"], ch["end_line"]))
65
+ parts.append(ch["text"])
66
+ parts.append("")
67
+ return "\n".join(parts)
68
+
69
+
70
+ def _extractive_answer(question, chunks):
71
+ """Deterministic fallback when no LLM is available.
72
+
73
+ Returns prose that references the top chunks by index so the same
74
+ citation-mapping path applies (every [n] resolves to a real chunk).
75
+ """
76
+ lines = [
77
+ "No language model was available, so here are the most relevant code "
78
+ "locations for your question:",
79
+ "",
80
+ ]
81
+ for i, ch in enumerate(chunks, start=1):
82
+ snippet = ch["text"].strip().splitlines()[:3]
83
+ preview = " ".join(s.strip() for s in snippet)[:160]
84
+ lines.append("- [%d] %s" % (i, preview))
85
+ return "\n".join(lines)
86
+
87
+
88
+ def main(argv=None):
89
+ ap = argparse.ArgumentParser(description="Grounded cited codebase Q&A.")
90
+ ap.add_argument("--root", default=".")
91
+ ap.add_argument("--question", required=True)
92
+ ap.add_argument("--k", type=int, default=6)
93
+ ap.add_argument("--json", action="store_true", help="emit JSON")
94
+ ap.add_argument("--quiet", action="store_true")
95
+ args = ap.parse_args(argv)
96
+
97
+ root = Path(args.root).resolve()
98
+ if not root.is_dir():
99
+ print("error: not a directory: %s" % root, file=sys.stderr)
100
+ return 2
101
+
102
+ index = _load_or_build_index(root)
103
+ chunks = wiki_index.retrieve(index, args.question, k=max(1, args.k))
104
+ if not chunks:
105
+ if args.json:
106
+ print(json.dumps({"answer": "", "citations": [],
107
+ "note": "no relevant code found"}))
108
+ else:
109
+ print("No relevant code found for that question.")
110
+ return 3
111
+
112
+ prompt = _build_prompt(args.question, chunks)
113
+ raw = invoke_llm(prompt)
114
+ if raw is None or not raw.strip():
115
+ raw = _extractive_answer(args.question, chunks)
116
+
117
+ answer, citations = map_and_validate_citations(raw, chunks, root)
118
+
119
+ if args.json:
120
+ print(json.dumps({
121
+ "question": args.question,
122
+ "answer": answer.strip(),
123
+ "citations": citations,
124
+ }, indent=2))
125
+ return 0
126
+
127
+ print(answer.strip())
128
+ if citations:
129
+ print("")
130
+ print("Citations:")
131
+ for c in citations:
132
+ print(" %s:%d" % (c["file"], c["line"]))
133
+ return 0
134
+
135
+
136
+ if __name__ == "__main__":
137
+ sys.exit(main())
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """wiki-generator.py -- build a per-project cited wiki under .loki/wiki/ (R5).
3
+
4
+ Loki's answer to Devin DeepWiki: an architecture overview, key-modules guide,
5
+ and data-flow section generated from the codebase, where EVERY section cites the
6
+ real source files it was built from.
7
+
8
+ Grounding contract: section citations are CODE-DERIVED. The scanner picks the
9
+ real files and the definition-extractor reads the real def/class/function line
10
+ numbers from those files. The LLM (or template fallback) writes prose only; it
11
+ never invents a citation. Citations are validated against the filesystem before
12
+ being written, so a citation can never point at a file/line that does not exist.
13
+
14
+ Incremental: a signature over (git HEAD + per-file content hashes) is stored in
15
+ wiki-manifest.json. Re-running skips regeneration when the signature is unchanged
16
+ (unless --force). Same cheap-incremental idea as the proof/docs manifests.
17
+
18
+ CI-safe: LOKI_WIKI_LLM_STUB mocks the LLM (see wiki_llm.py); with no provider and
19
+ no stub, a deterministic template wiki is written (citations still real).
20
+
21
+ Invoked as a subprocess (hyphen in filename, like proof-generator.py):
22
+ python3 wiki-generator.py --root <project> [--force] [--quiet]
23
+ Exit codes: 0 generated or up-to-date, 2 usage/error.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import sys
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ _HERE = os.path.dirname(os.path.abspath(__file__))
36
+ if _HERE not in sys.path:
37
+ sys.path.insert(0, _HERE)
38
+
39
+ import wiki_index # noqa: E402
40
+ from wiki_llm import invoke_llm # noqa: E402
41
+
42
+ # Optional reuse of the proof redactor for a final PII sweep. Repo-relative
43
+ # paths already avoid /Users/<name>, but this is belt-and-suspenders.
44
+ try:
45
+ import proof_redact # noqa: E402
46
+ _HAVE_REDACT = True
47
+ except Exception: # pragma: no cover - redactor optional
48
+ _HAVE_REDACT = False
49
+
50
+
51
+ def _utc_now():
52
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
53
+
54
+
55
+ def _top_modules(index, root, limit=10):
56
+ """Pick the most significant source files as 'key modules'.
57
+
58
+ Significance = chunk count (proxy for size/centrality). Returns a list of
59
+ {file, defs:[{name,line}], line_count} with REAL definition citations.
60
+ """
61
+ counts = {}
62
+ for ch in index["chunks"]:
63
+ counts[ch["file"]] = counts.get(ch["file"], 0) + 1
64
+ ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
65
+ modules = []
66
+ for rel, n in ranked:
67
+ defs = wiki_index.extract_definitions(root, rel, limit=8)
68
+ modules.append({
69
+ "file": rel,
70
+ "approx_lines": n * wiki_index.CHUNK_LINES,
71
+ "defs": defs,
72
+ })
73
+ return modules
74
+
75
+
76
+ def _entry_points(root, files):
77
+ """Detect likely entry-point files from the scanned set (real files only)."""
78
+ candidates = [
79
+ "src/index.ts", "src/index.js", "src/main.ts", "src/main.js",
80
+ "index.ts", "index.js", "main.ts", "main.js", "server.ts", "server.js",
81
+ "src/app.ts", "src/app.js", "app.py", "main.py", "manage.py",
82
+ "src/main.py", "__main__.py", "main.go", "cmd/main.go",
83
+ "src/main.rs", "src/lib.rs", "bin/loki",
84
+ ]
85
+ fileset = set(files)
86
+ return [c for c in candidates if c in fileset]
87
+
88
+
89
+ def _llm_prose(section, context, fallback):
90
+ """Get prose for a section from the LLM, or use the deterministic fallback."""
91
+ prompt = (
92
+ "You are documenting a software project for a team wiki. Write a clear, "
93
+ "factual " + section + " section in markdown. Be concise (under 250 words). "
94
+ "Do NOT use emojis. Do NOT use em dashes or en dashes. Do NOT invent file "
95
+ "paths. Output ONLY the markdown prose, no headings, no citations.\n\n"
96
+ "PROJECT CONTEXT:\n" + context
97
+ )
98
+ out = invoke_llm(prompt)
99
+ if out and out.strip():
100
+ return out.strip()
101
+ return fallback
102
+
103
+
104
+ def _build_context(root, index, modules, entries):
105
+ name = Path(root).name
106
+ lines = [
107
+ "PROJECT: %s" % name,
108
+ "SOURCE FILES INDEXED: %d" % len(index["files"]),
109
+ "ENTRY POINTS: %s" % (", ".join(entries) or "none detected"),
110
+ "TOP MODULES:",
111
+ ]
112
+ for m in modules[:8]:
113
+ lines.append(" - %s (~%d lines)" % (m["file"], m["approx_lines"]))
114
+ # Top-level dirs.
115
+ dirs = {}
116
+ for f in index["files"]:
117
+ top = f.split("/")[0] if "/" in f else "(root)"
118
+ dirs[top] = dirs.get(top, 0) + 1
119
+ lines.append("DIRECTORIES:")
120
+ for d, n in sorted(dirs.items(), key=lambda kv: -kv[1])[:12]:
121
+ lines.append(" - %s/ (%d files)" % (d, n))
122
+ return "\n".join(lines)
123
+
124
+
125
+ def _section_overview(root, index, modules, entries, context):
126
+ name = Path(root).name
127
+ fallback = (
128
+ "%s is a software project with %d indexed source files. "
129
+ "Entry points: %s. The largest modules by size are listed below."
130
+ % (name, len(index["files"]), ", ".join(entries) or "not detected")
131
+ )
132
+ prose = _llm_prose("architecture overview", context, fallback)
133
+ # Code-derived citations: the entry points + top modules (real files).
134
+ citations = []
135
+ for e in entries[:3]:
136
+ citations.append({"file": e, "line": 1})
137
+ for m in modules[:4]:
138
+ line = m["defs"][0]["line"] if m["defs"] else 1
139
+ citations.append({"file": m["file"], "line": line})
140
+ return {"id": "architecture", "title": "Architecture Overview",
141
+ "body": prose, "citations": citations}
142
+
143
+
144
+ def _section_modules(root, modules, context):
145
+ fallback_lines = ["The following modules carry most of the codebase:"]
146
+ citations = []
147
+ body_parts = []
148
+ prose = _llm_prose(
149
+ "key modules summary",
150
+ context,
151
+ "Key modules of this project, with their primary definitions:",
152
+ )
153
+ body_parts.append(prose)
154
+ body_parts.append("")
155
+ for m in modules:
156
+ defs = m["defs"]
157
+ def_str = ", ".join("%s (L%d)" % (d["name"], d["line"]) for d in defs[:6])
158
+ body_parts.append(
159
+ "- **%s** (~%d lines)%s"
160
+ % (m["file"], m["approx_lines"], (": " + def_str) if def_str else "")
161
+ )
162
+ line = defs[0]["line"] if defs else 1
163
+ citations.append({"file": m["file"], "line": line})
164
+ return {"id": "modules", "title": "Key Modules",
165
+ "body": "\n".join(body_parts), "citations": citations}
166
+
167
+
168
+ def _section_data_flow(root, index, entries, context):
169
+ fallback = (
170
+ "Execution begins at the entry point(s) (%s) and flows through the "
171
+ "key modules. Trace a request from the entry file into the modules it "
172
+ "imports to follow the data path." % (", ".join(entries) or "not detected")
173
+ )
174
+ prose = _llm_prose("data flow", context, fallback)
175
+ citations = [{"file": e, "line": 1} for e in entries[:4]]
176
+ if not citations and index["files"]:
177
+ citations = [{"file": index["files"][0], "line": 1}]
178
+ return {"id": "data-flow", "title": "Data Flow",
179
+ "body": prose, "citations": citations}
180
+
181
+
182
+ def _validate_citations(root, citations):
183
+ """Drop any citation that does not resolve to a real file/line on disk."""
184
+ root = Path(root)
185
+ clean = []
186
+ for c in citations:
187
+ rel = c.get("file")
188
+ line = int(c.get("line", 1))
189
+ if not rel:
190
+ continue
191
+ abs_path = root / rel
192
+ try:
193
+ if not abs_path.is_file():
194
+ continue
195
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
196
+ nlines = sum(1 for _ in f)
197
+ except OSError:
198
+ continue
199
+ if 1 <= line <= max(nlines, 1):
200
+ clean.append({"file": rel, "line": line})
201
+ return clean
202
+
203
+
204
+ def _render_md(section):
205
+ lines = ["## %s" % section["title"], "", section["body"], ""]
206
+ if section["citations"]:
207
+ lines.append("**Sources:**")
208
+ for c in section["citations"]:
209
+ lines.append("- `%s:%d`" % (c["file"], c["line"]))
210
+ lines.append("")
211
+ return "\n".join(lines)
212
+
213
+
214
+ def main(argv=None):
215
+ ap = argparse.ArgumentParser(description="Generate a cited project wiki.")
216
+ ap.add_argument("--root", default=".", help="project root")
217
+ ap.add_argument("--force", action="store_true", help="regenerate even if unchanged")
218
+ ap.add_argument("--quiet", action="store_true")
219
+ args = ap.parse_args(argv)
220
+
221
+ root = Path(args.root).resolve()
222
+ if not root.is_dir():
223
+ print("error: not a directory: %s" % root, file=sys.stderr)
224
+ return 2
225
+
226
+ def log(msg):
227
+ if not args.quiet:
228
+ print(msg)
229
+
230
+ wiki_dir = root / ".loki" / "wiki"
231
+ manifest_path = wiki_dir / "wiki-manifest.json"
232
+
233
+ signature = wiki_index.compute_signature(root)
234
+
235
+ if manifest_path.is_file() and not args.force:
236
+ try:
237
+ prev = json.loads(manifest_path.read_text())
238
+ except (OSError, json.JSONDecodeError):
239
+ prev = {}
240
+ if prev.get("signature") == signature:
241
+ log("wiki up to date (codebase unchanged); use --force to regenerate")
242
+ return 0
243
+
244
+ log("scanning codebase ...")
245
+ index = wiki_index.build_index(root)
246
+ if not index["files"]:
247
+ print("error: no source files found to document", file=sys.stderr)
248
+ return 2
249
+
250
+ modules = _top_modules(index, root)
251
+ entries = _entry_points(root, index["files"])
252
+ context = _build_context(root, index, modules, entries)
253
+
254
+ log("generating sections (%d files indexed) ..." % len(index["files"]))
255
+ sections = [
256
+ _section_overview(root, index, modules, entries, context),
257
+ _section_modules(root, modules, context),
258
+ _section_data_flow(root, index, entries, context),
259
+ ]
260
+
261
+ # Enforce the grounding contract: validate every citation against disk.
262
+ for sec in sections:
263
+ sec["citations"] = _validate_citations(root, sec["citations"])
264
+
265
+ # Optional PII sweep on prose (paths are already repo-relative).
266
+ if _HAVE_REDACT:
267
+ try:
268
+ proof_redact.set_context(repo_root=str(root))
269
+ for sec in sections:
270
+ sec["body"] = proof_redact.redact_value(sec["body"])
271
+ proof_redact.reset_context()
272
+ except Exception:
273
+ pass
274
+
275
+ wiki_dir.mkdir(parents=True, exist_ok=True)
276
+ generated_at = _utc_now()
277
+ wiki = {
278
+ "schema_version": "1.0",
279
+ "project": root.name,
280
+ "generated_at": generated_at,
281
+ "file_count": len(index["files"]),
282
+ "sections": sections,
283
+ }
284
+ (wiki_dir / "wiki.json").write_text(json.dumps(wiki, indent=2))
285
+
286
+ # Rendered markdown.
287
+ index_md = ["# %s -- Project Wiki" % root.name, "",
288
+ "Auto-generated by Loki (R5). %d source files indexed. "
289
+ "Generated %s." % (len(index["files"]), generated_at), ""]
290
+ for sec in sections:
291
+ index_md.append(_render_md(sec))
292
+ # Per-section file too.
293
+ (wiki_dir / (sec["id"] + ".md")).write_text(_render_md(sec))
294
+ (wiki_dir / "index.md").write_text("\n".join(index_md))
295
+
296
+ # Persist the code index (used by ask + dashboard).
297
+ (wiki_dir / "code-index.json").write_text(json.dumps({
298
+ "root_relative": True,
299
+ "files": index["files"],
300
+ "chunks": [
301
+ {"id": c["id"], "file": c["file"],
302
+ "start_line": c["start_line"], "end_line": c["end_line"]}
303
+ for c in index["chunks"]
304
+ ],
305
+ }, indent=2))
306
+
307
+ manifest = {
308
+ "schema_version": "1.0",
309
+ "signature": signature,
310
+ "generated_at": generated_at,
311
+ "file_count": len(index["files"]),
312
+ "sections": [s["id"] for s in sections],
313
+ }
314
+ wiki_dir.mkdir(parents=True, exist_ok=True)
315
+ manifest_path.write_text(json.dumps(manifest, indent=2))
316
+
317
+ log("wiki written to %s/.loki/wiki/ (%d sections)" % (root.name, len(sections)))
318
+ return 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ sys.exit(main())