@globalmpc/nomore 0.0.0-stage → 0.2.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/dist/detect.js ADDED
@@ -0,0 +1,165 @@
1
+ import { normalize, tokens } from "./normalize.js";
2
+ // Spec › Correction signals.
3
+ const LOOKAHEAD = 3;
4
+ const MAX_PHRASE = 400;
5
+ const STARTS = /^(no|nope|don'?t|do not|stop|wrong|not that|instead|undo|revert|i said|i told you)\b/i;
6
+ const CONTAINS = /\b(instead of|i said|i told you|don'?t|do not|never)\b/i;
7
+ const isCorrectionPhrase = (text) => {
8
+ const t = text.trim();
9
+ return t.length <= MAX_PHRASE && (STARTS.test(t) || CONTAINS.test(t.slice(0, 80)));
10
+ };
11
+ export function detectCorrections(events) {
12
+ const signals = new Map(); // index of a user_text event → its signals
13
+ const add = (i, s) => (signals.get(i) ?? signals.set(i, new Set()).get(i)).add(s);
14
+ const nextTyped = (i) => {
15
+ for (let j = i + 1; j <= i + LOOKAHEAD && j < events.length; j++)
16
+ if (events[j].kind === "user_text")
17
+ return j;
18
+ return -1;
19
+ };
20
+ const edits = [];
21
+ events.forEach((e, i) => {
22
+ if (e.kind === "tool_rejected" || e.kind === "user_marker") {
23
+ const j = nextTyped(i);
24
+ if (j >= 0)
25
+ add(j, e.kind === "tool_rejected" ? "rejected" : "interrupt");
26
+ }
27
+ if (e.kind === "user_text" && i > 0 && events[i - 1].kind.startsWith("assistant") && isCorrectionPhrase(e.text ?? ""))
28
+ add(i, "phrase");
29
+ if (e.kind === "assistant_tool_use" && e.tool === "Edit") {
30
+ const { file_path, old_string, new_string } = e.input ?? {};
31
+ if (edits.some((x) => x.file === file_path && x.old === new_string)) {
32
+ for (let j = i - 1; j >= 0; j--)
33
+ if (events[j].kind === "user_text") {
34
+ add(j, "revert");
35
+ break;
36
+ }
37
+ }
38
+ edits.push({ file: file_path, old: old_string });
39
+ }
40
+ });
41
+ return [...signals.keys()].sort((a, b) => a - b).map((i) => {
42
+ const e = events[i];
43
+ const text = e.text ?? "";
44
+ return { uuid: e.uuid, sessionId: e.sessionId, cwd: e.cwd, ts: e.ts, text, norm: normalize(text), signals: [...signals.get(i)].sort() };
45
+ });
46
+ }
47
+ // Spec › Repeated instructions: every typed message becomes clauses; clauses that can't be a lasting preference are
48
+ // dropped. Each clause keeps its message's correction signals for display.
49
+ const MAX_CLAUSE = 120;
50
+ const ACK = new Set("yes ok okay okey proceed go ahead continue sure thanks thank lets let start now then good great fine done next step right cool nice pls plz yea yeah yep keep going oke sip siap ya yuk lanjut terus teruskan ayo gas mantap aja saja silakan".split(" ")); // + Indonesian
51
+ const PLACEHOLDER = /^<(url|file|id|n)>$/;
52
+ const clean = (text) => text
53
+ .replace(/<pasted_content[^>]*>[\s\S]*?<\/pasted_content[^>]*>/g, "\n")
54
+ .replace(/<([\w-]+)[^>]*>[\s\S]*?<\/\1>/g, "\n")
55
+ .replace(/<\/?[\w-]+[^>]*>/g, "\n");
56
+ // Pasted content is judged line by line, before clause splitting, so a log line can't leave a harmless-looking
57
+ // fragment ("…limit reached, retry later" → "retry later"). Spec › Repeated instructions.
58
+ const PASTED = [
59
+ /^\+/, // diff line
60
+ /[{}[\];]|=>|\(['"]/, // code punctuation
61
+ /^[\w$.]+\s*=\s*\S/, // name = value
62
+ /^[\w$.]+:\s*['"`]/, // name: 'value'
63
+ /^import\s+([\w$]+\s*$|.*\bfrom\b)/i, // import json / import x from 'y'
64
+ /^from\s+\S+\s+import\b/i,
65
+ /^(const|let|var)\s+\w+\s*=/,
66
+ /^(def|class|function)\s+\w+\s*[(:{]/,
67
+ /\w\([^)]*\)/, // call: openDb(), load(cfg)
68
+ /^(error|warning|warn|fatal|exception|traceback)\b/i,
69
+ /^at\s+\S+\s+\(/, // stack frame
70
+ /\berror:|exception|traceback|limit (reached|exceeded)/i,
71
+ /:$/, // heading that introduces pasted content
72
+ ];
73
+ const isIdentifier = (line) => /^[A-Za-z_$][\w$]*$/.test(line) && /_|[a-z][A-Z]/.test(line);
74
+ // A typed shell command: a known command and only ASCII arguments, short or with a flag or path, so sentences that
75
+ // start with a command name ("git push only after review", "npm 말고 pnpm 써") are kept.
76
+ const SHELL = /^(git|pnpm|npm|npx|yarn|node|python3?|pip3?|docker|kubectl|curl|wget|cd|ls|rm|mkdir|bash|sh|cargo|brew|gh|tsx|bun|deno|uv|ssh|sudo)(\s+[-\w./@:=~'"&|>]+)*\s*$/i;
77
+ const isShellCommand = (line) => SHELL.test(line) && (line.split(/\s+/).length <= 3 || /\s-{1,2}\w|[/\\]|\.\w/.test(line));
78
+ const isPasted = (line) => isIdentifier(line) || isShellCommand(line) || PASTED.some((re) => re.test(line));
79
+ // One-off requests and questions recur because the task recurs, not because they are lasting preferences.
80
+ const QUESTION = /^(what|whats|what's|how|why|when|where|which|who|can you|could you|would you|do you|is it|are you|should we|apa|apakah|bagaimana|gimana|berapa|kenapa|mengapa|kapan|di mana|dimana|siapa)\b/i;
81
+ const REQUEST = /^(review|check|update|provide|crawl|seed|recommend|let me|lets|let's|then|and|fix|commit|push|continue|implement|create|give|add|retry|redo|try|run|double|do (it|this|that|the)|lanjut|lanjutkan|coba|cek|periksa|perbaiki|benerin|buat|buatkan|bikin|jalankan|tambahkan|hapus|kirim)\b/i;
82
+ // A task verb can still open a lasting preference ("run tests before saying done"); these words keep it.
83
+ const PREFERENCE_CUE = /\b(always|never|every|each|before|after|first|only|myself|by default|from now on|one by one|step by step|instead|selalu|jangan|tanpa|hanya|cuma|sebelum|sesudah|setelah|mulai sekarang|sendiri)\b/i;
84
+ const FILLER = /^((pls|plz|please|yea|yeah|yep|yes|ok|okay|okey|now|so|just|also|tolong|mohon|oke|sip|siap|ya|yuk|silakan|ayo)\b[\s,]*)+/i;
85
+ // A negated instruction ("do not use npm") must never join its positive form ("use npm"); grouping runs per polarity.
86
+ const NEGATION = /\b(not|no|never|don'?t|doesn'?t|didn'?t|isn'?t|aren'?t|won'?t|can'?t|cannot|shouldn'?t|without|jangan|tidak|bukan|tanpa)\b|지\s?마|말고|않|못/i;
87
+ // A contrast tail ("use pnpm, not npm") qualifies a positive instruction; it doesn't negate it.
88
+ const CONTRAST = /^(not|instead of|rather than)\b/i;
89
+ // Without the comma too, but only after a choice verb ("use pnpm not npm", "prefer tabs not spaces"): a bare
90
+ // "X not Y" is usually an evaluation ("force pushes not ok", "do it not now") and stays negated. One word after
91
+ // "not", two or more before, no auxiliary ("use what is not needed"), participle tail or evaluative/adverb tail.
92
+ // ponytail: choice verbs and one-word tails only, so "run vitest not jest" and "use pnpm not the npm cli" stay negated.
93
+ // EVAL_TAIL is not exhaustive: a missed word merges opposites ("use it not now"); extend it when one shows up.
94
+ const CHOICE = /^(use|prefer|pick|choose|write|reply|respond|answer|install|go with|stick (with|to))\b/i;
95
+ const EVAL_TAIL = /^(ok|okay|good|fine|bad|great|now|yet|again|anymore|always|ever|never|much|more|less|later|here|there|yes|no|it|this|that|me|you|them|flaky|needed|allowed|acceptable|ideal|welcome|useful|nice|cool|wise|appropriate|necessary|mandatory|helpful|safe|correct|right|wrong|true|today|tomorrow|first|twice|often|everywhere|anywhere|too|so|really|properly)\W*$/i;
96
+ const AUX = /^(do|does|did|is|are|was|were|am|be|been|can|could|should|would|will|must|may|might|shall|have|has|had|need|try|\w+['’](s|re|m|ve|ll|d))$/i;
97
+ const choiceBody = (text) => text.trim().replace(FILLER, "").replace(/^(always|i said)\s+/i, "");
98
+ const isContrastWord = (w) => !/(ed|ing)\W*$/i.test(w) && !EVAL_TAIL.test(w);
99
+ const dropBareContrast = (text) => {
100
+ const body = choiceBody(text);
101
+ const w = body.split(/\s+/);
102
+ const i = w.length - 2; // where "not" would be
103
+ return CHOICE.test(body) && i >= 2 && /^not$/i.test(w[i]) && !AUX.test(w[i - 1]) && isContrastWord(w[i + 1]) ? w.slice(0, i).join(" ") : text;
104
+ };
105
+ // A ", never …" tail is a contrast too (issue #18) before a description ("…, never an instruction") or, after a
106
+ // tool-choice verb, before one word that isn't a common verb ("use pnpm, never npm"). Any other ", never …" stays
107
+ // an instruction ("commit often, never amend", "answer briefly, never ramble", "use the API, never guess").
108
+ // ponytail: NEVER_VERB is not exhaustive; a missed verb merges ("use X, never <verb>"); extend it when one shows up.
109
+ const TOOL_CHOICE = /^(use|prefer|pick|choose|install|go with|stick (with|to))\b/i;
110
+ const NEVER_VERB = /^(guess|assume|ask|skip|stop|wait|ramble|apologi[sz]e|lie|invent|hardcode|mock|retry|rewrite|refactor|rename|delete|remove|overwrite|force|amend|rebase|squash|merge|push|commit|deploy|publish|release|edit|change|touch|modify|comment|explain|summari[sz]e|speculate|hallucinate|loop|repeat|truncate|ignore)\W*$/i;
111
+ const isContrastTail = (head, tail) => {
112
+ if (CONTRAST.test(tail) || /^never\s+(a|an|the)\s/i.test(tail))
113
+ return true;
114
+ const word = tail.match(/^never\s+(\S+)$/i)?.[1];
115
+ return word !== undefined && TOOL_CHOICE.test(choiceBody(head)) && isContrastWord(word) && !NEVER_VERB.test(word);
116
+ };
117
+ // The text without its ", never …" contrast tails, and those tails ("use pnpm, never npm, never yarn").
118
+ const splitNeverTails = (text) => {
119
+ const tails = [];
120
+ for (let m = text.match(/^(.*),\s*(never\b.*)$/i); m && isContrastTail(m[1], m[2]); m = text.match(/^(.*),\s*(never\b.*)$/i)) {
121
+ tails.unshift(m[2]);
122
+ text = m[1];
123
+ }
124
+ return { head: text, tails };
125
+ };
126
+ export const isNegated = (text) => NEGATION.test(dropBareContrast(splitNeverTails(text.replace(/,\s*(not|instead of|rather than)\b.*$/i, "")).head));
127
+ // Korean (spec › Repeated instructions): Hangul has no \b word boundaries, so these match as substrings.
128
+ const KO_QUESTION = /뭐|무엇|뭔|어떻게|어떤|어디|언제|왜|얼마|몇|누구|어때|어떨까|(나요|까요|까|니|냐|습니까)[??.!~]*$/;
129
+ const KO_REQUEST = /(해\s?줘|주세요|줘|해\s?봐|하자|보자|부탁(해|합니다|드려요)?)[.!~]*$/;
130
+ const KO_PREFERENCE = /항상|절대|지\s?마|말고|없이|금지|대신|앞으로|매번|무조건|먼저|전에|후에|다음부터|직접|자세히|간단히|짧게|쉽게/;
131
+ const keep = (clause, question) => {
132
+ if (clause.length > MAX_CLAUSE || /^([-*#`|>]|\d+\.)/.test(clause))
133
+ return false;
134
+ const body = clause.replace(FILLER, "");
135
+ if (question || QUESTION.test(body) || (REQUEST.test(body) && !PREFERENCE_CUE.test(body)))
136
+ return false;
137
+ if ((KO_QUESTION.test(body) || KO_REQUEST.test(body)) && !KO_PREFERENCE.test(body))
138
+ return false;
139
+ const t = tokens(normalize(clause));
140
+ const words = t.filter((x) => !PLACEHOLDER.test(x) && x.length > 1);
141
+ return !t.includes("<url>") && words.length >= 2 && (t.length - words.length) * 2 <= t.length && !words.every((x) => ACK.has(x));
142
+ };
143
+ // Grouping tokens: plain plurals are singularised so "words" and "word" match. A contrast "never" counts like the
144
+ // stopword "not", so "use pnpm, never npm" groups with "use pnpm, not npm".
145
+ export const clauseTokens = (text) => {
146
+ const { head, tails } = splitNeverTails(text);
147
+ const asNot = head + tails.map((t) => `, not${t.slice("never".length)}`).join("");
148
+ return tokens(normalize(asNot)).map((t) => (t.length > 3 && t.endsWith("s") && !t.endsWith("ss") ? t.slice(0, -1) : t));
149
+ };
150
+ export function extractInstructions(events) {
151
+ const signals = new Map(detectCorrections(events).map((c) => [c.uuid, c.signals]));
152
+ return events.flatMap((e) => {
153
+ if (e.kind !== "user_text")
154
+ return [];
155
+ return clean(e.text ?? "")
156
+ .split("\n")
157
+ .filter((line) => !isPasted(line.trim()))
158
+ .join("\n")
159
+ .split(/\n+|(?<=[.;!??])\s+|,\s+|\s+(?:and|but)\s+/i)
160
+ .reduce((parts, p) => (parts.length && isContrastTail(parts.at(-1), p.trim()) ? [...parts.slice(0, -1), `${parts.at(-1)}, ${p.trim()}`] : [...parts, p]), [])
161
+ .map((raw, i) => ({ c: raw.trim().replace(/[.;!??]+$/, "").trim(), q: /[??]\s*$/.test(raw), i }))
162
+ .filter(({ c, q }) => keep(c, q))
163
+ .map(({ c, i }) => ({ uuid: `${e.uuid}:${i}`, sessionId: e.sessionId, cwd: e.cwd, ts: e.ts, text: c, norm: normalize(c), signals: signals.get(e.uuid) ?? [] }));
164
+ });
165
+ }
package/dist/group.js ADDED
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ export function idf(docs) {
3
+ const df = new Map();
4
+ for (const d of docs)
5
+ for (const t of new Set(d))
6
+ df.set(t, (df.get(t) ?? 0) + 1);
7
+ return new Map([...df].map(([t, n]) => [t, Math.log(1 + docs.length / n)]));
8
+ }
9
+ const sortedSet = (ts) => [...new Set(ts)].sort();
10
+ // Merge over sorted token sets: the sums run in token order, so the result is the same bit for bit both ways.
11
+ function simSorted(a, b, w) {
12
+ let inter = 0, union = 0, i = 0, j = 0;
13
+ while (i < a.length || j < b.length) {
14
+ if (a[i] === b[j]) {
15
+ const x = w.get(a[i]) ?? 0;
16
+ inter += x;
17
+ union += x;
18
+ i++, j++;
19
+ }
20
+ else if (j >= b.length || (i < a.length && a[i] < b[j]))
21
+ union += w.get(a[i++]) ?? 0;
22
+ else
23
+ union += w.get(b[j++]) ?? 0;
24
+ }
25
+ return union === 0 ? 0 : inter / union;
26
+ }
27
+ export function similarity(a, b, w) {
28
+ return simSorted(sortedSet(a), sortedSet(b), w);
29
+ }
30
+ const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
31
+ // Items with no tokens (e.g. a bare "no") can't be compared or turned into a rule, so they are left out.
32
+ export function groupItems(items, tokensOf, threshold) {
33
+ const xs = items.map((x) => ({ x, t: sortedSet(tokensOf(x)) })).filter((e) => e.t.length > 0);
34
+ xs.sort((a, b) => byId(a.x, b.x));
35
+ const toks = xs.map((e) => e.t);
36
+ const w = idf(toks);
37
+ const parent = xs.map((_, i) => i);
38
+ const find = (i) => {
39
+ while (parent[i] !== i)
40
+ i = parent[i] = parent[parent[i]];
41
+ return i;
42
+ };
43
+ const push = (m, k, v) => (m.get(k) ?? m.set(k, []).get(k)).push(v);
44
+ // Identical token sets always join (similarity 1), so only one item per distinct set takes part in the join.
45
+ const byKey = new Map();
46
+ toks.forEach((ts, i) => push(byKey, ts.join(" "), i));
47
+ const uniq = [...byKey.values()].map((same) => {
48
+ same.forEach((i) => (parent[find(i)] = find(same[0])));
49
+ return same[0];
50
+ });
51
+ // Exact prefix filtering. With tokens in a global order (rarest first), any pair with weighted Jaccard ≥ t shares a
52
+ // token within each one's prefix: the leading tokens while the remaining weight could still hold t × its total
53
+ // weight. Common tokens end up in suffixes, so they no longer make every pair a candidate.
54
+ const order = (a, b) => w.get(b) - w.get(a) || (a < b ? -1 : a > b ? 1 : 0);
55
+ const index = new Map();
56
+ const prefixes = uniq.map((i) => {
57
+ const total = toks[i].reduce((s, t) => s + w.get(t), 0);
58
+ let rest = total;
59
+ const prefix = [];
60
+ for (const t of [...toks[i]].sort(order)) {
61
+ if (rest < threshold * total - 1e-9)
62
+ break;
63
+ prefix.push(t);
64
+ rest -= w.get(t);
65
+ }
66
+ return prefix;
67
+ });
68
+ prefixes.forEach((p, u) => p.forEach((t) => push(index, t, u)));
69
+ prefixes.forEach((p, u) => {
70
+ const cands = new Set();
71
+ for (const t of p)
72
+ for (const v of index.get(t))
73
+ if (v > u)
74
+ cands.add(v);
75
+ for (const v of cands) {
76
+ const [i, j] = [uniq[u], uniq[v]];
77
+ if (find(i) !== find(j) && simSorted(toks[i], toks[j], w) >= threshold)
78
+ parent[find(i)] = find(j);
79
+ }
80
+ });
81
+ const comps = new Map();
82
+ xs.forEach((_, i) => push(comps, find(i), i));
83
+ return [...comps.values()]
84
+ .map((idx) => {
85
+ // Score = summed similarity to the other members (pairs without a shared token score 0). Members with the same
86
+ // token set share a score, so it is computed once per distinct set: (copies − 1) × 1 + Σ copies × similarity.
87
+ // Rounded so float noise in the sums can't beat the ts tie-break.
88
+ const keys = new Map();
89
+ idx.forEach((i) => push(keys, toks[i].join(" "), i));
90
+ const reps = [...keys.values()];
91
+ const local = new Map();
92
+ reps.forEach((r, k) => toks[r[0]].forEach((t) => push(local, t, k)));
93
+ const keyScore = reps.map((r, k) => {
94
+ const others = new Set(toks[r[0]].flatMap((t) => local.get(t)).filter((m) => m !== k));
95
+ return Math.round(([...others].reduce((s, m) => s + reps[m].length * simSorted(toks[r[0]], toks[reps[m][0]], w), 0) + r.length - 1) * 1e9);
96
+ });
97
+ const scoreOf = new Map(reps.flatMap((r, k) => r.map((i) => [i, keyScore[k]])));
98
+ const score = (i) => scoreOf.get(i);
99
+ const best = idx
100
+ .map((i) => ({ i, s: score(i) }))
101
+ .sort((a, b) => b.s - a.s || (xs[a.i].x.ts < xs[b.i].x.ts ? -1 : xs[a.i].x.ts > xs[b.i].x.ts ? 1 : a.i - b.i))[0].i;
102
+ return {
103
+ id: createHash("sha1").update(toks[best].join(" ")).digest("hex").slice(0, 8),
104
+ members: idx.map((i) => xs[i].x),
105
+ representative: xs[best].x,
106
+ };
107
+ })
108
+ .sort((a, b) => byId(a.representative, b.representative));
109
+ }
package/dist/issues.js ADDED
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ export const Issue = z.object({
4
+ id: z.string(),
5
+ title: z.string(),
6
+ url: z.string(),
7
+ reactions: z.number(),
8
+ ts: z.string(),
9
+ labels: z.array(z.string()),
10
+ state: z.string(), // "open", or the close reason: "completed", "not_planned", …
11
+ });
12
+ // Only the fields we use; the body is never read.
13
+ const RawIssue = z.object({
14
+ number: z.number(),
15
+ title: z.string().nullable(),
16
+ html_url: z.string(),
17
+ created_at: z.string(),
18
+ labels: z.array(z.object({ name: z.string() })),
19
+ reactions: z.object({ total_count: z.number() }).optional(),
20
+ pull_request: z.unknown().optional(),
21
+ state: z.string(),
22
+ state_reason: z.string().nullable().optional(),
23
+ });
24
+ const EXCLUDED = new Set(["duplicate", "invalid"]);
25
+ // The whole title is a retraction: "Withdrawn by the author", "[withdrawn]", "[Bug] (withdrawn)",
26
+ // "[withdrawn] filed in error". A title that merely starts with the word ("Withdrawn API key …") is kept.
27
+ const WITHDRAWN = /^\s*(?:\[[^\]]{0,30}\]\s*)?[\s\p{P}]*withdrawn(?:[\s\p{P}]+(?:by (?:the )?author|filed in error)\b.*)?[\s\p{P}]*$/iu;
28
+ export function loadIssues(files) {
29
+ // First copy of each issue, with the labels of every copy, so an exclusion label in any file counts.
30
+ const byNumber = new Map();
31
+ for (const file of files) {
32
+ let raw;
33
+ try {
34
+ raw = z.array(RawIssue).parse(JSON.parse(readFileSync(file, "utf8")));
35
+ }
36
+ catch (e) {
37
+ throw new Error(`can't load issues from ${file}: ${e.message}`);
38
+ }
39
+ for (const r of raw) {
40
+ const seen = byNumber.get(r.number) ?? byNumber.set(r.number, { r, labels: new Set() }).get(r.number);
41
+ r.labels.forEach((l) => seen.labels.add(l.name));
42
+ }
43
+ }
44
+ return [...byNumber.keys()]
45
+ .sort((a, b) => a - b)
46
+ .map((n) => byNumber.get(n))
47
+ .filter(({ r, labels }) => r.pull_request === undefined && ![...labels].some((l) => EXCLUDED.has(l)) && !WITHDRAWN.test(r.title ?? ""))
48
+ .map(({ r, labels }) => Issue.parse({
49
+ id: String(r.number),
50
+ title: r.title ?? "",
51
+ url: r.html_url,
52
+ reactions: r.reactions?.total_count ?? 0,
53
+ ts: r.created_at,
54
+ labels: [...labels],
55
+ state: r.state === "open" ? "open" : (r.state_reason ?? r.state),
56
+ }));
57
+ }
58
+ // Strips short leading tags ("[BUG]", "[Feature Request]"); a title written entirely in brackets is kept.
59
+ export const cleanTitle = (title) => title.replace(/^(\s*\[[^\]]{0,30}\])+/, "").trim();
package/dist/mine.js ADDED
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ import { groupItems } from "./group.js";
3
+ import { cleanTitle } from "./issues.js";
4
+ import { normalize, tokens } from "./normalize.js";
5
+ export const IssueGroup = z.object({
6
+ id: z.string(),
7
+ weight: z.number(),
8
+ issues: z.number(),
9
+ reactions: z.number(),
10
+ representative: z.object({ id: z.string(), title: z.string(), url: z.string() }),
11
+ top: z.array(z.object({ id: z.string(), title: z.string(), url: z.string(), reactions: z.number(), state: z.string() })).max(5),
12
+ });
13
+ // Spec › Part 0: titles join at ≥ 0.5; weight = Σ(1 + reactions); ranked by weight, ties by group id.
14
+ export function mineGroups(issues, threshold = 0.5) {
15
+ return groupItems(issues, (i) => tokens(normalize(cleanTitle(i.title))), threshold)
16
+ .map((g) => IssueGroup.parse({
17
+ id: g.id,
18
+ weight: g.members.reduce((s, i) => s + 1 + i.reactions, 0),
19
+ issues: g.members.length,
20
+ reactions: g.members.reduce((s, i) => s + i.reactions, 0),
21
+ representative: { id: g.representative.id, title: cleanTitle(g.representative.title), url: g.representative.url },
22
+ top: [...g.members]
23
+ .sort((a, b) => b.reactions - a.reactions || Number(a.id) - Number(b.id))
24
+ .slice(0, 5)
25
+ .map((i) => ({ id: i.id, title: i.title, url: i.url, reactions: i.reactions, state: i.state })),
26
+ }))
27
+ .sort((a, b) => b.weight - a.weight || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
28
+ }
29
+ // Titles are untrusted: escape markdown and HTML punctuation so they render as plain link text.
30
+ const text = (s) => s.replace(/\s+/g, " ").replace(/[\\`*_[\]()<>#!|~]/g, "\\$&");
31
+ export function renderTopGroups(gs, n) {
32
+ const rows = gs.slice(0, n).map((g, i) => {
33
+ const links = g.top.slice(0, 3).map((t) => `[#${t.id}](${t.url})`).join(" ");
34
+ return `| ${i + 1} | ${g.weight} | ${g.issues} | ${g.reactions} | [${text(g.representative.title)}](${g.representative.url}) | ${links} |`;
35
+ });
36
+ return ["| # | Weight | Issues | Reactions | Representative title | Top issues |", "|---|---|---|---|---|---|", ...rows, ""].join("\n");
37
+ }
@@ -0,0 +1,18 @@
1
+ const STOPWORDS = new Set(("a an the and or but if then so to of in on at by for from with about as into is are was were be been am " +
2
+ "it its this that these those i me my we us our you your he she they them their do does did don t s " +
3
+ "not no please here there just very can will would should could have has had").split(" "));
4
+ export function normalize(text) {
5
+ return text
6
+ .toLowerCase()
7
+ .replace(/https?:\/\/\S+/g, "<url>")
8
+ // name.ext needs a name of 2+ chars with a letter, so "e.g." and "24.x" stay text.
9
+ .replace(/\S*[/\\]\S*|\b(?=[\w-]*[a-z])[\w-]{2,}\.[a-z][a-z0-9]{0,4}\b/g, "<file>")
10
+ .replace(/\b[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b|\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*\d)[0-9a-f]{7,}\b/g, "<id>")
11
+ .replace(/\d+/g, "<n>")
12
+ .replace(/\s+/g, " ")
13
+ .trim();
14
+ }
15
+ export function tokens(norm) {
16
+ // Placeholders stay whole ("<file>"), so they never match the word "file".
17
+ return (norm.match(/<(?:url|file|id|n)>|[\p{L}\p{N}]+/gu) ?? []).filter((t) => !STOPWORDS.has(t));
18
+ }
@@ -0,0 +1,58 @@
1
+ import { join, sep } from "node:path";
2
+ import { codexRuleFile, listRollouts, parseRollout } from "./codex.js";
3
+ import { clauseTokens, extractInstructions, isNegated } from "./detect.js";
4
+ import { groupItems } from "./group.js";
5
+ import { rankGroups } from "./rank.js";
6
+ import { readRuleLines, toSuggestions } from "./rules.js";
7
+ import { listSessionFiles, parseSession } from "./sessions.js";
8
+ import { Report } from "./types.js";
9
+ const GROUP_THRESHOLD = 0.6; // spec › Grouping (clauses)
10
+ export async function runPipeline(opts) {
11
+ const codex = opts.agent === "codex";
12
+ const [list, parse] = codex ? [listRollouts, parseRollout] : [listSessionFiles, parseSession];
13
+ const files = await list(opts.configDir, { sinceMs: opts.nowMs - opts.days * 86_400_000, project: opts.project });
14
+ const corrections = [];
15
+ let skippedLines = 0;
16
+ let scanned = files.length; // codex: only rollouts that yielded events (subagent/exec ones are skipped); claude keeps every file
17
+ let skippedFiles = 0;
18
+ for (const f of files) {
19
+ const s = await parse(f);
20
+ if (codex && s.events.length === 0 && !s.unreadable)
21
+ scanned--; // an unreadable file still counts, as for Claude (#26)
22
+ skippedLines += s.skippedLines;
23
+ if (s.unreadable)
24
+ skippedFiles++;
25
+ corrections.push(...extractInstructions(s.events));
26
+ }
27
+ // Two project paths can share one encoded folder (/tmp/a-b, /tmp/a_b): keep only instructions from inside the project.
28
+ if (opts.project) {
29
+ const root = opts.project;
30
+ corrections.splice(0, corrections.length, ...corrections.filter((c) => c.cwd === root || c.cwd.startsWith(root + sep) || c.cwd.startsWith(root + "/")));
31
+ }
32
+ // Grouping runs per polarity, and negated clauses carry a "<not>" token, so ids never collide across the two.
33
+ const byPolarity = [corrections.filter((c) => !isNegated(c.text)), corrections.filter((c) => isNegated(c.text))];
34
+ const grouped = byPolarity.flatMap((cs, negated) => groupItems(cs.map((c) => ({ id: c.uuid, ts: c.ts, c })), (x) => [...clauseTokens(x.c.text), ...(negated ? ["<not>"] : [])], GROUP_THRESHOLD));
35
+ const groups = grouped.map((g) => {
36
+ const members = g.members.map((m) => m.c);
37
+ return {
38
+ id: g.id,
39
+ members,
40
+ representative: g.representative.c,
41
+ count: members.length,
42
+ sessions: new Set(members.map((m) => m.sessionId)).size,
43
+ projects: new Set(members.map((m) => m.cwd)).size,
44
+ days: new Set(members.map((m) => m.ts.slice(0, 10))).size,
45
+ lastSeen: members.map((m) => m.ts).sort().at(-1),
46
+ signals: [...new Set(members.flatMap((m) => m.signals))].sort(),
47
+ };
48
+ });
49
+ const ruleFile = async (dir) => (codex ? codexRuleFile(dir) : join(dir, "CLAUDE.md"));
50
+ const rules = await readRuleLines([...(opts.project ? [await ruleFile(opts.project)] : []), await ruleFile(opts.configDir)], { imports: !codex });
51
+ return Report.parse({
52
+ scannedSessions: scanned,
53
+ skippedLines,
54
+ skippedFiles,
55
+ corrections: corrections.length,
56
+ suggestions: toSuggestions(rankGroups(groups, opts.nowMs), rules),
57
+ });
58
+ }
package/dist/rank.js ADDED
@@ -0,0 +1,18 @@
1
+ const DAY_MS = 86_400_000;
2
+ const HALF_LIFE_DAYS = 14;
3
+ // Tasks repeat more often than preferences; a preference word in any clause triples a group's score. Korean has no
4
+ // \b word boundaries, so its words are matched as plain substrings.
5
+ const PREFERENCE = /\b(never|don'?t|do not|without|instead|always|only|stop|avoid|prefer|myself|too|selalu|jangan|tanpa|hanya|terlalu)\b|지 ?마|말고|없이|항상|절대|금지|대신|너무/i;
6
+ const BOOST = 3;
7
+ // Spec › Ranking: score = count × sessions × 0.5^(days since last / 14) × boost; shown if sessions ≥ 2.
8
+ export function rankGroups(gs, nowMs) {
9
+ return gs
10
+ .filter((g) => g.sessions >= 2)
11
+ .map((g) => ({
12
+ ...g,
13
+ signals: g.sessions >= 3 && !g.signals.includes("repeated") ? [...g.signals, "repeated"] : g.signals,
14
+ score: g.count * g.sessions * 0.5 ** ((nowMs - Date.parse(g.lastSeen)) / DAY_MS / HALF_LIFE_DAYS) *
15
+ (g.members.some((m) => PREFERENCE.test(m.text)) ? BOOST : 1),
16
+ }))
17
+ .sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
18
+ }
package/dist/report.js ADDED
@@ -0,0 +1,29 @@
1
+ // The public repo; change to the org's repo at handover (README's install link too).
2
+ export const REPO_URL = "https://github.com/globalmpc/nomore";
3
+ const pack = (file) => `Meanwhile, start with the 20-rule pack: ${REPO_URL}/blob/main/rules/${file}`;
4
+ const nothingYet = (p) => `No instructions repeated across sessions yet.\n${p}`;
5
+ export const RULE_PACK = pack("CLAUDE.md");
6
+ export const NOTHING_YET = nothingYet(RULE_PACK);
7
+ export const RULE_PACK_CODEX = pack("AGENTS.md");
8
+ export const NOTHING_YET_CODEX = nothingYet(RULE_PACK_CODEX);
9
+ export const skippedFilesNote = (r) => `${r.skippedFiles} unreadable ${r.skippedFiles === 1 ? "file" : "files"} skipped`;
10
+ const line = (s) => `● ${s.text}\n ${s.count}× in ${s.sessions} sessions, last ${s.lastSeen.slice(0, 10)}\n` +
11
+ s.examples.map((e) => ` “${e.length > 80 ? e.slice(0, 79) + "…" : e}”`).join("\n");
12
+ export function renderReport(r, nothing = NOTHING_YET) {
13
+ const head = `nomore · ${r.scannedSessions} sessions · ${r.corrections} instructions` + (r.skippedLines ? ` · ${r.skippedLines} unreadable lines skipped` : "") +
14
+ (r.skippedFiles ? ` · ${skippedFilesNote(r)}` : "");
15
+ const fresh = r.suggestions.filter((s) => s.status === "new");
16
+ const ignored = r.suggestions.filter((s) => s.status === "ignored");
17
+ if (r.suggestions.length === 0)
18
+ return `${head}\n\n${nothing}\n`;
19
+ return [
20
+ head,
21
+ ...(fresh.length ? ["", "Repeated instructions", "", ...fresh.map(line)] : []),
22
+ ...(ignored.length ? ["", "Rules you have, but the agent ignores", "", ...ignored.map((s) => `${line(s)}\n rule: ${s.matchedRule}`)] : []),
23
+ "",
24
+ ].join("\n");
25
+ }
26
+ // Safe to share: totals only. Suggestion text can hold private words, so it never appears here.
27
+ export function renderCard(r) {
28
+ return `nomore: ${r.scannedSessions} sessions, ${r.corrections} instructions, ${r.suggestions.length} repeated.\n`;
29
+ }
package/dist/rules.js ADDED
@@ -0,0 +1,69 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { isNegated } from "./detect.js";
5
+ import { idf, similarity } from "./group.js";
6
+ import { normalize, tokens } from "./normalize.js";
7
+ import { Suggestion } from "./types.js";
8
+ const RULE_MATCH = 0.3; // spec › Rule check
9
+ // Bullet or sentence lines of the rules files; headings, blanks and missing files are skipped. A line "@path"
10
+ // imports another file in its place (Claude Code's CLAUDE.md imports: relative to the file, or ~/), once each.
11
+ // AGENTS.md has no imports (Codex), so callers pass imports: false and an "@x" line stays plain text.
12
+ export async function readRuleLines(paths, { imports = true } = {}) {
13
+ const seen = new Set();
14
+ const read = async (path) => {
15
+ if (seen.has(path))
16
+ return [];
17
+ seen.add(path);
18
+ const text = await readFile(path, "utf8").catch(() => "");
19
+ const out = [];
20
+ for (const raw of text.split("\n")) {
21
+ const line = raw.trim();
22
+ const imp = imports ? line.match(/^@(\S+)$/) : null;
23
+ if (imp)
24
+ out.push(...(await read(imp[1].startsWith("~/") ? join(homedir(), imp[1].slice(2)) : resolve(dirname(path), imp[1]))));
25
+ else if (line !== "" && !line.startsWith("#"))
26
+ out.push(line.replace(/^([-*]|\d+\.)\s+/, ""));
27
+ }
28
+ return out;
29
+ };
30
+ const lines = [];
31
+ for (const p of paths)
32
+ lines.push(...(await read(resolve(p))));
33
+ return lines;
34
+ }
35
+ const template = (text) => {
36
+ const t = text.trim().replace(/^(no|nope)[,.!]*\s+|^don'?t do that[,.!]*\s+/i, "");
37
+ return t.charAt(0).toUpperCase() + t.slice(1);
38
+ };
39
+ export function toSuggestions(groups, ruleLines) {
40
+ const ruleToks = ruleLines.map((l) => tokens(normalize(l)));
41
+ const repToks = groups.map((g) => tokens(g.representative.norm));
42
+ const w = idf([...ruleToks, ...repToks]);
43
+ return groups.map((g, gi) => {
44
+ let best = -1, bestSim = 0;
45
+ const negated = isNegated(g.representative.text);
46
+ ruleToks.forEach((rt, ri) => {
47
+ if (isNegated(ruleLines[ri]) !== negated)
48
+ return; // "Use npm" doesn't cover "don't use npm"
49
+ const s = similarity(repToks[gi], rt, w);
50
+ if (s > bestSim)
51
+ [best, bestSim] = [ri, s];
52
+ });
53
+ const ignored = best >= 0 && bestSim >= RULE_MATCH;
54
+ return Suggestion.parse({
55
+ id: g.id,
56
+ text: template(g.representative.text),
57
+ status: ignored ? "ignored" : "new",
58
+ ...(ignored ? { matchedRule: ruleLines[best] } : {}),
59
+ count: g.count,
60
+ sessions: g.sessions,
61
+ projects: g.projects,
62
+ days: g.days,
63
+ score: g.score,
64
+ lastSeen: g.lastSeen,
65
+ examples: [...new Set(g.members.map((m) => m.text.trim()))].slice(0, 3),
66
+ signals: g.signals,
67
+ });
68
+ });
69
+ }