opencode-self-improvement 0.3.5 → 0.4.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/README.md +12 -0
- package/package.json +1 -1
- package/src/gates.ts +5 -0
- package/src/index.ts +15 -3
- package/src/init.ts +6 -2
- package/src/inject.ts +11 -6
- package/src/memory.ts +53 -2
- package/src/portable.ts +13 -2
- package/src/repokey.ts +66 -0
- package/src/resolver.ts +5 -1
- package/src/viewer/server.ts +12 -3
package/README.md
CHANGED
|
@@ -231,6 +231,18 @@ as `00:11:25.867`, or a unique substring of its text. Omit it to update the newe
|
|
|
231
231
|
entry of that scope and type. A `query` matching nothing, or matching several rows,
|
|
232
232
|
writes nothing and says so rather than updating the wrong memory.
|
|
233
233
|
|
|
234
|
+
## Repo scoping
|
|
235
|
+
|
|
236
|
+
Memories belong to the repo that wrote them: rows are stamped with your git
|
|
237
|
+
remote (an id that survives re-clones and moves between machines) or, with no
|
|
238
|
+
remote, the repo path. Injection, recall, the frontier block, compaction,
|
|
239
|
+
`memory_forget`, and decay only ever see the current repo plus global rows —
|
|
240
|
+
project A's facts never surface in project B. Preferences and habits that
|
|
241
|
+
should follow you everywhere go in with `memory_remember global: true`.
|
|
242
|
+
Memories written before this existed, and ones restored through the viewer,
|
|
243
|
+
are global (visible everywhere), so upgrading changes nothing; exports carry
|
|
244
|
+
origin stamps, so scoping survives a machine hop.
|
|
245
|
+
|
|
234
246
|
## Backup & move between machines
|
|
235
247
|
|
|
236
248
|
`memory_export` writes the live store as JSONL — one JSON object per line
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
package/src/gates.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface WriteCandidate {
|
|
|
30
30
|
issue?: string;
|
|
31
31
|
tags?: string[];
|
|
32
32
|
status?: "open" | "settled";
|
|
33
|
+
// "global" = cross-project (memory_remember global:true; imports carry a
|
|
34
|
+
// row's origin stamp). Any other value (including undefined) is resolved by
|
|
35
|
+
// appendMemory against the live repo scope (issue #8).
|
|
36
|
+
repo?: string;
|
|
37
|
+
repoPath?: string;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface GateContext {
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { STATUSES, TYPES } from "./types.ts";
|
|
|
17
17
|
import {
|
|
18
18
|
clearSuperseded,
|
|
19
19
|
isActive,
|
|
20
|
+
isVisibleHere,
|
|
20
21
|
loadAll,
|
|
21
22
|
markSuperseded,
|
|
22
23
|
resolveMemoryTarget,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
onCompacting,
|
|
61
62
|
} from "./triggers.ts";
|
|
62
63
|
import { getCurrent, setCurrent } from "./state.ts";
|
|
64
|
+
import { resolveRepo, setRepoScope } from "./repokey.ts";
|
|
63
65
|
import { readLines } from "./logfmt.ts";
|
|
64
66
|
import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
|
|
65
67
|
|
|
@@ -163,6 +165,11 @@ const impl = {
|
|
|
163
165
|
async setup(ctx: PluginCtx): Promise<Cleanup | void> {
|
|
164
166
|
const liveRoot = ctx?.location?.directory ?? process.cwd();
|
|
165
167
|
|
|
168
|
+
// Repo scoping (issue #8): resolve this workspace's identity ONCE. Every
|
|
169
|
+
// append stamps it; every model-facing read filters by it. Never throws —
|
|
170
|
+
// a non-git dir simply scopes by path.
|
|
171
|
+
setRepoScope(resolveRepo(liveRoot));
|
|
172
|
+
|
|
166
173
|
// Character budget for the epoch-frozen injection block: plugin options
|
|
167
174
|
// (object form in opencode.json(c)) win, then OPENCODE_MEMORY_INJECT_BUDGET,
|
|
168
175
|
// then the 1200 default.
|
|
@@ -211,6 +218,10 @@ const impl = {
|
|
|
211
218
|
en(STATUSES),
|
|
212
219
|
"open = live planning thread / frontier (surfaced at session start); settled or omitted = durable knowledge",
|
|
213
220
|
),
|
|
221
|
+
global: withDesc(
|
|
222
|
+
bool(),
|
|
223
|
+
"Store cross-project (visible in every repo): user preferences and general tooling habits. Default false = the memory belongs to THIS repo.",
|
|
224
|
+
),
|
|
214
225
|
},
|
|
215
226
|
["type", "scope", "content"],
|
|
216
227
|
),
|
|
@@ -222,6 +233,7 @@ const impl = {
|
|
|
222
233
|
issue: args.issue,
|
|
223
234
|
tags: args.tags,
|
|
224
235
|
status: args.status,
|
|
236
|
+
repo: args.global ? "global" : undefined,
|
|
225
237
|
};
|
|
226
238
|
const outcome = await runWriteGate(candidate, gateContext());
|
|
227
239
|
if (outcome.action === "promoted") return { content: `Remembered: ${args.type} in ${args.scope}` };
|
|
@@ -256,7 +268,7 @@ const impl = {
|
|
|
256
268
|
options: direct,
|
|
257
269
|
input: obj({ scope: str(), type: en(TYPES), query: str(), limit: num() }),
|
|
258
270
|
async execute(args: any): Promise<{ content: string }> {
|
|
259
|
-
let results = (await loadAll()).filter(isActive);
|
|
271
|
+
let results = (await loadAll()).filter(isActive).filter(isVisibleHere);
|
|
260
272
|
if (!results.length) return { content: "No memories found" };
|
|
261
273
|
const total = results.length;
|
|
262
274
|
if (args.scope) results = results.filter((m) => m.scope.includes(args.scope!));
|
|
@@ -305,7 +317,7 @@ const impl = {
|
|
|
305
317
|
),
|
|
306
318
|
async execute(args: any): Promise<{ content: string }> {
|
|
307
319
|
const prior = (await loadAll()).filter(
|
|
308
|
-
(m) => m.scope === args.scope && m.type === args.type && isActive(m),
|
|
320
|
+
(m) => m.scope === args.scope && m.type === args.type && isActive(m) && isVisibleHere(m),
|
|
309
321
|
);
|
|
310
322
|
// Resolve the row BEFORE the gate: an update that cannot identify its
|
|
311
323
|
// target must write nothing, rather than silently superseding whichever
|
|
@@ -369,7 +381,7 @@ const impl = {
|
|
|
369
381
|
options: direct,
|
|
370
382
|
input: obj({}),
|
|
371
383
|
async execute(): Promise<{ content: string }> {
|
|
372
|
-
const mems = (await loadAll()).filter(isActive);
|
|
384
|
+
const mems = (await loadAll()).filter(isActive).filter(isVisibleHere);
|
|
373
385
|
if (!mems.length) return { content: "No memories found" };
|
|
374
386
|
const scopes = new Map<string, number>();
|
|
375
387
|
const types = new Map<string, number>();
|
package/src/init.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { basename, join } from "node:path";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { cbmBin } from "./config.ts";
|
|
11
|
-
import { loadAll, markSuperseded } from "./memory.ts";
|
|
11
|
+
import { isVisibleHere, loadAll, markSuperseded } from "./memory.ts";
|
|
12
12
|
import { runWriteGate, type GateContext } from "./gates.ts";
|
|
13
13
|
import { record } from "./evidence.ts";
|
|
14
14
|
import { evidenceCalls, hasGateCheck } from "./triggers.ts";
|
|
@@ -185,7 +185,11 @@ export interface InitStamp {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
export async function initStamp(scope: string): Promise<InitStamp | null> {
|
|
188
|
-
|
|
188
|
+
// Issue #8: scope is a basename, so two different repos can share one
|
|
189
|
+
// ("api" next to "api", a fork beside its upstream). Visibility filtering
|
|
190
|
+
// makes each repo see only its own stamp — basename collisions no longer
|
|
191
|
+
// alias init state between repos.
|
|
192
|
+
const mems = (await loadAll()).filter((m) => !m.superseded_by && isVisibleHere(m));
|
|
189
193
|
for (const m of mems) {
|
|
190
194
|
if (m.type !== "context" || m.scope !== scope) continue;
|
|
191
195
|
if (!m.tags?.some((t) => t === "init-stamp")) continue;
|
package/src/inject.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// results. Evidence is never injected (invariant I3). Open threads (status=
|
|
6
6
|
// "open") get a single capped frontier block in the digest so planning sessions
|
|
7
7
|
// start at the previous session's frontier instead of re-deriving it.
|
|
8
|
-
import { isActive, loadAll, type Memory } from "./memory.ts";
|
|
8
|
+
import { isActive, loadAll, visibleHere, type Memory } from "./memory.ts";
|
|
9
9
|
import { record } from "./evidence.ts";
|
|
10
10
|
import { dateStr } from "./config.ts";
|
|
11
11
|
import { bracketList } from "./logfmt.ts";
|
|
@@ -93,7 +93,8 @@ export async function systemDigest(
|
|
|
93
93
|
_input: unknown,
|
|
94
94
|
output: TransformOutput,
|
|
95
95
|
): Promise<void> {
|
|
96
|
-
|
|
96
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
97
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
97
98
|
if (!mems.length) return;
|
|
98
99
|
const latest = mems.map((m) => m.ts).sort().pop() ?? "?";
|
|
99
100
|
output.system.push(
|
|
@@ -143,7 +144,8 @@ export async function systemDigest(
|
|
|
143
144
|
export async function relevantKnowledge(message: string, limit = 3): Promise<string> {
|
|
144
145
|
const text = message.trim();
|
|
145
146
|
if (!text) return "";
|
|
146
|
-
|
|
147
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
148
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
147
149
|
const top = scoreMemories(text, mems, limit);
|
|
148
150
|
if (!top.length) return "";
|
|
149
151
|
return `## Relevant durable memory\n${top.map(formatMemory).join("\n")}`;
|
|
@@ -154,7 +156,8 @@ export async function relevantKnowledge(message: string, limit = 3): Promise<str
|
|
|
154
156
|
// injection block is recomputed only when this string changes (or after a
|
|
155
157
|
// compaction). Evidence and superseded rows never affect it (I3, memory.ts).
|
|
156
158
|
export async function storeVersion(): Promise<string> {
|
|
157
|
-
|
|
159
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
160
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
158
161
|
let max = "";
|
|
159
162
|
for (const m of mems) if (m.ts > max) max = m.ts;
|
|
160
163
|
return `${mems.length}:${max}`;
|
|
@@ -193,7 +196,8 @@ const ANCHOR_SETTLED_LIMIT = 5;
|
|
|
193
196
|
const ANCHOR_BUDGET = 2500;
|
|
194
197
|
|
|
195
198
|
export async function compactionAnchor(limit = 12): Promise<string> {
|
|
196
|
-
|
|
199
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
200
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
197
201
|
if (!mems.length) return "";
|
|
198
202
|
const byNewest = (a: Memory, b: Memory) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0);
|
|
199
203
|
const open = mems
|
|
@@ -218,7 +222,8 @@ export async function contextualPull(input: PullInput, output: PullOutput): Prom
|
|
|
218
222
|
if (!["grep", "glob", "read"].includes(tool)) return;
|
|
219
223
|
const term = argText(input.args);
|
|
220
224
|
if (!term) return;
|
|
221
|
-
|
|
225
|
+
// Repo-scoped (issue #8): model-facing reads see this repo + global rows.
|
|
226
|
+
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
222
227
|
const top = scoreMemories(term, mems, 3);
|
|
223
228
|
if (!top.length) return;
|
|
224
229
|
const block = top.map(formatMemory).join("\n");
|
package/src/memory.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { memoryRoot, deletionsFile, snapshotDir } from "./config.ts";
|
|
8
|
+
import { repoScope } from "./repokey.ts";
|
|
8
9
|
import { appendLog, ensureDir, formatLine, parseLine, readLines } from "./logfmt.ts";
|
|
9
10
|
import { blockedTs } from "./blocklist.ts";
|
|
10
11
|
|
|
@@ -28,6 +29,12 @@ export interface Memory {
|
|
|
28
29
|
superseded_by?: string;
|
|
29
30
|
reason?: string;
|
|
30
31
|
status?: MemoryStatus;
|
|
32
|
+
// Origin repo (issue #8): sha1 of the normalized origin URL, the toplevel
|
|
33
|
+
// path when the repo had no remote, the literal "global" for cross-project
|
|
34
|
+
// writes, or absent for legacy/viewer rows — all three read as described
|
|
35
|
+
// in isVisibleHere().
|
|
36
|
+
repo?: string;
|
|
37
|
+
repoPath?: string;
|
|
31
38
|
file: string;
|
|
32
39
|
index: number;
|
|
33
40
|
}
|
|
@@ -56,6 +63,8 @@ function toMemory(fields: Record<string, unknown>, file: string, index: number):
|
|
|
56
63
|
reason: typeof fields.reason === "string" ? fields.reason : undefined,
|
|
57
64
|
status:
|
|
58
65
|
fields.status === "open" || fields.status === "settled" ? fields.status : undefined,
|
|
66
|
+
repo: typeof fields.repo === "string" ? fields.repo : undefined,
|
|
67
|
+
repoPath: typeof fields.repoPath === "string" ? fields.repoPath : undefined,
|
|
59
68
|
file,
|
|
60
69
|
index,
|
|
61
70
|
};
|
|
@@ -89,7 +98,9 @@ export async function loadAll(): Promise<Memory[]> {
|
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
export async function loadScope(scope: string): Promise<Memory[]> {
|
|
92
|
-
|
|
101
|
+
// Gate dedup/stale reads are repo-scoped: a row from another repo must not
|
|
102
|
+
// collapse or supersede a write here (issue #8).
|
|
103
|
+
return (await loadAll()).filter((m) => m.scope === scope && isVisibleHere(m));
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
export interface AppendMemoryInput {
|
|
@@ -99,6 +110,11 @@ export interface AppendMemoryInput {
|
|
|
99
110
|
issue?: string;
|
|
100
111
|
tags?: string[];
|
|
101
112
|
status?: MemoryStatus;
|
|
113
|
+
// "global" for explicit cross-project writes; imports carry the row's own
|
|
114
|
+
// origin stamp (hash survives the machine hop — see repokey.ts). Any other
|
|
115
|
+
// value is resolved by appendMemory against the live repo scope.
|
|
116
|
+
repo?: string;
|
|
117
|
+
repoPath?: string;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
// Existing ts values across all memory files, used to guarantee uniqueness of
|
|
@@ -130,6 +146,18 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
130
146
|
while (taken.has(ts) && guard++ < 1000) {
|
|
131
147
|
ts = new Date(new Date(ts).getTime() + 1).toISOString();
|
|
132
148
|
}
|
|
149
|
+
// Repo stamp (issue #8) at the single write choke point: gated writes,
|
|
150
|
+
// viewer restores and edits all pass through here. Explicit "global"
|
|
151
|
+
// survives; an unscoped process (viewer) stamps nothing = global.
|
|
152
|
+
let repo = input.repo;
|
|
153
|
+
let repoPath = input.repoPath;
|
|
154
|
+
if (repo === undefined) {
|
|
155
|
+
const cur = repoScope();
|
|
156
|
+
if (cur) {
|
|
157
|
+
repo = cur.key;
|
|
158
|
+
repoPath = cur.path;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
133
161
|
const file = join(memoryRoot(), `${ts.split("T")[0]}.logfmt`);
|
|
134
162
|
await appendLog(
|
|
135
163
|
file,
|
|
@@ -141,6 +169,8 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
141
169
|
issue: input.issue,
|
|
142
170
|
tags: input.tags?.length ? input.tags.join(",") : undefined,
|
|
143
171
|
status: input.status,
|
|
172
|
+
repo,
|
|
173
|
+
repoPath,
|
|
144
174
|
}),
|
|
145
175
|
);
|
|
146
176
|
return ts;
|
|
@@ -333,7 +363,10 @@ export async function prune(scope: string, type: string, reason: string): Promis
|
|
|
333
363
|
for (const line of lines) {
|
|
334
364
|
const parsed = parseLine(line);
|
|
335
365
|
const m = parsed ? toMemory(parsed, file, 0) : null;
|
|
336
|
-
|
|
366
|
+
// Repo floor (issue #8): forgetting scope X here must never delete
|
|
367
|
+
// another repo's row for scope X — same basename collisions are real
|
|
368
|
+
// (a fork next to its upstream).
|
|
369
|
+
if (m && m.scope === scope && m.type === type && isVisibleHere(m)) {
|
|
337
370
|
deleted.push({ memory: m, reason });
|
|
338
371
|
await logDeletion(m, reason);
|
|
339
372
|
changed = true;
|
|
@@ -349,3 +382,21 @@ export async function prune(scope: string, type: string, reason: string): Promis
|
|
|
349
382
|
export function isActive(m: Memory): boolean {
|
|
350
383
|
return !m.superseded_by;
|
|
351
384
|
}
|
|
385
|
+
|
|
386
|
+
// The ONLY repo filter the model-facing paths use (injection sinks, recall,
|
|
387
|
+
// list, update targeting, prune, decay, gate scope reads). A row is visible
|
|
388
|
+
// when it is global (explicit marker OR no stamp — legacy rows and writes by
|
|
389
|
+
// processes with no scope, e.g. the viewer), or was written in this repo.
|
|
390
|
+
// Keeping it one predicate is what stops the filtering from drifting between
|
|
391
|
+
// entry points the way pre-zod validation did.
|
|
392
|
+
export function isVisibleHere(m: Memory): boolean {
|
|
393
|
+
if (!m.repo || m.repo === "global") return true;
|
|
394
|
+
const cur = repoScope();
|
|
395
|
+
if (!cur) return true; // unscoped process: no partitioning
|
|
396
|
+
return m.repo === cur.key || (!!m.repoPath && m.repoPath === cur.path);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Rows visible to the current process (active filtering stays with callers).
|
|
400
|
+
export function visibleHere(mems: Memory[]): Memory[] {
|
|
401
|
+
return mems.filter(isVisibleHere);
|
|
402
|
+
}
|
package/src/portable.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// moat. Decay is supersede-not-delete and snapshot-first, mirroring prune.
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { memoryRoot } from "./config.ts";
|
|
8
|
-
import { isActive, loadAll, type Memory } from "./memory.ts";
|
|
8
|
+
import { isActive, isVisibleHere, loadAll, type Memory } from "./memory.ts";
|
|
9
9
|
import { TYPES } from "./types.ts";
|
|
10
10
|
import type { WriteCandidate } from "./gates.ts";
|
|
11
11
|
|
|
@@ -45,6 +45,10 @@ export async function exportMemories(opts: ExportOptions = {}): Promise<ExportRe
|
|
|
45
45
|
status: m.status,
|
|
46
46
|
tags: m.tags,
|
|
47
47
|
issue: m.issue,
|
|
48
|
+
// Origin stamps ride along so scoping survives the machine hop (the
|
|
49
|
+
// repo key is a remote-URL hash; the path is the secondary match).
|
|
50
|
+
repo: m.repo,
|
|
51
|
+
repoPath: m.repoPath,
|
|
48
52
|
}),
|
|
49
53
|
);
|
|
50
54
|
await Bun.write(path, lines.length ? lines.join("\n") + "\n" : "");
|
|
@@ -79,6 +83,11 @@ function parseRow(line: string): WriteCandidate | null {
|
|
|
79
83
|
if (r.status === "open" || r.status === "settled") c.status = r.status;
|
|
80
84
|
if (Array.isArray(r.tags) && r.tags.every((t) => typeof t === "string")) c.tags = r.tags;
|
|
81
85
|
if (typeof r.issue === "string" && r.issue) c.issue = r.issue;
|
|
86
|
+
// Origin stamps survive an export/import hop (repokey.ts): a row written
|
|
87
|
+
// for repo X stays visible only where repo X is opened, even on a new
|
|
88
|
+
// machine. Unstamped rows stay global.
|
|
89
|
+
if (typeof r.repo === "string" && r.repo) c.repo = r.repo;
|
|
90
|
+
if (typeof r.repoPath === "string" && r.repoPath) c.repoPath = r.repoPath;
|
|
82
91
|
return c;
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -116,6 +125,8 @@ export async function importMemories(
|
|
|
116
125
|
export async function findStaleOpen(days: number): Promise<Memory[]> {
|
|
117
126
|
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
118
127
|
return (await loadAll())
|
|
119
|
-
|
|
128
|
+
// Decay is repo-scoped (issue #8): running it here must not retire
|
|
129
|
+
// another repo's open threads.
|
|
130
|
+
.filter((m) => isActive(m) && isVisibleHere(m) && m.status === "open" && m.ts < cutoff)
|
|
120
131
|
.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
121
132
|
}
|
package/src/repokey.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Repo identity for memory scoping (issue #8). One plugin process serves one
|
|
2
|
+
// workspace, so the scope resolves ONCE at setup; every store write stamps
|
|
3
|
+
// it and every model-facing read filters by it.
|
|
4
|
+
//
|
|
5
|
+
// Two keys, both cheap, with opposite failure modes:
|
|
6
|
+
// key — sha1 of the normalized origin URL: survives re-clone, moves, and
|
|
7
|
+
// export/import to another machine. Absent when the repo has no
|
|
8
|
+
// origin (fresh or remote-less), where the path is used as the key.
|
|
9
|
+
// path — resolved git toplevel: the secondary match, so rows stamped
|
|
10
|
+
// before an origin was added keep recognizing their repo.
|
|
11
|
+
// Rows carry no stamp when their writer had no scope (viewer process,
|
|
12
|
+
// tests) and legacy rows predate the field: both read as GLOBAL — visible
|
|
13
|
+
// everywhere — which keeps every upgrade non-breaking.
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export interface RepoScope {
|
|
17
|
+
key: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let current: RepoScope | null = null;
|
|
22
|
+
|
|
23
|
+
export function setRepoScope(scope: RepoScope | null): void {
|
|
24
|
+
current = scope;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function repoScope(): RepoScope | null {
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// git@github.com:Owner/Repo.git / https://GitHub.com/owner/repo/ ->
|
|
32
|
+
// github.com/owner/repo — the same repo must hash identically whichever
|
|
33
|
+
// remote form (and case) the clone used.
|
|
34
|
+
export function normalizeRemote(url: string): string {
|
|
35
|
+
let u = url.trim().toLowerCase();
|
|
36
|
+
u = u.replace(/^(?:https?:\/\/|ssh:\/\/|git:\/\/)/, "");
|
|
37
|
+
u = u.replace(/^git@/, "");
|
|
38
|
+
u = u.replace(/\/$/, "");
|
|
39
|
+
u = u.replace(/\.git$/, "");
|
|
40
|
+
// scp-style "github.com:owner/repo" -> slash form, so every transport of
|
|
41
|
+
// one repo hashes to one id. (First colon only; URL ports in remotes are
|
|
42
|
+
// rarer than the scp form and merely produce a stable different id.)
|
|
43
|
+
u = u.replace(":", "/");
|
|
44
|
+
return u;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function git(args: string[], cwd: string): string | null {
|
|
48
|
+
try {
|
|
49
|
+
const p = Bun.spawnSync(args, { cwd, stdout: "pipe", stderr: "ignore" });
|
|
50
|
+
const out = p.stdout?.toString().trim();
|
|
51
|
+
return p.exitCode === 0 && out ? out : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve the current repo from any start dir (never throws; a non-git dir
|
|
58
|
+
// simply scopes by its own path).
|
|
59
|
+
export function resolveRepo(startDir: string): RepoScope {
|
|
60
|
+
const path = git(["git", "rev-parse", "--show-toplevel"], startDir) ?? startDir;
|
|
61
|
+
const remote = git(["git", "remote", "get-url", "origin"], path);
|
|
62
|
+
const key = remote
|
|
63
|
+
? createHash("sha1").update(normalizeRemote(remote)).digest("hex").slice(0, 12)
|
|
64
|
+
: path;
|
|
65
|
+
return { key, path };
|
|
66
|
+
}
|
package/src/resolver.ts
CHANGED
|
@@ -116,7 +116,11 @@ export function extractCallForms(content: string): CallForm[] {
|
|
|
116
116
|
return forms;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
// The extension must end the name: without the lookahead `Button.tsx` matched
|
|
120
|
+
// as `Button.ts` and `config.json` as `config.js`, and the existence check then
|
|
121
|
+
// blocked real files. `.tsx.snap` and `.js.map` are other files, not refs; a
|
|
122
|
+
// sentence-ending `.` still ends the ref.
|
|
123
|
+
const PATH_RE = /[A-Za-z0-9_@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs)(?!\w|\.\w)(?::\d+)?/g;
|
|
120
124
|
|
|
121
125
|
export function extractPathRefs(content: string): string[] {
|
|
122
126
|
if (content.length > REGEX_SCAN_MAX_CHARS) return [];
|
package/src/viewer/server.ts
CHANGED
|
@@ -125,11 +125,20 @@ export async function startViewer(port: number = PORT): Promise<ViewerServer> {
|
|
|
125
125
|
const server = serve({
|
|
126
126
|
hostname: HOST,
|
|
127
127
|
port,
|
|
128
|
+
// Without this, an uncaught throw gets Bun's development error page, which
|
|
129
|
+
// includes the stack trace and local file paths. The stack still goes to
|
|
130
|
+
// the terminal.
|
|
131
|
+
error: (e) => {
|
|
132
|
+
console.error(e);
|
|
133
|
+
return error("internal error", 500);
|
|
134
|
+
},
|
|
128
135
|
fetch(req, srv) {
|
|
129
136
|
// Bun builds req.url from the request's Host header. A malformed Host
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
137
|
+
// (`127.0.0.1.8787`, an out-of-range port, `exa mple`) makes it
|
|
138
|
+
// unparseable, and the URL parse THROWs — before rejectRequest could
|
|
139
|
+
// answer. Letting that escape hands the caller Bun's dev error page
|
|
140
|
+
// (stack + absolute paths), so the parse itself is the first guarded
|
|
141
|
+
// step (issue #12).
|
|
133
142
|
let url: URL;
|
|
134
143
|
try {
|
|
135
144
|
url = new URL(req.url);
|