projview 0.8.1 → 0.8.3
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/docs/ai-tests/comments.md +118 -0
- package/package.json +1 -1
- package/public/anchor.js +38 -0
- package/public/comments.js +12 -17
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# comments - AI test
|
|
2
|
+
|
|
3
|
+
An AI-driven test of projview's **comment machine interface** (`projview comment
|
|
4
|
+
add` / `comments` / `comment resolve|rm`, JSON on stdout). The scenario: an agent
|
|
5
|
+
reviews a doc set, leaves anchored review notes, and a second reader pulls them
|
|
6
|
+
back - no browser involved.
|
|
7
|
+
|
|
8
|
+
## experimental parameters
|
|
9
|
+
|
|
10
|
+
| parameter | value |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| tool | `projview` (global install) |
|
|
13
|
+
| version | `0.8.2` |
|
|
14
|
+
| commands | `comment add`, `comments`, `comment resolve`, `comment rm`, `usage` |
|
|
15
|
+
| dataset | the bundled demo docs (`--demo`) |
|
|
16
|
+
| store | a persistent sqlite file (`--store-url sqlite://…`) - comments need a store |
|
|
17
|
+
| output | JSON per comment: `{ id, file, anchor, body, author, createdAt, resolved }` |
|
|
18
|
+
|
|
19
|
+
> Comments ride a **persistent store**. With the default ephemeral store the
|
|
20
|
+
> agent is warned the note won't survive exit, so pass `--persist` or
|
|
21
|
+
> `--store-url`.
|
|
22
|
+
|
|
23
|
+
## method
|
|
24
|
+
|
|
25
|
+
An agent leaves review notes targeting different anchor types, then the notes are
|
|
26
|
+
listed, one resolved, and one deleted:
|
|
27
|
+
|
|
28
|
+
| # | file | anchor | aspect under test |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| 1 | `README.md` | file | whole-file note + agent self-tag (`--author claude`) |
|
|
31
|
+
| 2 | `getting-started.md` | heading | section anchor to a stable `pv-` heading id |
|
|
32
|
+
| 3 | lifecycle | — | list → resolve → delete over the CLI |
|
|
33
|
+
|
|
34
|
+
## results
|
|
35
|
+
|
|
36
|
+
### 1. leave an anchored, agent-tagged comment
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
DB="sqlite:///tmp/review.sqlite"
|
|
40
|
+
projview comment add README.md "clear intro - add a 'what it is NOT' line" \
|
|
41
|
+
--author claude --store-url "$DB" --demo
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```jsonc
|
|
45
|
+
{ "id": "be15fdb7-…", "file": "README.md",
|
|
46
|
+
"anchor": { "type": "file" },
|
|
47
|
+
"body": "clear intro - add a 'what it is NOT' line",
|
|
48
|
+
"author": "claude", "resolved": false }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
✅ The agent self-tags via `--author claude`, so AI notes are distinguishable
|
|
52
|
+
from human ones. Without `--author`, the author defaults to the git/OS user.
|
|
53
|
+
|
|
54
|
+
### 2. anchor to a section (heading)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
projview comment add getting-started.md "could link forward to the api reference" \
|
|
58
|
+
--heading pv-headings-h1-h6 --author claude --store-url "$DB" --demo
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`anchor` becomes `{ "type": "heading", "id": "pv-headings-h1-h6" }`. Heading ids
|
|
62
|
+
are the `pv-`-prefixed slugs projview renders, so a section note survives edits to
|
|
63
|
+
the section body.
|
|
64
|
+
|
|
65
|
+
✅ Three anchor flavours exist: `file`, `heading`, and `text` (a quote selector,
|
|
66
|
+
created from the viewer - it re-anchors on edits and flags itself *orphaned* only
|
|
67
|
+
if the quoted text truly disappears).
|
|
68
|
+
|
|
69
|
+
### 3. read them back
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
projview comments --store-url "$DB" --demo # all comments, as a JSON array
|
|
73
|
+
projview comments README.md --store-url "$DB" --demo # filtered to one file
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
An agent can diff, summarise, or act on the returned objects programmatically.
|
|
77
|
+
|
|
78
|
+
### 4. resolve + delete
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
projview comment resolve <id> --store-url "$DB" --demo # -> { …, "resolved": true }
|
|
82
|
+
projview comment rm <id> --store-url "$DB" --demo # -> { "deleted": "<id>" }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
✅ Full lifecycle over the CLI: create → list → resolve/reopen → delete. A
|
|
86
|
+
missing id exits non-zero with a clear message.
|
|
87
|
+
|
|
88
|
+
### 5. usage
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
projview usage --store-url "$DB" --demo
|
|
92
|
+
# { "total": 0, "byType": {}, "topFiles": [], "recentSearches": [] }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`usage` is empty here because the **viewer** (not the CLI) records `view` /
|
|
96
|
+
`search` events; run the server and browse, then `usage` aggregates what was
|
|
97
|
+
looked at.
|
|
98
|
+
|
|
99
|
+
## observations & limitations
|
|
100
|
+
|
|
101
|
+
- **Authorship**: explicit `--author` wins; otherwise the git `user.name` (or the
|
|
102
|
+
OS username). UI-created comments default to the server's git/OS user.
|
|
103
|
+
- **Persistence is required** for CLI comments to survive - the default store is
|
|
104
|
+
ephemeral and `comment add` warns when it isn't writable beyond the session.
|
|
105
|
+
- **Anchors**: `file` and `heading` are creatable from the CLI; `text` quote
|
|
106
|
+
anchors come from the viewer (they need a live text selection).
|
|
107
|
+
- **Writes are localhost-only** over HTTP - an exposed (`0.0.0.0`) instance is
|
|
108
|
+
read-only (`403` on write). The CLI writes the store directly, so it isn't gated.
|
|
109
|
+
|
|
110
|
+
## reproduce
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm i -g projview@latest
|
|
114
|
+
DB="sqlite:///tmp/review.sqlite"
|
|
115
|
+
projview comment add README.md "a note" --author claude --store-url "$DB" --demo
|
|
116
|
+
projview comments --store-url "$DB" --demo
|
|
117
|
+
projview usage --store-url "$DB" --demo
|
|
118
|
+
```
|
package/package.json
CHANGED
package/public/anchor.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* text-quote re-anchoring - the pure part.
|
|
2
|
+
*
|
|
3
|
+
* findQuote() locates a {exact, prefix, suffix} selector in a flat string and
|
|
4
|
+
* returns the best start offset, disambiguating repeated text by surrounding
|
|
5
|
+
* context. It touches no DOM, so it's unit-testable directly (no jsdom). The
|
|
6
|
+
* caller (comments.js) maps the offset back to a DOM Range. */
|
|
7
|
+
|
|
8
|
+
export const commonPrefix = (a, b) => {
|
|
9
|
+
let i = 0;
|
|
10
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
11
|
+
return i;
|
|
12
|
+
};
|
|
13
|
+
export const commonSuffix = (a, b) => {
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < a.length && i < b.length && a[a.length - 1 - i] === b[b.length - 1 - i]) i++;
|
|
16
|
+
return i;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Best start offset of anchor.exact in `text`, or -1 if it isn't present.
|
|
20
|
+
// When `exact` repeats, the candidate whose surrounding text best matches
|
|
21
|
+
// prefix/suffix wins (ties -> earliest).
|
|
22
|
+
export function findQuote(text, anchor) {
|
|
23
|
+
const exact = anchor.exact || '';
|
|
24
|
+
if (!exact) return -1;
|
|
25
|
+
const prefix = anchor.prefix || '';
|
|
26
|
+
const suffix = anchor.suffix || '';
|
|
27
|
+
let best = -1;
|
|
28
|
+
let bestScore = -1;
|
|
29
|
+
let idx = text.indexOf(exact);
|
|
30
|
+
while (idx !== -1) {
|
|
31
|
+
const before = text.slice(Math.max(0, idx - prefix.length), idx);
|
|
32
|
+
const after = text.slice(idx + exact.length, idx + exact.length + suffix.length);
|
|
33
|
+
const score = commonSuffix(before, prefix) + commonPrefix(after, suffix);
|
|
34
|
+
if (score > bestScore) { bestScore = score; best = idx; }
|
|
35
|
+
idx = text.indexOf(exact, idx + 1);
|
|
36
|
+
}
|
|
37
|
+
return best;
|
|
38
|
+
}
|
package/public/comments.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { $, esc } from './dom.js';
|
|
11
11
|
import { state } from './store.js';
|
|
12
12
|
import { toast } from './toast.js';
|
|
13
|
+
import { findQuote } from './anchor.js';
|
|
13
14
|
|
|
14
15
|
const contentEl = $('content');
|
|
15
16
|
const viewerEl = $('viewer');
|
|
@@ -29,7 +30,14 @@ const cre = (tag, cls, html) => {
|
|
|
29
30
|
return e;
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
const
|
|
33
|
+
const FAB_ICON =
|
|
34
|
+
`<svg class="cmt-fab-ico" viewBox="0 0 24 24" width="23" height="23" fill="none" aria-hidden="true">
|
|
35
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
|
|
36
|
+
<circle cx="8" cy="10" r="1.05" fill="currentColor"/>
|
|
37
|
+
<circle cx="12" cy="10" r="1.05" fill="currentColor"/>
|
|
38
|
+
<circle cx="16" cy="10" r="1.05" fill="currentColor"/>
|
|
39
|
+
</svg>`;
|
|
40
|
+
const fab = cre('button', 'cmt-fab', `${FAB_ICON}<span class="cmt-badge" hidden>0</span>`);
|
|
33
41
|
fab.title = 'comments';
|
|
34
42
|
fab.hidden = true;
|
|
35
43
|
|
|
@@ -148,24 +156,11 @@ function pointAt(segs, offset) {
|
|
|
148
156
|
return last ? { node: last.node, offset: last.node.nodeValue.length } : null;
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// Resolve a {exact, prefix, suffix} quote selector to a Range, disambiguating
|
|
155
|
-
// repeats by surrounding context. Returns null if `exact` isn't present.
|
|
159
|
+
// Resolve a {exact, prefix, suffix} quote selector to a DOM Range (using the
|
|
160
|
+
// pure finder in anchor.js). Returns null if `exact` isn't present (orphan).
|
|
156
161
|
function resolveText(anchor, ctx) {
|
|
157
|
-
const { text } = ctx;
|
|
158
162
|
const exact = anchor.exact || '';
|
|
159
|
-
|
|
160
|
-
let best = -1, bestScore = -1;
|
|
161
|
-
let idx = text.indexOf(exact);
|
|
162
|
-
while (idx !== -1) {
|
|
163
|
-
const before = text.slice(Math.max(0, idx - (anchor.prefix?.length || 0)), idx);
|
|
164
|
-
const after = text.slice(idx + exact.length, idx + exact.length + (anchor.suffix?.length || 0));
|
|
165
|
-
const score = commonSuffix(before, anchor.prefix || '') + commonPrefix(after, anchor.suffix || '');
|
|
166
|
-
if (score > bestScore) { bestScore = score; best = idx; }
|
|
167
|
-
idx = text.indexOf(exact, idx + 1);
|
|
168
|
-
}
|
|
163
|
+
const best = exact ? findQuote(ctx.text, anchor) : -1;
|
|
169
164
|
if (best === -1) return null;
|
|
170
165
|
const a = pointAt(ctx.segs, best);
|
|
171
166
|
const b = pointAt(ctx.segs, best + exact.length);
|