@oharness/server 0.1.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,54 @@
1
+ import type { HookRegistry } from "@oharness/engine/agent/hooks.ts";
2
+ /**
3
+ * What the agent has actually done to the working tree this session.
4
+ *
5
+ * A coding agent's deliverable is the diff, not the transcript, so the review
6
+ * pane needs the real before and after of every file it touched — not a
7
+ * reconstruction from tool arguments. `write` carries only the new content and
8
+ * `edit` carries only the fragment it replaced, so neither can produce a
9
+ * file-level diff on its own.
10
+ *
11
+ * The before image is therefore read from disk immediately before the tool
12
+ * runs and the after image immediately after. That is the only point where
13
+ * both are true, and it is honest about deletions, creations and edits alike.
14
+ */
15
+ export interface FileChange {
16
+ path: string;
17
+ /** Absent means the file did not exist: the change created it. */
18
+ before?: string;
19
+ /** Absent means the file no longer exists: the change deleted it. */
20
+ after?: string;
21
+ added: number;
22
+ removed: number;
23
+ /** Set when the file is too large or not text; the pane shows a note. */
24
+ note?: string;
25
+ }
26
+ export declare class ChangeLedger {
27
+ private readonly changes;
28
+ /** Files whose "before" is already captured, so later edits do not overwrite it. */
29
+ private readonly snapshotted;
30
+ /**
31
+ * Files whose mutation actually completed.
32
+ *
33
+ * Without this, a tool that failed after the pre-snapshot leaves a `before`
34
+ * with no `after` — which reads as a deletion, sending the reviewer to look
35
+ * for a change that never happened.
36
+ */
37
+ private readonly settled;
38
+ private readonly cwd;
39
+ constructor(cwd: string);
40
+ /**
41
+ * Snapshot before and after every file-mutating tool call.
42
+ *
43
+ * Registered as hooks rather than derived from the event stream because a
44
+ * hook runs inside the tool's execution window; an event consumer sees
45
+ * `tool_end` only after the write has landed, by which time the original
46
+ * content is gone.
47
+ */
48
+ attach(hooks: HookRegistry): void;
49
+ list(): FileChange[];
50
+ clear(): void;
51
+ private entryFor;
52
+ private resolve;
53
+ private readText;
54
+ }
@@ -0,0 +1,154 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** Files larger than this are recorded as changed but not diffed. */
4
+ const MAX_DIFF_BYTES = 512 * 1024;
5
+ const WATCHED = new Set(["write", "edit"]);
6
+ export class ChangeLedger {
7
+ changes = new Map();
8
+ /** Files whose "before" is already captured, so later edits do not overwrite it. */
9
+ snapshotted = new Set();
10
+ /**
11
+ * Files whose mutation actually completed.
12
+ *
13
+ * Without this, a tool that failed after the pre-snapshot leaves a `before`
14
+ * with no `after` — which reads as a deletion, sending the reviewer to look
15
+ * for a change that never happened.
16
+ */
17
+ settled = new Set();
18
+ cwd;
19
+ constructor(cwd) {
20
+ this.cwd = cwd;
21
+ }
22
+ /**
23
+ * Snapshot before and after every file-mutating tool call.
24
+ *
25
+ * Registered as hooks rather than derived from the event stream because a
26
+ * hook runs inside the tool's execution window; an event consumer sees
27
+ * `tool_end` only after the write has landed, by which time the original
28
+ * content is gone.
29
+ */
30
+ attach(hooks) {
31
+ hooks.on("PreToolUse", async (payload) => {
32
+ if (payload.event !== "PreToolUse" || !WATCHED.has(payload.toolName))
33
+ return;
34
+ const file = this.resolve(payload.toolInput);
35
+ if (!file)
36
+ return;
37
+ const entry = this.entryFor(file);
38
+ // Only the first snapshot is the true "before": later edits to the same
39
+ // file in one session are steps within one change, not separate ones.
40
+ // The flag rather than `before === undefined`, because undefined is a
41
+ // meaningful value here — it means the file did not exist.
42
+ if (!this.snapshotted.has(entry.path)) {
43
+ this.snapshotted.add(entry.path);
44
+ const read = await this.readText(file);
45
+ if (read.note)
46
+ entry.note = read.note;
47
+ else
48
+ entry.before = read.text;
49
+ }
50
+ });
51
+ hooks.on("PostToolUse", async (payload) => {
52
+ if (payload.event !== "PostToolUse" || !WATCHED.has(payload.toolName))
53
+ return;
54
+ const file = this.resolve(payload.toolInput);
55
+ if (!file)
56
+ return;
57
+ // A failed call leaves the file as it was; recording an "after" here
58
+ // would show a change that did not happen.
59
+ if (payload.result.isError)
60
+ return;
61
+ const entry = this.entryFor(file);
62
+ const read = await this.readText(file);
63
+ if (read.note)
64
+ entry.note = read.note;
65
+ else
66
+ entry.after = read.text;
67
+ // Only a completed mutation counts as a change to review.
68
+ this.settled.add(entry.path);
69
+ count(entry);
70
+ });
71
+ }
72
+ list() {
73
+ return [...this.changes.values()]
74
+ .filter((entry) => this.settled.has(entry.path))
75
+ .filter((entry) => entry.before !== entry.after || entry.note)
76
+ .sort((a, b) => a.path.localeCompare(b.path));
77
+ }
78
+ clear() {
79
+ this.changes.clear();
80
+ this.snapshotted.clear();
81
+ this.settled.clear();
82
+ }
83
+ entryFor(file) {
84
+ const relative = path.relative(this.cwd, file) || file;
85
+ let entry = this.changes.get(relative);
86
+ if (!entry) {
87
+ entry = { path: relative, added: 0, removed: 0 };
88
+ this.changes.set(relative, entry);
89
+ }
90
+ return entry;
91
+ }
92
+ resolve(input) {
93
+ const target = input?.path;
94
+ return typeof target === "string" ? path.resolve(this.cwd, target) : undefined;
95
+ }
96
+ async readText(file) {
97
+ try {
98
+ const buffer = await readFile(file);
99
+ if (buffer.byteLength > MAX_DIFF_BYTES) {
100
+ return { note: `${(buffer.byteLength / 1024).toFixed(0)}KB — too large to diff` };
101
+ }
102
+ // A NUL byte in the first block is the usual signal for binary content,
103
+ // and rendering one into the browser produces noise, not a diff.
104
+ if (buffer.subarray(0, 8000).includes(0))
105
+ return { note: "binary" };
106
+ return { text: buffer.toString("utf8") };
107
+ }
108
+ catch {
109
+ // Missing is a real state: it means created (as before) or deleted (as
110
+ // after), and both should show as such rather than as an error.
111
+ return { text: undefined };
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * Split into lines the way a diff tool counts them.
117
+ *
118
+ * A trailing newline terminates the last line rather than starting an empty
119
+ * one, so a plain `split` reports one line more than `git diff --stat` does
120
+ * for every well-formed text file.
121
+ */
122
+ function lines(text) {
123
+ if (text === "")
124
+ return [];
125
+ return text.replace(/\n$/, "").split("\n");
126
+ }
127
+ /** Line counts, via the same subsequence walk the browser uses to draw. */
128
+ function count(entry) {
129
+ if (entry.note)
130
+ return;
131
+ const a = lines(entry.before ?? "");
132
+ const b = lines(entry.after ?? "");
133
+ if (entry.before === undefined) {
134
+ entry.added = b.length;
135
+ entry.removed = 0;
136
+ return;
137
+ }
138
+ if (entry.after === undefined) {
139
+ entry.added = 0;
140
+ entry.removed = a.length;
141
+ return;
142
+ }
143
+ const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
144
+ for (let i = a.length - 1; i >= 0; i--) {
145
+ for (let j = b.length - 1; j >= 0; j--) {
146
+ table[i][j] =
147
+ a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
148
+ }
149
+ }
150
+ const common = table[0][0];
151
+ entry.added = b.length - common;
152
+ entry.removed = a.length - common;
153
+ }
154
+ //# sourceMappingURL=changes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.js","sourceRoot":"","sources":["../src/changes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The project tree, as the composer needs to see it.
3
+ *
4
+ * Two questions, and only two: what is in this folder, and which paths look
5
+ * like what someone just typed. Both answer in paths relative to the project
6
+ * root — the page never learns where on the disk the project lives, and a
7
+ * relative path is also what the agent's own tools take.
8
+ *
9
+ * This is deliberately not a file reader. Picking a file in the menu references
10
+ * it; reading it is still the agent's job, through the tools the user can see
11
+ * it using.
12
+ */
13
+ export interface FileEntry {
14
+ /** Relative to the project root, with forward slashes. */
15
+ path: string;
16
+ name: string;
17
+ dir: boolean;
18
+ }
19
+ /**
20
+ * A path under `root`, said the short way.
21
+ *
22
+ * Everything else comes back as it was: a file chosen from the desktop is not
23
+ * in the project, and a relative path climbing out of it with ../../ is
24
+ * neither shorter nor clearer than where the file actually is.
25
+ */
26
+ export declare function within(root: string, target: string): string;
27
+ /**
28
+ * One directory's children, for browsing into a project.
29
+ *
30
+ * A directory that cannot be read lists as empty rather than failing: the
31
+ * menu still shows the way back out of it.
32
+ */
33
+ export declare function browseFiles(root: string, rel?: string): Promise<{
34
+ dir: string;
35
+ parent?: string;
36
+ entries: FileEntry[];
37
+ }>;
38
+ /**
39
+ * Paths that fuzzily match `query`, best first.
40
+ *
41
+ * Breadth-first, so that when the budget runs out on a large repository it has
42
+ * been spent near the top of the tree — where the file someone is naming from
43
+ * memory almost always is — rather than deep inside one branch of it.
44
+ */
45
+ export declare function searchFiles(root: string, query: string, limit?: number): Promise<{
46
+ entries: FileEntry[];
47
+ truncated: boolean;
48
+ }>;
package/dist/files.js ADDED
@@ -0,0 +1,168 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * Directories that are never what someone is reaching for, and are large
5
+ * enough that walking them would spend the whole budget before reaching the
6
+ * source. Hidden directories are skipped by the same reasoning.
7
+ */
8
+ const SKIP = new Set([
9
+ "node_modules",
10
+ "dist",
11
+ "build",
12
+ "out",
13
+ "coverage",
14
+ "target",
15
+ "vendor",
16
+ "__pycache__",
17
+ "venv",
18
+ ]);
19
+ /** How many directory entries one search may look at before it gives up. */
20
+ const VISIT_BUDGET = 15_000;
21
+ /** How deep a search descends; deeper than this is generated output. */
22
+ const MAX_DEPTH = 10;
23
+ /** How many matches a search returns, which is more than a menu shows. */
24
+ const MAX_RESULTS = 40;
25
+ /**
26
+ * A path under `root`, said the short way.
27
+ *
28
+ * Everything else comes back as it was: a file chosen from the desktop is not
29
+ * in the project, and a relative path climbing out of it with ../../ is
30
+ * neither shorter nor clearer than where the file actually is.
31
+ */
32
+ export function within(root, target) {
33
+ const rel = path.relative(path.resolve(root), path.resolve(target));
34
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
35
+ return target;
36
+ return rel.split(path.sep).join("/");
37
+ }
38
+ /** Resolve a client-supplied relative path, or undefined if it escapes. */
39
+ function inside(root, rel) {
40
+ const full = path.resolve(root, rel);
41
+ if (full === root)
42
+ return full;
43
+ return full.startsWith(root + path.sep) ? full : undefined;
44
+ }
45
+ function relative(root, full) {
46
+ return path.relative(root, full).split(path.sep).join("/");
47
+ }
48
+ function skip(name) {
49
+ return name.startsWith(".") || SKIP.has(name);
50
+ }
51
+ /**
52
+ * One directory's children, for browsing into a project.
53
+ *
54
+ * A directory that cannot be read lists as empty rather than failing: the
55
+ * menu still shows the way back out of it.
56
+ */
57
+ export async function browseFiles(root, rel = "") {
58
+ const base = path.resolve(root);
59
+ const full = inside(base, rel) ?? base;
60
+ const here = relative(base, full);
61
+ let entries = [];
62
+ try {
63
+ const items = await readdir(full, { withFileTypes: true });
64
+ entries = items
65
+ .filter((item) => !skip(item.name))
66
+ .map((item) => ({
67
+ path: here ? `${here}/${item.name}` : item.name,
68
+ name: item.name,
69
+ dir: item.isDirectory(),
70
+ }))
71
+ // Folders first: browsing is how someone gets to a file, so the rows
72
+ // that go somewhere belong above the rows that end the trip.
73
+ .sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
74
+ }
75
+ catch {
76
+ entries = [];
77
+ }
78
+ return {
79
+ dir: here,
80
+ ...(here ? { parent: relative(base, path.dirname(full)) } : {}),
81
+ entries,
82
+ };
83
+ }
84
+ /**
85
+ * Paths that fuzzily match `query`, best first.
86
+ *
87
+ * Breadth-first, so that when the budget runs out on a large repository it has
88
+ * been spent near the top of the tree — where the file someone is naming from
89
+ * memory almost always is — rather than deep inside one branch of it.
90
+ */
91
+ export async function searchFiles(root, query, limit = MAX_RESULTS) {
92
+ const base = path.resolve(root);
93
+ const needle = query.trim().toLowerCase();
94
+ if (!needle)
95
+ return { entries: [], truncated: false };
96
+ const scored = [];
97
+ const queue = [{ full: base, depth: 0 }];
98
+ let visited = 0;
99
+ let truncated = false;
100
+ while (queue.length) {
101
+ const { full, depth } = queue.shift();
102
+ let items;
103
+ try {
104
+ items = await readdir(full, { withFileTypes: true });
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ for (const item of items) {
110
+ if (skip(item.name))
111
+ continue;
112
+ if (++visited > VISIT_BUDGET) {
113
+ truncated = true;
114
+ queue.length = 0;
115
+ break;
116
+ }
117
+ const child = path.join(full, item.name);
118
+ const rel = relative(base, child);
119
+ const score = rank(rel.toLowerCase(), item.name.toLowerCase(), needle);
120
+ if (score >= 0)
121
+ scored.push({ entry: { path: rel, name: item.name, dir: item.isDirectory() }, score });
122
+ if (item.isDirectory() && depth + 1 < MAX_DEPTH)
123
+ queue.push({ full: child, depth: depth + 1 });
124
+ }
125
+ }
126
+ scored.sort((a, b) => b.score - a.score || a.entry.path.length - b.entry.path.length);
127
+ return {
128
+ entries: scored.slice(0, limit).map((hit) => hit.entry),
129
+ truncated: truncated || scored.length > limit,
130
+ };
131
+ }
132
+ /**
133
+ * How well one path answers a query.
134
+ *
135
+ * Subsequence matching, because that is how people type a path they half
136
+ * remember — "svpage" for "server/src/page.ts". A run of adjacent characters
137
+ * and a match that starts a path segment both count for more than a letter
138
+ * found somewhere in the middle, and matching inside the file name counts for
139
+ * more than matching in the directories above it.
140
+ */
141
+ function rank(relPath, name, needle) {
142
+ const base = subsequence(relPath, needle);
143
+ if (base < 0)
144
+ return -1;
145
+ const inName = subsequence(name, needle);
146
+ // Long paths accumulate incidental matches; without this a deeply nested
147
+ // near-miss outranks the file whose own name is what was typed.
148
+ return base + (inName >= 0 ? inName + 12 : 0) - relPath.length / 40;
149
+ }
150
+ function subsequence(text, needle) {
151
+ let score = 0;
152
+ let at = 0;
153
+ let previous = -2;
154
+ for (const ch of needle) {
155
+ if (ch === " ")
156
+ continue;
157
+ const found = text.indexOf(ch, at);
158
+ if (found === -1)
159
+ return -1;
160
+ score += found === previous + 1 ? 3 : 1;
161
+ if (found === 0 || "/-_. ".includes(text[found - 1] ?? ""))
162
+ score += 2;
163
+ previous = found;
164
+ at = found + 1;
165
+ }
166
+ return score;
167
+ }
168
+ //# sourceMappingURL=files.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"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"}
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * UI copy, in one place per language.
3
+ *
4
+ * English is the source: a key with no translation falls back to it rather
5
+ * than showing the key, because a missing string should degrade to readable
6
+ * text, not to `nav.models`. The locale is chosen from the browser and can be
7
+ * overridden with `?lang=`, so a translation can be checked without changing
8
+ * system settings.
9
+ */
10
+ export declare const STRINGS: Record<string, Record<string, string>>;
11
+ /** Locales offered in the UI, in menu order. */
12
+ export declare const LOCALES: {
13
+ id: string;
14
+ label: string;
15
+ }[];