pi-weave 0.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/LICENSE +21 -0
- package/README.md +122 -0
- package/package.json +69 -0
- package/skills/.gitkeep +0 -0
- package/skills/weave-explore/SKILL.md +64 -0
- package/skills/weave-notepad/SKILL.md +77 -0
- package/src/core/frontmatter.ts +119 -0
- package/src/core/git.ts +194 -0
- package/src/core/graph/build.ts +258 -0
- package/src/core/graph/current.ts +107 -0
- package/src/core/graph/model.ts +63 -0
- package/src/core/graph/wikilinks.ts +28 -0
- package/src/core/index.ts +25 -0
- package/src/core/languages.ts +76 -0
- package/src/core/mutex.ts +34 -0
- package/src/core/paths.ts +37 -0
- package/src/core/repoIndex.ts +404 -0
- package/src/core/slug.ts +28 -0
- package/src/core/summaries.ts +300 -0
- package/src/core/types.ts +155 -0
- package/src/core/vault.ts +254 -0
- package/src/core/workspace.ts +85 -0
- package/src/pi/index.ts +191 -0
- package/src/pi/summarize.ts +134 -0
- package/src/pi/tools/noteTool.ts +178 -0
- package/src/pi/tools/repoTool.ts +94 -0
- package/src/pi/viewer/browser.ts +29 -0
- package/src/pi/viewer/page.ts +1316 -0
- package/src/pi/viewer/server.ts +229 -0
- package/src/pi/viewer/tui/explorer.ts +597 -0
- package/src/pi/viewer/tui/model.ts +1011 -0
- package/src/pi/viewer/tui/run.ts +69 -0
- package/src/pi/viewer/tui/theme.ts +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eran Yonai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# pi-weave
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/logo-high-res.jpg" alt="pi-weave โ an agent-native knowledge workspace" width="220"/>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
**An agent-native knowledge workspace for your life and your code.**
|
|
8
|
+
|
|
9
|
+
pi-weave is a [pi](https://github.com/earendil-works/pi)
|
|
10
|
+
extension with two faces that are secretly one:
|
|
11
|
+
|
|
12
|
+
1. **A smart notepad with AI skills.** A persistent vault of knowledge โ
|
|
13
|
+
decisions, ideas, people, meetings โ stored as plain Markdown notes with
|
|
14
|
+
front matter under `~/.okf/notes/`. Your agent reads and writes it *with*
|
|
15
|
+
you; everything is editable by hand in any editor.
|
|
16
|
+
|
|
17
|
+
2. **A repository exploration engine.** A derived, git-aware knowledge index
|
|
18
|
+
of the repo you're in, living at `<repo>/.okf/` โ structure, languages,
|
|
19
|
+
packages, modules, entry points, and staleness state. Rebuildable,
|
|
20
|
+
disposable, never the source of truth.
|
|
21
|
+
|
|
22
|
+
And one rule across both: **everything is equally usable by humans and
|
|
23
|
+
agents.** No opaque databases. No lock-in formats.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
๐งต vault:12 ยท my-project:ok โ pi's status line when weave is active
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install npm:pi-weave # from npm
|
|
33
|
+
pi install git:github.com/EranYonai/pi-weave # from git
|
|
34
|
+
pi install /path/to/pi-weave # local path
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or for development: `pi -e ./src/pi/index.ts`.
|
|
38
|
+
|
|
39
|
+
## What you get
|
|
40
|
+
|
|
41
|
+
| Surface | Name | Purpose |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Tool | `weave_note` | list / get / add / append / search vault notes |
|
|
44
|
+
| Tool | `weave_repo` | status / scan / overview of the `.okf` repo index |
|
|
45
|
+
| Command | `/weave` | workspace dashboard (vault + repository) |
|
|
46
|
+
| Command | `/weave-scan` | build/refresh the repository index (light) |
|
|
47
|
+
| Command | `/weave-scan deep` | light index + model-summarized sidecars (opt-in, incremental) |
|
|
48
|
+
| Command | `/weave-view` | open the local graph viewer in your browser |
|
|
49
|
+
| Command | `/weave-view tui` | explore the same graph in-terminal (keyboard) |
|
|
50
|
+
| Skill | `weave-notepad` | how the agent should take good notes |
|
|
51
|
+
| Skill | `weave-explore` | how the agent should explore repositories |
|
|
52
|
+
|
|
53
|
+
**`/weave-view`** starts a loopback-only server (`127.0.0.1`, random port)
|
|
54
|
+
and opens an interactive graph of your knowledge space: vault notes with
|
|
55
|
+
trust provenance (solid = human, dashed = agent, dimmed = generated),
|
|
56
|
+
wiki-link edges between notes, and the repository's structure anchored to
|
|
57
|
+
git state. It reads disk live on every refresh โ never a stale cache.
|
|
58
|
+
Zoom/scroll, drag to pan, click nodes to expand; notes open in a rendered
|
|
59
|
+
markdown side panel.
|
|
60
|
+
|
|
61
|
+
**`/weave-view tui`** explores the same knowledge graph in the terminal:
|
|
62
|
+
an expandable containment tree (Explore), a 1-hop neighborhood (Focus), a
|
|
63
|
+
selected-node detail view with note/`.okf` bodies, and a staleness + link
|
|
64
|
+
health surface โ all keyboard-driven and read-only. Same data as the
|
|
65
|
+
browser viewer (the `GraphModel` assembled from vault + repo index); a
|
|
66
|
+
pure, harness-free view-model (`src/pi/viewer/tui/model.ts`) backs the
|
|
67
|
+
`WeaveExplorer` component. See `docs/weave-view-tui-design.md`.
|
|
68
|
+
|
|
69
|
+
On session start, pi-weave detects the repository you're in, checks whether
|
|
70
|
+
`.okf` exists and is fresh, and says so in the status line.
|
|
71
|
+
|
|
72
|
+
**`/weave-scan deep`** is the opt-in, incremental deep pass: it refreshes the
|
|
73
|
+
light index and then writes a short model summary per file to
|
|
74
|
+
`.okf/repository/summaries/`, skipping files whose content hash is unchanged
|
|
75
|
+
since their last summary. It costs tokens, so it never runs implicitly.
|
|
76
|
+
|
|
77
|
+
## The formats (why everything is portable)
|
|
78
|
+
|
|
79
|
+
Vault note (`~/.okf/notes/auth-boundary.md`):
|
|
80
|
+
|
|
81
|
+
```markdown
|
|
82
|
+
---
|
|
83
|
+
title: Auth boundary decision
|
|
84
|
+
created: 2026-08-22T09:00:00.000Z
|
|
85
|
+
updated: 2026-08-22T09:30:00.000Z
|
|
86
|
+
tags: [auth, security]
|
|
87
|
+
source: human
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
JWT validation happens at the gateway becauseโฆ
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Repository index (`<repo>/.okf/`):
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
.okf/
|
|
97
|
+
โโโ okf.json # format version + generator + source: generated
|
|
98
|
+
โโโ repository/
|
|
99
|
+
โโโ identity.json # name, remotes, default branch
|
|
100
|
+
โโโ git.json # HEAD sha + branch + changed-file content hashes (staleness anchor)
|
|
101
|
+
โโโ structure.json # languages, packages, modules, entry points
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The `.okf` index is **derived**: delete it, rescan, lose nothing. By default
|
|
105
|
+
it's excluded from git locally (`.git/info/exclude`); committing it for team
|
|
106
|
+
sharing is a deliberate opt-in.
|
|
107
|
+
|
|
108
|
+
## For other agent harnesses
|
|
109
|
+
|
|
110
|
+
The on-disk artifacts and `src/core` are harness-agnostic by design โ Claude
|
|
111
|
+
Code and opencode adapters are on the roadmap (docs/design.md ยง21), and the
|
|
112
|
+
skills follow the [Agent Skills standard](https://agentskills.io/specification).
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm install
|
|
118
|
+
npm run check # typecheck + tests with coverage gate (โฅ95%)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
See [AGENTS.md](AGENTS.md) for contributor/agent rules and
|
|
122
|
+
[docs/design.md](docs/design.md) for the full design.
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-weave",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"knowledge-graph",
|
|
13
|
+
"agent",
|
|
14
|
+
"notepad",
|
|
15
|
+
"repository-index"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Eran Yonai <yonai.eran@gmail.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/EranYonai/pi-weave.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/EranYonai/pi-weave#readme",
|
|
24
|
+
"bugs": "https://github.com/EranYonai/pi-weave/issues",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./core": "./src/core/index.ts"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"skills",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./src/pi/index.ts"
|
|
40
|
+
],
|
|
41
|
+
"skills": [
|
|
42
|
+
"./skills"
|
|
43
|
+
],
|
|
44
|
+
"image": "https://raw.githubusercontent.com/EranYonai/pi-weave/main/docs/logo-high-res.jpg"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"coverage": "vitest run --coverage",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"check": "npm run typecheck && npm run coverage",
|
|
51
|
+
"prepublishOnly": "npm run check"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@earendil-works/pi-ai": "*",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
56
|
+
"@earendil-works/pi-tui": "*",
|
|
57
|
+
"typebox": "*"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
62
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
63
|
+
"@types/node": "^24.0.0",
|
|
64
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
65
|
+
"typebox": "1.3.7",
|
|
66
|
+
"typescript": "^5.8.0",
|
|
67
|
+
"vitest": "^3.2.4"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/skills/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: weave-explore
|
|
3
|
+
description: Explore a git repository through its pi-weave knowledge index (.okf). Use when starting work in an unfamiliar repo, when asked to "explore this repository", or before broad structural questions about modules, packages, or architecture.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Weave Explore
|
|
7
|
+
|
|
8
|
+
pi-weave keeps a **derived** knowledge index of the repository at
|
|
9
|
+
`<repo>/.okf/`. Source code is the truth; the index is a rebuildable cache.
|
|
10
|
+
Deleting `.okf` loses nothing โ rescan to regenerate it.
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
|
|
14
|
+
In pi, use the `weave_repo` tool (or the `/weave-scan` command). To see
|
|
15
|
+
the assembled graph in the terminal, run `/weave-view tui` (Explore tree,
|
|
16
|
+
Focus neighborhood, Health surface); `/weave-view` opens the browser viewer.
|
|
17
|
+
In other harnesses, read the JSON documents under `.okf/` directly.
|
|
18
|
+
|
|
19
|
+
## Workflow
|
|
20
|
+
|
|
21
|
+
1. **Check for an index**: `weave_repo` action=status.
|
|
22
|
+
- `missing` โ offer to scan (`weave_repo` action=scan), or scan directly
|
|
23
|
+
when the user asked to explore.
|
|
24
|
+
- `stale` โ scan again; the repository moved on.
|
|
25
|
+
- `fresh` โ read it: action=overview.
|
|
26
|
+
2. **Start from the overview**: file counts, languages, packages, module
|
|
27
|
+
groupings, and likely entry points. This replaces dozens of `ls`/`find`
|
|
28
|
+
calls.
|
|
29
|
+
3. **Descend progressively** (design ยง9): only open files in modules relevant
|
|
30
|
+
to the user's question. The index gives you the map; the code gives you
|
|
31
|
+
the terrain.
|
|
32
|
+
4. **Read summaries before full files**: if the repo has been deep-scanned
|
|
33
|
+
(`.okf/repository/summaries/` exists), read the relevant sidecars first โ
|
|
34
|
+
they tell you what a file does and its outward surface in 1โ3 sentences,
|
|
35
|
+
so you can decide whether to open the full file at all.
|
|
36
|
+
5. **Answer with structure**: name modules and packages by their indexed
|
|
37
|
+
paths so the user can jump straight to them.
|
|
38
|
+
|
|
39
|
+
## Deep summaries
|
|
40
|
+
|
|
41
|
+
`/weave-scan deep` creates or refreshes `.okf/repository/summaries/` โ one
|
|
42
|
+
sidecar per file, written by the session model. It is **opt-in and
|
|
43
|
+
incremental**: it never runs implicitly, and it only re-summarizes files
|
|
44
|
+
whose content hash changed since their last summary. If summaries are
|
|
45
|
+
missing or stale, offer `/weave-scan deep` to create or refresh them before
|
|
46
|
+
diving into full files.
|
|
47
|
+
|
|
48
|
+
## On-disk layout
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
.okf/
|
|
52
|
+
โโโ okf.json # format version + generator
|
|
53
|
+
โโโ repository/
|
|
54
|
+
โโโ identity.json # name, remotes, default branch
|
|
55
|
+
โโโ git.json # HEAD sha + branch + changed files (staleness anchor)
|
|
56
|
+
โโโ structure.json # languages, packages, modules, entry points
|
|
57
|
+
โโโ summaries/ # deep-scan sidecars (one per file, when present)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Trust model
|
|
61
|
+
|
|
62
|
+
Everything in `.okf` is machine-generated (`source: generated`). If the user
|
|
63
|
+
corrects an interpretation, that correction belongs in the vault (see the
|
|
64
|
+
`weave-notepad` skill) as human knowledge, not in the derived index.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: weave-notepad
|
|
3
|
+
description: Take and retrieve durable notes in the pi-weave vault. Use when the user asks to remember something or to start/add to a note (aliases: notes, ai note, note-taking, note-taker), or when answering questions about past decisions, people, or projects. Also handles interview note-taking where raw dictations are appended AND expanded.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Weave Notepad
|
|
7
|
+
|
|
8
|
+
The pi-weave vault is the user's long-term memory: plain Markdown notes with
|
|
9
|
+
front matter under `~/.okf/notes/`. It is shared with the human โ anything
|
|
10
|
+
you write here, they can read and edit, and vice versa.
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
|
|
14
|
+
In pi, use the `weave_note` tool. In other harnesses (or when the tool is not
|
|
15
|
+
available), operate on the files directly:
|
|
16
|
+
|
|
17
|
+
- **Notes** live at `~/.okf/notes/<slug>.md` (vault root overridable via `PI_WEAVE_VAULT`).
|
|
18
|
+
- Each note has YAML front matter: `title`, `created`, `updated` (ISO-8601),
|
|
19
|
+
`tags: [..]`, and `source: human | agent | generated`.
|
|
20
|
+
- `weave_note` actions: `list`, `get`, `add`, `append`, `finalize`, `search`.
|
|
21
|
+
`finalize` restructures the body *above* the `## Raw notes` tail and preserves
|
|
22
|
+
the tail verbatim.
|
|
23
|
+
|
|
24
|
+
## Dictation mode ("I will write, you expand")
|
|
25
|
+
|
|
26
|
+
When the user is dictating (e.g. "I am in an interview", "I will write, you
|
|
27
|
+
expand on my draft"), behave as notepad + expander:
|
|
28
|
+
|
|
29
|
+
1. **Every raw line the user adds MUST be captured.** Append their words
|
|
30
|
+
verbatim to the active note (`weave_note` action=append).
|
|
31
|
+
2. **Then expand it immediately.** Add `## Raw: "<user's line>"` followed by
|
|
32
|
+
`**Expand:** <polished, interview-ready passage>` (STAR-style when
|
|
33
|
+
applicable). Never append without expanding.
|
|
34
|
+
3. **Terse replies.** Acknowledge briefly and show what was written. Do NOT
|
|
35
|
+
dump long prose in chat โ the expansion lives in the note, not the reply.
|
|
36
|
+
4. **Small talk / questions โ ask the user.** If you want to chat, clarify,
|
|
37
|
+
or ask something, pose it to the user directly and briefly, and wait.
|
|
38
|
+
|
|
39
|
+
## When to take a note
|
|
40
|
+
|
|
41
|
+
Create a note **only when the user explicitly asks** for one to exist:
|
|
42
|
+
"start a note on X", "add to the X note", "remember this", "jot that down".
|
|
43
|
+
Never promote conversation into a note on your own initiative โ capture is
|
|
44
|
+
explicit by design.
|
|
45
|
+
|
|
46
|
+
## When NOT to take a note
|
|
47
|
+
|
|
48
|
+
- Anything derivable from the repository itself (that knowledge belongs to
|
|
49
|
+
the `.okf` index, not the vault).
|
|
50
|
+
- Session-scratch information (in-progress task state).
|
|
51
|
+
- Secrets, credentials, or anything the user hasn't confirmed is safe to persist.
|
|
52
|
+
|
|
53
|
+
## How to write a good note
|
|
54
|
+
|
|
55
|
+
1. **Search first** (`weave_note` action=search): if a note exists, `append`
|
|
56
|
+
to it rather than creating a duplicate.
|
|
57
|
+
2. Title: short noun phrase ("Auth boundary decision", not "Notes").
|
|
58
|
+
3. **Scribble in, verbatim.** When the user is dictating, append their words
|
|
59
|
+
to the note as rough, verbatim scribbles โ no silent rewording. Keep them
|
|
60
|
+
under a `## Raw notes` tail at the end of the note.
|
|
61
|
+
4. **Finalize on request.** When the user says "finalize this" / "clean this
|
|
62
|
+
up", restructure the body *above* the raw tail: front-loaded summary,
|
|
63
|
+
sections, entities, links. Use `weave_note` action=finalize (or edit the
|
|
64
|
+
file directly in other harnesses). Move nothing out of `## Raw notes` โ it
|
|
65
|
+
is append-only and never rewritten.
|
|
66
|
+
5. Tags: 1โ4 lowercase tags; reuse existing tags when possible.
|
|
67
|
+
6. Provenance: notes the user scribbled stay `source: human` (finalization is
|
|
68
|
+
editorial, not authorship) โ pass `source: "human"` to `add` for
|
|
69
|
+
user-scribbled notes. Notes you draft from scratch are `source: agent`
|
|
70
|
+
(the default). Never overwrite a `source: human` note's meaning; append
|
|
71
|
+
with a dated "Agent addendum" section instead.
|
|
72
|
+
|
|
73
|
+
## Retrieving knowledge
|
|
74
|
+
|
|
75
|
+
Use `weave_note` action=search with the user's key terms, then `get` the best
|
|
76
|
+
hits. When a note and the repository index disagree, trust the repository for
|
|
77
|
+
facts about code and flag the discrepancy โ the note may be stale intent.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { NoteMeta, NoteSource } from "./types";
|
|
2
|
+
import { NOTE_SOURCES } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal YAML-front-matter handling for vault notes.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a *subset*: notes are meant to be human-editable plain text,
|
|
8
|
+
* so we write only `key: value` scalars and `[a, b]` inline arrays, and we
|
|
9
|
+
* parse exactly that. Anything richer belongs in the Markdown body.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ParsedNoteFile {
|
|
13
|
+
meta: NoteMeta;
|
|
14
|
+
body: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Generic, tolerant front-matter parse (any fields) โ for non-note OKF files. */
|
|
18
|
+
export interface ParsedFrontMatter {
|
|
19
|
+
fields: Map<string, string>;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
24
|
+
|
|
25
|
+
export function quoteField(value: string): string {
|
|
26
|
+
// Only quote when the value could confuse our subset parser.
|
|
27
|
+
if (/[:#[\]]|^\s|\s$|^$/.test(value)) {
|
|
28
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function unquoteField(value: string): string {
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
36
|
+
return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
37
|
+
}
|
|
38
|
+
return trimmed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseTags(value: string): string[] {
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
|
|
44
|
+
return trimmed.length > 0 ? [unquoteField(trimmed)] : [];
|
|
45
|
+
}
|
|
46
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
47
|
+
if (inner.length === 0) return [];
|
|
48
|
+
return inner
|
|
49
|
+
.split(",")
|
|
50
|
+
.map((t) => unquoteField(t))
|
|
51
|
+
.filter((t) => t.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSource(value: string): NoteSource {
|
|
55
|
+
const raw = value.trim();
|
|
56
|
+
return (NOTE_SOURCES as readonly string[]).includes(raw)
|
|
57
|
+
? (raw as NoteSource)
|
|
58
|
+
: "human";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Serialize note metadata + body to the on-disk Markdown form. */
|
|
62
|
+
export function serializeNote(meta: NoteMeta, body: string): string {
|
|
63
|
+
const lines = [
|
|
64
|
+
"---",
|
|
65
|
+
`title: ${quoteField(meta.title)}`,
|
|
66
|
+
`created: ${meta.created}`,
|
|
67
|
+
`updated: ${meta.updated}`,
|
|
68
|
+
`tags: [${meta.tags.map(quoteField).join(", ")}]`,
|
|
69
|
+
`source: ${meta.source}`,
|
|
70
|
+
"---",
|
|
71
|
+
"",
|
|
72
|
+
body.replace(/\s+$/, ""),
|
|
73
|
+
"",
|
|
74
|
+
];
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse any OKF front-matter block generically. Returns null when there is
|
|
80
|
+
* no leading `---` block; field parsing is best-effort (junk lines skipped).
|
|
81
|
+
* The body is stripped of the serializer's blank separator and trailing
|
|
82
|
+
* whitespace so round-trips are stable.
|
|
83
|
+
*/
|
|
84
|
+
export function parseFrontMatter(text: string): ParsedFrontMatter | null {
|
|
85
|
+
const match = FRONTMATTER_RE.exec(text);
|
|
86
|
+
if (!match || match[1] === undefined) return null;
|
|
87
|
+
const body = text.slice(match[0].length).replace(/^\n/, "").replace(/\s+$/, "");
|
|
88
|
+
const fields = new Map<string, string>();
|
|
89
|
+
for (const line of match[1].split("\n")) {
|
|
90
|
+
if (line.trim().length === 0) continue;
|
|
91
|
+
const idx = line.indexOf(":");
|
|
92
|
+
if (idx <= 0) continue; // tolerate blank/junk lines, front matter is best-effort
|
|
93
|
+
fields.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
|
94
|
+
}
|
|
95
|
+
return { fields, body };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse a note file. Throws on missing/invalid front matter so callers can
|
|
100
|
+
* treat the file as malformed rather than guessing.
|
|
101
|
+
*/
|
|
102
|
+
export function parseNoteFile(text: string): ParsedNoteFile {
|
|
103
|
+
const parsed = parseFrontMatter(text);
|
|
104
|
+
if (!parsed) {
|
|
105
|
+
throw new Error("Missing front matter block (expected leading --- block)");
|
|
106
|
+
}
|
|
107
|
+
const title = parsed.fields.get("title");
|
|
108
|
+
if (!title) {
|
|
109
|
+
throw new Error("Front matter is missing required field: title");
|
|
110
|
+
}
|
|
111
|
+
const meta: NoteMeta = {
|
|
112
|
+
title: unquoteField(title),
|
|
113
|
+
created: parsed.fields.get("created") ?? "",
|
|
114
|
+
updated: parsed.fields.get("updated") ?? "",
|
|
115
|
+
tags: parseTags(parsed.fields.get("tags") ?? "[]"),
|
|
116
|
+
source: parseSource(parsed.fields.get("source") ?? "human"),
|
|
117
|
+
};
|
|
118
|
+
return { meta, body: parsed.body };
|
|
119
|
+
}
|
package/src/core/git.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
|
+
import type { GitState } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal git layer for repository knowledge.
|
|
9
|
+
*
|
|
10
|
+
* Every function degrades to `null`/empty instead of throwing: callers are
|
|
11
|
+
* usually answering "is there repo knowledge here?" and must survive
|
|
12
|
+
* detached worktrees, missing git binaries, and non-repo directories.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface GitExecOptions {
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
async function git(args: string[], cwd: string, timeoutMs: number): Promise<string | null> {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile("git", args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
|
|
24
|
+
if (err) resolve(null);
|
|
25
|
+
else resolve(stdout);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Absolute path of the enclosing git worktree root, or null. */
|
|
31
|
+
export async function findGitRoot(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
|
|
32
|
+
const out = await git(["rev-parse", "--show-toplevel"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
33
|
+
const root = out?.trim();
|
|
34
|
+
if (!root || root.length === 0) return null;
|
|
35
|
+
// Normalize symlinks (macOS /var -> /private/var) so callers always get a
|
|
36
|
+
// canonical, comparable path.
|
|
37
|
+
try {
|
|
38
|
+
return await fs.realpath(root);
|
|
39
|
+
} catch {
|
|
40
|
+
return root;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Current HEAD sha, or null (unborn branch, not a repo, ...). */
|
|
45
|
+
export async function headSha(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
|
|
46
|
+
const out = await git(["rev-parse", "HEAD"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
47
|
+
const sha = out?.trim();
|
|
48
|
+
return sha && sha.length > 0 ? sha : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Current branch name; null when detached or not a repo. */
|
|
52
|
+
export async function currentBranch(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
|
|
53
|
+
const out = await git(["symbolic-ref", "--short", "HEAD"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
54
|
+
const branch = out?.trim();
|
|
55
|
+
return branch && branch.length > 0 ? branch : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Paths changed in the worktree relative to HEAD (porcelain v1). */
|
|
59
|
+
export async function changedFiles(cwd: string, options: GitExecOptions = {}): Promise<string[]> {
|
|
60
|
+
const out = await git(["status", "--porcelain"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
61
|
+
if (out === null) return [];
|
|
62
|
+
return out
|
|
63
|
+
.split("\n")
|
|
64
|
+
.map((line) => line.slice(3).trim()) // drop the 2 status columns + space
|
|
65
|
+
.map((path) => {
|
|
66
|
+
// Renames appear as "old -> new"; the new path is what matters.
|
|
67
|
+
const arrow = path.indexOf(" -> ");
|
|
68
|
+
return arrow >= 0 ? path.slice(arrow + 4) : path;
|
|
69
|
+
})
|
|
70
|
+
.filter((path) => path.length > 0)
|
|
71
|
+
.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Files git knows about: tracked plus untracked-but-not-ignored. */
|
|
75
|
+
export async function listFiles(cwd: string, options: GitExecOptions = {}): Promise<string[] | null> {
|
|
76
|
+
const out = await git(
|
|
77
|
+
["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
78
|
+
cwd,
|
|
79
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
80
|
+
);
|
|
81
|
+
if (out === null) return null;
|
|
82
|
+
return out
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((f) => f.length > 0)
|
|
85
|
+
.sort();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Configured remotes, deduplicated URLs. */
|
|
89
|
+
export async function remotes(cwd: string, options: GitExecOptions = {}): Promise<string[]> {
|
|
90
|
+
const out = await git(["remote", "-v"], cwd, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
91
|
+
if (out === null) return [];
|
|
92
|
+
const urls = new Set<string>();
|
|
93
|
+
for (const line of out.split("\n")) {
|
|
94
|
+
const parts = line.split(/\s+/);
|
|
95
|
+
const url = parts[1];
|
|
96
|
+
if (url) urls.add(url);
|
|
97
|
+
}
|
|
98
|
+
return [...urls].sort();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Remote default branch (origin/HEAD), null when unknown. */
|
|
102
|
+
export async function defaultBranch(cwd: string, options: GitExecOptions = {}): Promise<string | null> {
|
|
103
|
+
const out = await git(
|
|
104
|
+
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
|
|
105
|
+
cwd,
|
|
106
|
+
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
107
|
+
);
|
|
108
|
+
const ref = out?.trim();
|
|
109
|
+
if (!ref) return null;
|
|
110
|
+
const prefix = "origin/";
|
|
111
|
+
return ref.startsWith(prefix) ? ref.slice(prefix.length) : ref;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a path inside the repo's *real* git dir via `git rev-parse
|
|
116
|
+
* --git-path`. In linked worktrees and submodules `.git` is a file pointing
|
|
117
|
+
* at the real git dir, so naive `<root>/.git/...` joins fail with ENOTDIR.
|
|
118
|
+
* Returns null when no git metadata is available.
|
|
119
|
+
*/
|
|
120
|
+
async function resolveGitPath(repoRoot: string, relPath: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string | null> {
|
|
121
|
+
const out = await git(["rev-parse", "--git-path", relPath], repoRoot, timeoutMs);
|
|
122
|
+
const resolved = out?.trim();
|
|
123
|
+
if (!resolved || resolved.length === 0) return null;
|
|
124
|
+
// Normal repos print a root-relative path (".git/info/exclude"); linked
|
|
125
|
+
// worktrees and submodules print the real, absolute location.
|
|
126
|
+
return isAbsolute(resolved) ? resolved : join(repoRoot, resolved);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Add `.okf/` to the repo's local exclude file (info/exclude in the real
|
|
131
|
+
* git dir) so the derived index stays a local cache by default (design ยง15,
|
|
132
|
+
* Model A) without touching tracked files. Idempotent; no-op when the repo
|
|
133
|
+
* has no git metadata (not a repo, no git binary).
|
|
134
|
+
*/
|
|
135
|
+
export async function excludeOkfLocally(repoRoot: string): Promise<void> {
|
|
136
|
+
const excludePath = await resolveGitPath(repoRoot, "info/exclude");
|
|
137
|
+
if (!excludePath) return;
|
|
138
|
+
let current = "";
|
|
139
|
+
try {
|
|
140
|
+
current = await fs.readFile(excludePath, "utf8");
|
|
141
|
+
} catch {
|
|
142
|
+
// no exclude file yet โ we will create it
|
|
143
|
+
}
|
|
144
|
+
const already = current
|
|
145
|
+
.split("\n")
|
|
146
|
+
.map((line) => line.trim())
|
|
147
|
+
.some((line) => line === ".okf/" || line === ".okf");
|
|
148
|
+
if (already) return;
|
|
149
|
+
await fs.mkdir(dirname(excludePath), { recursive: true });
|
|
150
|
+
const prefix = current.length > 0 && !current.endsWith("\n") ? "\n" : "";
|
|
151
|
+
await fs.writeFile(excludePath, `${current}${prefix}.okf/\n`, "utf8");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* sha1 the worktree content of each repo-relative path. Paths without file
|
|
156
|
+
* content (deletions, untracked directories, unreadable entries) map to
|
|
157
|
+
* null โ their porcelain membership still anchors them.
|
|
158
|
+
*/
|
|
159
|
+
export async function hashWorktreeFiles(
|
|
160
|
+
repoRoot: string,
|
|
161
|
+
paths: string[],
|
|
162
|
+
): Promise<Record<string, string | null>> {
|
|
163
|
+
const entries = await Promise.all(
|
|
164
|
+
paths.map(async (path): Promise<[string, string | null]> => {
|
|
165
|
+
try {
|
|
166
|
+
const full = join(repoRoot, path);
|
|
167
|
+
if (!(await fs.stat(full)).isFile()) return [path, null];
|
|
168
|
+
return [path, createHash("sha1").update(await fs.readFile(full)).digest("hex")];
|
|
169
|
+
} catch {
|
|
170
|
+
return [path, null];
|
|
171
|
+
}
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
return Object.fromEntries(entries);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Snapshot the current git state. Returns null when HEAD does not exist. */
|
|
178
|
+
export async function snapshotGitState(cwd: string, options: GitExecOptions = {}): Promise<GitState | null> {
|
|
179
|
+
const sha = await headSha(cwd, options);
|
|
180
|
+
if (!sha) return null;
|
|
181
|
+
const [branch, changed, root] = await Promise.all([
|
|
182
|
+
currentBranch(cwd, options),
|
|
183
|
+
changedFiles(cwd, options),
|
|
184
|
+
findGitRoot(cwd, options),
|
|
185
|
+
]);
|
|
186
|
+
return {
|
|
187
|
+
headSha: sha,
|
|
188
|
+
branch: branch ?? "(detached)",
|
|
189
|
+
changedFiles: changed,
|
|
190
|
+
// Hash against the repo root: `cwd` may be a subdirectory.
|
|
191
|
+
changedHashes: await hashWorktreeFiles(root ?? cwd, changed),
|
|
192
|
+
capturedAt: new Date().toISOString(),
|
|
193
|
+
};
|
|
194
|
+
}
|