seisin 0.1.1 → 0.3.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.
- package/README.md +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/walls.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The walls a role has already hit.
|
|
3
|
+
*
|
|
4
|
+
* seisin answers *whose is this* at the moment of the refusal, once, in the
|
|
5
|
+
* middle of a turn, and then the sentence is gone. Nothing carries it forward,
|
|
6
|
+
* so the agent tries again — and the measurement that made this module exist
|
|
7
|
+
* says how often: of 345 denials on a real team, **88 (25%) were a repeat of
|
|
8
|
+
* something that same role had already been refused**. One role spent 37 calls
|
|
9
|
+
* on two walls, hitting the same one nineteen times.
|
|
10
|
+
*
|
|
11
|
+
* A block that repeats nineteen times is not a boundary working. It is a
|
|
12
|
+
* boundary that failed to communicate. The refusal was correct every time and
|
|
13
|
+
* still cost thirty-seven calls, because *correct* and *heard* are different
|
|
14
|
+
* properties and only one of them was being measured.
|
|
15
|
+
*
|
|
16
|
+
* So this reads what the role has already been told and hands it back, with
|
|
17
|
+
* the reason — the reason is the part that lets it look for the other way in.
|
|
18
|
+
*
|
|
19
|
+
* ## Why it recomputes instead of trusting the log
|
|
20
|
+
*
|
|
21
|
+
* The obvious version of this is a time window: only count the last two days,
|
|
22
|
+
* because an old wall may have been granted since and telling an agent not to
|
|
23
|
+
* do something it can now do is worse than saying nothing. That is what the
|
|
24
|
+
* prior art does, and the window is a proxy for the thing you actually want to
|
|
25
|
+
* know.
|
|
26
|
+
*
|
|
27
|
+
* seisin can ask the real question instead. The policy is right here, so every
|
|
28
|
+
* candidate is checked against it **now**: if the role would be allowed today,
|
|
29
|
+
* it is not a wall, whatever the log says. Same move as `owners` and the
|
|
30
|
+
* request queue — recompute rather than believe — and it means a grant makes
|
|
31
|
+
* its wall disappear on the next turn instead of two days later.
|
|
32
|
+
*
|
|
33
|
+
* A window is still accepted, and still useful for a different reason: a
|
|
34
|
+
* refusal from three weeks ago is noise even if it is still refused today.
|
|
35
|
+
*/
|
|
36
|
+
import { openSync, readSync, closeSync, statSync } from "node:fs";
|
|
37
|
+
import { read } from "./log.js";
|
|
38
|
+
import { explain } from "./owners.js";
|
|
39
|
+
import { STALE_RUNS, RUN_GAP_MS, runsAfter } from "./requests.js";
|
|
40
|
+
|
|
41
|
+
/** A wall is something you hit more than once. Once is information; twice is a pattern. */
|
|
42
|
+
export const MIN_HITS = 2;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What `role` keeps being denied, most-repeated first.
|
|
46
|
+
*
|
|
47
|
+
* Each entry is `{ action, target, times, reason, owners, firstAt, lastAt }`.
|
|
48
|
+
* The grain is action+target, not the directory above it: two writes into two
|
|
49
|
+
* different repositories are two walls, and merging them hides which one was
|
|
50
|
+
* hit.
|
|
51
|
+
*/
|
|
52
|
+
export function walls(config, role, { file, entries = null, min = MIN_HITS, since = null, limit = 6, fresh = false } = {}) {
|
|
53
|
+
// `entries`: the log already read, for a caller asking about every role at
|
|
54
|
+
// once (the console asks for 32 on a real policy) — one read instead of 32.
|
|
55
|
+
const denied = entries
|
|
56
|
+
? entries.filter((e) => e.role === role && e.verdict === "denied" && (!since || e.at >= since))
|
|
57
|
+
: read(file, { role, verdict: "denied", ...(since ? { since } : {}) });
|
|
58
|
+
|
|
59
|
+
const byKey = new Map();
|
|
60
|
+
for (const e of denied) {
|
|
61
|
+
if (!e.target || !e.action) continue;
|
|
62
|
+
const key = `${e.action}\u0000${e.target}`;
|
|
63
|
+
const seen = byKey.get(key);
|
|
64
|
+
if (seen) { seen.times++; seen.lastAt = e.at; continue; }
|
|
65
|
+
byKey.set(key, { action: e.action, target: e.target, times: 1, firstAt: e.at, lastAt: e.at });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const w of byKey.values()) {
|
|
70
|
+
if (w.times < min) continue;
|
|
71
|
+
// The policy as it stands, not as it stood. A granted wall is not a wall,
|
|
72
|
+
// and the whole point of saying this to an agent is that it is true now.
|
|
73
|
+
const verdict = explain(config, role, w.action, w.target);
|
|
74
|
+
if (verdict.allowed) continue;
|
|
75
|
+
out.push({ ...w, reason: verdict.reason, owners: verdict.owners });
|
|
76
|
+
}
|
|
77
|
+
// A wall the role stopped hitting — it ran STALE_RUNS times since, refused
|
|
78
|
+
// elsewhere but not here — is still true and no longer worth saying. Same
|
|
79
|
+
// rule as an old request. `fresh` drops them; otherwise they are marked.
|
|
80
|
+
for (const w of out) {
|
|
81
|
+
const n = runsAfter(denied, Date.parse(w.lastAt), RUN_GAP_MS);
|
|
82
|
+
if (n >= STALE_RUNS) w.stale = { runs: n };
|
|
83
|
+
}
|
|
84
|
+
const kept = fresh ? out.filter((w) => !w.stale) : out;
|
|
85
|
+
kept.sort((a, b) => b.times - a.times || a.target.localeCompare(b.target));
|
|
86
|
+
return limit > 0 ? kept.slice(0, limit) : kept;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How much of the log `timesHit` will look at.
|
|
91
|
+
*
|
|
92
|
+
* The log is append-only and never rotates — that is the storage design and it
|
|
93
|
+
* is the right one — so "read the file" is O(everything you have ever done),
|
|
94
|
+
* on a path that runs inside the hook. Measured on a 372 KB log: 3 ms per
|
|
95
|
+
* call, which is nothing, and 300 ms at 37 MB, which is not, on every refusal
|
|
96
|
+
* of every turn forever.
|
|
97
|
+
*
|
|
98
|
+
* So it reads the tail. That makes the count **recent repetition** rather than
|
|
99
|
+
* lifetime repetition, and recent is the one the sentence is about anyway: an
|
|
100
|
+
* agent being told "you have hit this three times" means this session, not
|
|
101
|
+
* last month. 4 MB is thousands of entries — far more than a turn produces,
|
|
102
|
+
* and bounded.
|
|
103
|
+
*/
|
|
104
|
+
const TAIL_BYTES = 4 * 1024 * 1024;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How many times this exact denial is already in the recent log for this role.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately NOT the same question as `walls()`: no policy recomputation, no
|
|
110
|
+
* threshold. The hook calls this while deciding, and the only thing it needs is
|
|
111
|
+
* a count — the policy has just been consulted, so asking it again would be
|
|
112
|
+
* asking a question already answered, in the one place that runs on every tool
|
|
113
|
+
* call.
|
|
114
|
+
*/
|
|
115
|
+
export function timesHit(file, role, action, target) {
|
|
116
|
+
let text;
|
|
117
|
+
try {
|
|
118
|
+
const { size } = statSync(file);
|
|
119
|
+
const from = Math.max(0, size - TAIL_BYTES);
|
|
120
|
+
const fd = openSync(file, "r");
|
|
121
|
+
try {
|
|
122
|
+
const buf = Buffer.alloc(size - from);
|
|
123
|
+
readSync(fd, buf, 0, buf.length, from);
|
|
124
|
+
text = buf.toString("utf8");
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
return 0; // no log yet is not an error; it is a first turn
|
|
130
|
+
}
|
|
131
|
+
const lines = text.split("\n");
|
|
132
|
+
// The first line of a tail read is usually half an entry. Dropping it costs
|
|
133
|
+
// one count at most and keeps a truncated line from being parsed as if it
|
|
134
|
+
// were whole.
|
|
135
|
+
if (from0(text, file)) lines.shift();
|
|
136
|
+
let n = 0;
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (!line.trim()) continue;
|
|
139
|
+
let e;
|
|
140
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
141
|
+
if (e.role === role && e.verdict === "denied" && e.action === action && e.target === target) n++;
|
|
142
|
+
}
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Did we start mid-file? Then the first line is a fragment. */
|
|
147
|
+
function from0(text, file) {
|
|
148
|
+
try { return statSync(file).size > text.length; } catch { return false; }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The calls that went into hitting the same wall twice or more.
|
|
153
|
+
*
|
|
154
|
+
* `times - 1` per wall, because the first one is the agent finding out. Every
|
|
155
|
+
* one after that is the cost of not having been able to remember.
|
|
156
|
+
*/
|
|
157
|
+
export function wasted(list) {
|
|
158
|
+
return list.reduce((n, w) => n + w.times - 1, 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The block an agent reads, or `""` when there is nothing to say.
|
|
163
|
+
*
|
|
164
|
+
* Empty rather than "no walls found", because this goes into a prompt: a line
|
|
165
|
+
* that says nothing happened, every turn, is how an agent learns to skip the
|
|
166
|
+
* whole section — and then the one turn it matters, it is skipped too.
|
|
167
|
+
*/
|
|
168
|
+
export function render(list) {
|
|
169
|
+
if (!list.length) return "";
|
|
170
|
+
const lines = [
|
|
171
|
+
"WALLS — you have already been denied these, and the policy still denies them.",
|
|
172
|
+
"Do not retry; the reason is where the other way in is.",
|
|
173
|
+
];
|
|
174
|
+
for (const w of list) {
|
|
175
|
+
lines.push(` ${w.times}× ${w.action} ${w.target}`);
|
|
176
|
+
// The reason for an owned path starts with the path again; the owner is the news.
|
|
177
|
+
lines.push(` ${w.owners?.length ? `belongs to ${w.owners.join(", ")}` : w.reason}`);
|
|
178
|
+
}
|
|
179
|
+
const n = wasted(list);
|
|
180
|
+
// Said to the agent, not about it. One that can see what retrying cost has a
|
|
181
|
+
// reason to stop; one told "do not retry" has an instruction.
|
|
182
|
+
if (n > 0) lines.push(` (${n} of your calls went into retrying these.)`);
|
|
183
|
+
return lines.join("\n") + "\n";
|
|
184
|
+
}
|
package/ui/favicon.svg
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1f2328"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="#2da44e"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="#fff" stroke-width="2.8" stroke-linecap="round"/></svg>
|