Skip to content

Commit 484232c

Browse files
committed
feat(pi): add qmd package
1 parent 85b2ecb commit 484232c

39 files changed

Lines changed: 13660 additions & 0 deletions

config/pi/settings.jsonc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@
6565
"~/.config/dotfiles/pi-packages/pi-dcp",
6666
// Agentic context management (/acm, /context, context_* tools) - https://github.com/ttttmr/pi-context
6767
"npm:pi-context",
68+
// Repo-local QMD workflow (/qmd, /qp) and qmd-cli-first guidance
69+
"~/.config/dotfiles/pi-packages/pi-qmd",
6870
// Secure web fetch – HTML→markdown via mdream, secret scanning, injection detection
6971
"~/.config/dotfiles/pi-packages/pi-scurl",
7072
// Auto-load direnv env at session start

modules/shell/pi/default.nix

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,41 @@ in
284284
''
285285
);
286286

287+
# QMD depends on native modules (better-sqlite3/sqlite-vec), so install
288+
# its local package deps in the mutable repo instead of a Nix-store
289+
# node_modules symlink.
290+
home.activation.pi-qmd-deps = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
291+
pkg_dir="$HOME/.config/dotfiles/pi-packages/pi-qmd"
292+
lock_file="$pkg_dir/package-lock.json"
293+
stamp_file="$pkg_dir/.node-modules-lock-sha256"
294+
npm_bin="${pkgs.nodejs}/bin/npm"
295+
node_bin_dir="${pkgs.nodejs}/bin"
296+
sha_bin="${pkgs.coreutils}/bin/sha256sum"
297+
298+
if [ -f "$lock_file" ] && [ -x "$npm_bin" ]; then
299+
current_sha="$($sha_bin "$lock_file" | ${pkgs.coreutils}/bin/cut -d' ' -f1)"
300+
saved_sha="$(cat "$stamp_file" 2>/dev/null || true)"
301+
needs_install=0
302+
303+
if [ ! -d "$pkg_dir/node_modules/@tobilu/qmd" ]; then
304+
needs_install=1
305+
elif [ "$current_sha" != "$saved_sha" ]; then
306+
needs_install=1
307+
fi
308+
309+
if [ -L "$pkg_dir/node_modules" ]; then
310+
rm -f "$pkg_dir/node_modules"
311+
needs_install=1
312+
fi
313+
314+
if [ "$needs_install" -eq 1 ]; then
315+
echo "Installing pi-qmd npm deps..."
316+
(cd "$pkg_dir" && PATH="$node_bin_dir:$PATH" "$npm_bin" ci --workspaces=false --omit=dev) || echo "Warning: pi-qmd npm install failed."
317+
printf '%s\n' "$current_sha" > "$stamp_file"
318+
fi
319+
fi
320+
'';
321+
287322
# Pi binary now provided by pkgs.llm-agents.pi (nix-managed).
288323
# Pi-package deps now provided via Nix-built node_modules (home.file symlinks above).
289324
# This activation handles remaining bun-dependent extras only.

pi-packages/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
node_modules/
2+
.node-modules-lock-sha256

pi-packages/pi-qmd/README.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# QMD Extension
2+
3+
Repo-local QMD infrastructure for Pi.
4+
5+
## What it does
6+
7+
- Detects whether the current repo is indexed by QMD
8+
- Tracks repo freshness via `.pi/qmd.json`
9+
- Adds a quiet footer for indexed repos only
10+
- Injects short guidance so the agent knows when to use `qmd query/search/get` via `bash`
11+
- Provides an interactive split-pane TUI panel (`/qmd`, `/qp`, `Ctrl+Alt+Q`) with:
12+
- Persistent collection sidebar (left) — always visible, navigate with `j/k`, filter with `/`
13+
- Context-sensitive main pane (right) — overview, files, or search view
14+
- Interactive search with debounced lex results and hybrid mode (`ctrl+t`)
15+
- File browser with NERDTree-style tree and index toggle
16+
- In-panel update (`u`, bound only) and init (`i`) actions
17+
- `tab` switches focus between sidebar and main pane
18+
- Provides subcommands: `/qmd status`, `/qmd update`, `/qmd init`
19+
20+
## What it does not do
21+
22+
- It does **not** expose an always-on search tool
23+
- It does **not** intercept or rewrite search queries automatically
24+
- It does **not** mirror QMD config into repo files
25+
26+
The extension owns infra and workflow. The agent still uses the QMD CLI directly for retrieval.
27+
28+
## Source of truth
29+
30+
- **QMD store** — collections and path contexts
31+
- **`.pi/qmd.json`** — repo binding and freshness marker only
32+
33+
## Commands
34+
35+
### `/qmd` (no args) · `/qp` · `Ctrl+Alt+Q`
36+
37+
Opens the QMD index dashboard as a split-pane panel. Left pane shows all collections; right pane shows overview, files, or search for the selected collection. Use `tab` to switch focus, `s` to search, `f` for files. See `docs/panel.md` for full keyboard shortcuts and layout.
38+
39+
When `hasUI` is false, prints a plain-text summary instead.
40+
41+
### `/qmd status`
42+
43+
Shows current repo state only:
44+
45+
- indexed / not indexed / unavailable
46+
- repo root
47+
- collection key
48+
- freshness state
49+
- repair notes when marker/store drift is detected
50+
51+
### `/qmd update`
52+
53+
Updates the **current repo collection only**.
54+
It never reindexes all collections by default.
55+
56+
### `/qmd init`
57+
58+
Starts a deterministic onboarding flow:
59+
60+
1. scan repo
61+
2. build draft proposal
62+
3. let the agent refine it with the user
63+
4. execute `qmd_init` only after explicit confirmation
64+
65+
## Setup
66+
67+
This repo currently expects the local QMD fork to be linked via Bun:
68+
69+
- package: `@tobilu/qmd`
70+
- local fork: `~/git/qmd-fork`
71+
- link style: `bun link`
72+
73+
## File layout
74+
75+
```
76+
extensions/qmd/
77+
├── index.ts # Extension entry point
78+
├── core/
79+
│ ├── errors.ts # Agent-legible typed errors
80+
│ ├── qmd-store.ts # SDK wrapper with lazy lifecycle
81+
│ └── types.ts # Zod schemas + TypeBox tool params
82+
├── domain/
83+
│ ├── freshness.ts # Git-based markdown freshness
84+
│ ├── onboarding.ts # Deterministic init pipeline
85+
│ └── repo-binding.ts # Repo root, collection key, marker I/O
86+
├── extension/
87+
│ ├── command.ts # Slash commands, alias, shortcut, panel lifecycle
88+
│ ├── runtime.ts # Session hooks, footer, prompt injection
89+
│ └── tool.ts # Workflow-scoped qmd_init tool
90+
├── ui/
91+
│ ├── constants.ts # Panel constants (width, shortcuts, icon)
92+
│ ├── data.ts # Snapshot builder, file tree, helpers
93+
│ ├── panel.ts # Split-pane TUI panel (sidebar + main: overview/files/search)
94+
│ └── plain-text.ts # Non-TUI fallback summary
95+
├── diy/
96+
│ ├── README.md # How to copy/paste this blueprint into another repo
97+
│ ├── qmd-extension-snapshot-spec.md
98+
│ ├── qmd-extension-diy-execution-plan.md
99+
│ ├── agent-prompt-template.md
100+
│ └── references.md
101+
├── docs/
102+
│ ├── architecture.md # Layer diagram and responsibilities
103+
│ ├── freshness.md # Freshness model and footer behavior
104+
│ ├── onboarding.md # Init flow steps and caveats
105+
│ └── panel.md # Panel states, keyboard shortcuts, data flow
106+
└── __tests__/
107+
├── core/
108+
│ ├── qmd-store.test.ts
109+
│ └── types.test.ts
110+
├── domain/
111+
│ ├── freshness.test.ts
112+
│ ├── onboarding.test.ts
113+
│ └── repo-binding.test.ts
114+
├── extension/
115+
│ └── runtime.test.ts
116+
└── ui/
117+
└── data.test.ts
118+
```
119+
120+
## DIY blueprint
121+
122+
If you want to recreate this extension in another repo without installing this package, use:
123+
124+
- `diy/README.md` — copy/paste usage instructions
125+
- `diy/qmd-extension-snapshot-spec.md` — current behavior blueprint
126+
- `diy/qmd-extension-diy-execution-plan.md` — implementation milestones
127+
- `diy/references.md` — internal docs + agent-memory raw links
128+
- `diy/agent-prompt-template.md` — copy/paste prompt for rebuilding elsewhere
129+
130+
## Docs
131+
132+
- `docs/architecture.md` — layers, dependency direction, file responsibilities
133+
- `docs/onboarding.md` — init flow steps and caveats
134+
- `docs/freshness.md` — freshness model and footer behavior
135+
- `docs/panel.md` — panel states, keyboard shortcuts, data flow
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
import { handelize_path } from "../../core/qmd-store.js";
3+
4+
describe("handelize_path", () => {
5+
it("lowercases paths", () => {
6+
expect(handelize_path("docs/ARCHITECTURE.md")).toBe("docs/architecture.md");
7+
});
8+
9+
it("strips leading dots from directory segments", () => {
10+
expect(handelize_path(".pi/tracks/summary.md")).toBe("pi/tracks/summary.md");
11+
});
12+
13+
it("replaces non-alphanumeric chars with hyphens", () => {
14+
expect(handelize_path("docs/my file (1).md")).toBe("docs/my-file-1.md");
15+
});
16+
17+
it("preserves file extension", () => {
18+
expect(handelize_path("README.md")).toBe("readme.md");
19+
expect(handelize_path("notes.mdx")).toBe("notes.mdx");
20+
});
21+
22+
it("collapses multiple special chars into single hyphen", () => {
23+
expect(handelize_path("docs/a---b___c.md")).toBe("docs/a-b-c.md");
24+
});
25+
26+
it("strips leading/trailing hyphens from segments", () => {
27+
expect(handelize_path("-docs-/--file--.md")).toBe("docs/file.md");
28+
});
29+
30+
it("handles deeply nested paths", () => {
31+
expect(handelize_path(".pi/tracks/agent-memory/workstreams/notes.md")).toBe(
32+
"pi/tracks/agent-memory/workstreams/notes.md"
33+
);
34+
});
35+
36+
it("filters out empty segments", () => {
37+
expect(handelize_path("docs//nested/file.md")).toBe("docs/nested/file.md");
38+
});
39+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
beforeEach(() => {
4+
vi.resetModules();
5+
});
6+
7+
describe("core/qmd-store", () => {
8+
it("lists collections through the SDK wrapper", async () => {
9+
vi.doMock("@tobilu/qmd", () => ({
10+
createStore: vi.fn(async () => ({
11+
listCollections: async () => [
12+
{
13+
name: "demo",
14+
pwd: "/tmp/demo",
15+
glob_pattern: "**/*.md",
16+
doc_count: 1,
17+
active_count: 1,
18+
last_modified: null,
19+
includeByDefault: true,
20+
},
21+
],
22+
close: async () => {},
23+
})),
24+
}));
25+
26+
const module = await import("../../core/qmd-store.js");
27+
const collections = await module.list_collections();
28+
expect(collections[0]?.name).toBe("demo");
29+
await module.close_store();
30+
});
31+
32+
it("normalizes SDK status collection counts", async () => {
33+
vi.doMock("@tobilu/qmd", () => ({
34+
createStore: vi.fn(async () => ({
35+
getStatus: async () => ({
36+
totalDocuments: 3,
37+
needsEmbedding: 0,
38+
hasVectorIndex: true,
39+
collections: [
40+
{ name: "agents", path: "/repo", pattern: "**/*.md", documents: 3 },
41+
{ name: "legacy", path: "/repo", pattern: "**/*.md", documentCount: 2 },
42+
{ name: "snake", path: "/repo", pattern: "**/*.md", doc_count: 1 },
43+
],
44+
}),
45+
close: async () => {},
46+
})),
47+
}));
48+
49+
const module = await import("../../core/qmd-store.js");
50+
const status = await module.get_status();
51+
expect(status.collections.map((c) => c.documentCount)).toEqual([3, 2, 1]);
52+
await module.close_store();
53+
});
54+
55+
it("translates SDK failures into QmdUnavailableError", async () => {
56+
vi.doMock("@tobilu/qmd", () => ({
57+
createStore: vi.fn(async () => {
58+
throw new Error("boom");
59+
}),
60+
}));
61+
62+
const module = await import("../../core/qmd-store.js");
63+
await expect(module.list_collections()).rejects.toThrow("QMD is unavailable");
64+
});
65+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import { qmd_init_params_schema, qmd_repo_marker_schema } from "../../core/types.js";
3+
4+
describe("qmd_repo_marker_schema", () => {
5+
it("accepts a valid marker", () => {
6+
const result = qmd_repo_marker_schema.parse({
7+
schema_version: 1,
8+
repo_root: "/tmp/repo",
9+
collection_key: "p_L3RtcC9yZXBv",
10+
last_indexed_at: "2026-03-13T12:00:00.000Z",
11+
last_indexed_commit: "abc123",
12+
created_at: "2026-03-13T11:00:00.000Z",
13+
});
14+
15+
expect(result.collection_key).toBe("p_L3RtcC9yZXBv");
16+
});
17+
18+
it("rejects the wrong schema version", () => {
19+
const result = qmd_repo_marker_schema.safeParse({
20+
schema_version: 2,
21+
repo_root: "/tmp/repo",
22+
collection_key: "p_x",
23+
last_indexed_at: "now",
24+
last_indexed_commit: "abc123",
25+
created_at: "now",
26+
});
27+
28+
expect(result.success).toBe(false);
29+
});
30+
});
31+
32+
describe("qmd_init_params_schema", () => {
33+
it("accepts valid onboarding input", () => {
34+
const result = qmd_init_params_schema.parse({
35+
root: "/tmp/repo",
36+
glob_pattern: "**/*.md",
37+
paths: [{ path: "docs", annotation: "Documentation" }],
38+
});
39+
40+
expect(result.paths).toHaveLength(1);
41+
});
42+
43+
it("requires a non-empty root", () => {
44+
const result = qmd_init_params_schema.safeParse({
45+
root: "",
46+
paths: [],
47+
});
48+
49+
expect(result.success).toBe(false);
50+
});
51+
});

0 commit comments

Comments
 (0)