portable-agent-layer 0.8.1 → 0.10.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/assets/templates/AGENTS.md.template +6 -0
- package/assets/templates/STEERING-RULES.md +23 -0
- package/package.json +2 -2
- package/src/hooks/LoadContext.ts +1 -4
- package/src/hooks/handlers/rating.ts +9 -49
- package/src/hooks/handlers/reflect-trigger.ts +83 -0
- package/src/hooks/handlers/relationship.ts +8 -5
- package/src/hooks/handlers/session-name.ts +8 -6
- package/src/hooks/handlers/work-learning.ts +1 -0
- package/src/hooks/handlers/work-session.ts +16 -3
- package/src/hooks/lib/claude-md.ts +12 -2
- package/src/hooks/lib/context.ts +31 -21
- package/src/hooks/lib/graduation.ts +6 -4
- package/src/hooks/lib/learning-store.ts +7 -117
- package/src/hooks/lib/log.ts +1 -3
- package/src/hooks/lib/opinions.ts +191 -0
- package/src/hooks/lib/relationship.ts +5 -4
- package/src/hooks/lib/security.ts +3 -0
- package/src/hooks/lib/stop.ts +3 -0
- package/src/hooks/lib/text-similarity.ts +125 -0
- package/src/hooks/lib/work-tracking.ts +1 -1
- package/src/targets/opencode/install.ts +6 -2
- package/src/targets/opencode/plugin.ts +20 -171
- package/src/tools/analyze.ts +49 -15
- package/src/tools/opinion.ts +250 -0
- package/src/tools/relationship-reflect.ts +215 -105
- package/src/hooks/lib/prompts.ts +0 -11
- package/src/tools/eval-principles.ts +0 -234
|
@@ -13,6 +13,7 @@ import { parse } from "./frontmatter";
|
|
|
13
13
|
|
|
14
14
|
export interface FailureEntry {
|
|
15
15
|
slug: string;
|
|
16
|
+
path: string;
|
|
16
17
|
rating: number;
|
|
17
18
|
context: string;
|
|
18
19
|
principle: string;
|
|
@@ -22,11 +23,13 @@ export interface FailureEntry {
|
|
|
22
23
|
|
|
23
24
|
export interface LearningEntry {
|
|
24
25
|
filename: string;
|
|
26
|
+
path: string;
|
|
25
27
|
title: string;
|
|
26
28
|
category: string;
|
|
27
29
|
principle: string;
|
|
28
30
|
date: string;
|
|
29
31
|
insights: string;
|
|
32
|
+
cwd: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
// ── Shared Directory Walker ──
|
|
@@ -79,6 +82,7 @@ export function readFailures(baseDir: string, limit?: number): FailureEntry[] {
|
|
|
79
82
|
|
|
80
83
|
entries.push({
|
|
81
84
|
slug: meta.slug || slug,
|
|
85
|
+
path: capturePath,
|
|
82
86
|
rating: meta.rating ?? 0,
|
|
83
87
|
context: meta.context,
|
|
84
88
|
principle: meta.principle || "",
|
|
@@ -119,6 +123,7 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
|
|
|
119
123
|
category?: string;
|
|
120
124
|
principle?: string;
|
|
121
125
|
date?: string;
|
|
126
|
+
cwd?: string;
|
|
122
127
|
}>(content);
|
|
123
128
|
|
|
124
129
|
if (!meta.title) continue;
|
|
@@ -127,11 +132,13 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
|
|
|
127
132
|
|
|
128
133
|
entries.push({
|
|
129
134
|
filename: file,
|
|
135
|
+
path: resolve(monthDir, file),
|
|
130
136
|
title: meta.title,
|
|
131
137
|
category: meta.category || "algorithm",
|
|
132
138
|
principle: meta.principle || "",
|
|
133
139
|
date: meta.date || "",
|
|
134
140
|
insights: insightsMatch?.[1]?.trim() || "",
|
|
141
|
+
cwd: meta.cwd || "",
|
|
135
142
|
});
|
|
136
143
|
|
|
137
144
|
if (limit && entries.length >= limit) return entries;
|
|
@@ -146,120 +153,3 @@ export function readLearnings(baseDir: string, limit?: number): LearningEntry[]
|
|
|
146
153
|
|
|
147
154
|
return entries;
|
|
148
155
|
}
|
|
149
|
-
|
|
150
|
-
// ── Text Similarity (Jaccard on keywords) ──
|
|
151
|
-
|
|
152
|
-
const STOP_WORDS = new Set([
|
|
153
|
-
"the",
|
|
154
|
-
"a",
|
|
155
|
-
"an",
|
|
156
|
-
"is",
|
|
157
|
-
"was",
|
|
158
|
-
"are",
|
|
159
|
-
"were",
|
|
160
|
-
"be",
|
|
161
|
-
"been",
|
|
162
|
-
"being",
|
|
163
|
-
"have",
|
|
164
|
-
"has",
|
|
165
|
-
"had",
|
|
166
|
-
"do",
|
|
167
|
-
"does",
|
|
168
|
-
"did",
|
|
169
|
-
"will",
|
|
170
|
-
"would",
|
|
171
|
-
"could",
|
|
172
|
-
"should",
|
|
173
|
-
"may",
|
|
174
|
-
"might",
|
|
175
|
-
"can",
|
|
176
|
-
"shall",
|
|
177
|
-
"to",
|
|
178
|
-
"of",
|
|
179
|
-
"in",
|
|
180
|
-
"for",
|
|
181
|
-
"on",
|
|
182
|
-
"with",
|
|
183
|
-
"at",
|
|
184
|
-
"by",
|
|
185
|
-
"from",
|
|
186
|
-
"as",
|
|
187
|
-
"into",
|
|
188
|
-
"through",
|
|
189
|
-
"during",
|
|
190
|
-
"before",
|
|
191
|
-
"after",
|
|
192
|
-
"and",
|
|
193
|
-
"but",
|
|
194
|
-
"or",
|
|
195
|
-
"nor",
|
|
196
|
-
"not",
|
|
197
|
-
"no",
|
|
198
|
-
"so",
|
|
199
|
-
"if",
|
|
200
|
-
"then",
|
|
201
|
-
"than",
|
|
202
|
-
"that",
|
|
203
|
-
"this",
|
|
204
|
-
"it",
|
|
205
|
-
"its",
|
|
206
|
-
"i",
|
|
207
|
-
"you",
|
|
208
|
-
"he",
|
|
209
|
-
"she",
|
|
210
|
-
"we",
|
|
211
|
-
"they",
|
|
212
|
-
"my",
|
|
213
|
-
"your",
|
|
214
|
-
"his",
|
|
215
|
-
"her",
|
|
216
|
-
"our",
|
|
217
|
-
"their",
|
|
218
|
-
"what",
|
|
219
|
-
"which",
|
|
220
|
-
"who",
|
|
221
|
-
"when",
|
|
222
|
-
"where",
|
|
223
|
-
"how",
|
|
224
|
-
"all",
|
|
225
|
-
"each",
|
|
226
|
-
"both",
|
|
227
|
-
"few",
|
|
228
|
-
"more",
|
|
229
|
-
"most",
|
|
230
|
-
"other",
|
|
231
|
-
"some",
|
|
232
|
-
"such",
|
|
233
|
-
"up",
|
|
234
|
-
"out",
|
|
235
|
-
"about",
|
|
236
|
-
"just",
|
|
237
|
-
"also",
|
|
238
|
-
"very",
|
|
239
|
-
"too",
|
|
240
|
-
"only",
|
|
241
|
-
"own",
|
|
242
|
-
]);
|
|
243
|
-
|
|
244
|
-
export function extractKeywords(text: string): Set<string> {
|
|
245
|
-
return new Set(
|
|
246
|
-
text
|
|
247
|
-
.toLowerCase()
|
|
248
|
-
.replace(/[^a-z0-9\s-]/g, " ")
|
|
249
|
-
.split(/\s+/)
|
|
250
|
-
.filter((w) => w.length > 2 && !STOP_WORDS.has(w))
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function similarity(a: string, b: string): number {
|
|
255
|
-
const ka = extractKeywords(a);
|
|
256
|
-
const kb = extractKeywords(b);
|
|
257
|
-
if (ka.size === 0 || kb.size === 0) return 0;
|
|
258
|
-
|
|
259
|
-
let intersection = 0;
|
|
260
|
-
for (const w of ka) {
|
|
261
|
-
if (kb.has(w)) intersection++;
|
|
262
|
-
}
|
|
263
|
-
const union = new Set([...ka, ...kb]).size;
|
|
264
|
-
return union > 0 ? intersection / union : 0;
|
|
265
|
-
}
|
package/src/hooks/lib/log.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Only writes when PAL_DEBUG=1 or when called via logError (always logged).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { appendFileSync, existsSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { appendFileSync, existsSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { paths } from "./paths";
|
|
11
11
|
|
|
@@ -21,8 +21,6 @@ function rotateIfNeeded(): void {
|
|
|
21
21
|
if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > MAX_LOG_SIZE) {
|
|
22
22
|
const prev = `${LOG_FILE}.prev`;
|
|
23
23
|
writeFileSync(prev, "");
|
|
24
|
-
// Swap: current → prev, start fresh
|
|
25
|
-
const { renameSync } = require("node:fs");
|
|
26
24
|
renameSync(LOG_FILE, prev);
|
|
27
25
|
}
|
|
28
26
|
} catch {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opinion store — persistent confidence-tracked opinions about the user.
|
|
3
|
+
*
|
|
4
|
+
* Opinions are promoted from recurring relationship notes (O/B types) by the
|
|
5
|
+
* reflect tool. Confidence evolves with evidence over time.
|
|
6
|
+
* High-confidence opinions (≥85%) are injected into every session context.
|
|
7
|
+
*
|
|
8
|
+
* Storage: memory/relationship/opinions.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { paths } from "./paths";
|
|
14
|
+
import { similarity } from "./text-similarity";
|
|
15
|
+
|
|
16
|
+
// ── Types ──
|
|
17
|
+
|
|
18
|
+
export type EvidenceType = "supporting" | "counter" | "confirmation" | "contradiction";
|
|
19
|
+
export type OpinionCategory = "communication" | "technical" | "workflow" | "general";
|
|
20
|
+
|
|
21
|
+
export interface Evidence {
|
|
22
|
+
date: string;
|
|
23
|
+
type: EvidenceType;
|
|
24
|
+
source: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Opinion {
|
|
28
|
+
id: string;
|
|
29
|
+
statement: string;
|
|
30
|
+
confidence: number;
|
|
31
|
+
category: OpinionCategory;
|
|
32
|
+
evidence: Evidence[];
|
|
33
|
+
created: string;
|
|
34
|
+
updated: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Confidence Deltas (matching original PAI) ──
|
|
38
|
+
|
|
39
|
+
const CONFIDENCE_DELTAS: Record<EvidenceType, number> = {
|
|
40
|
+
supporting: 0.02,
|
|
41
|
+
counter: -0.05,
|
|
42
|
+
confirmation: 0.1,
|
|
43
|
+
contradiction: -0.2,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const MIN_CONFIDENCE = 0.01;
|
|
47
|
+
const MAX_CONFIDENCE = 0.99;
|
|
48
|
+
const HIGH_CONFIDENCE_THRESHOLD = 0.85;
|
|
49
|
+
|
|
50
|
+
// ── Category Classification ──
|
|
51
|
+
|
|
52
|
+
const CATEGORY_MAP: [RegExp, OpinionCategory][] = [
|
|
53
|
+
[/tone|format|response|verbose|brief|concise|explain|direct|style/i, "communication"],
|
|
54
|
+
[/code|test|build|deploy|function|type|lint|debug|refactor/i, "technical"],
|
|
55
|
+
[/commit|git|release|workflow|process|approach|iterative|step/i, "workflow"],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function classifyCategory(text: string): OpinionCategory {
|
|
59
|
+
for (const [pattern, category] of CATEGORY_MAP) {
|
|
60
|
+
if (pattern.test(text)) return category;
|
|
61
|
+
}
|
|
62
|
+
return "general";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── File Path ──
|
|
66
|
+
|
|
67
|
+
function opinionsPath(): string {
|
|
68
|
+
return resolve(paths.relationship(), "opinions.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Store Format ──
|
|
72
|
+
|
|
73
|
+
interface OpinionStore {
|
|
74
|
+
lastReflect: string;
|
|
75
|
+
opinions: Opinion[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── CRUD ──
|
|
79
|
+
|
|
80
|
+
function readStore(): OpinionStore {
|
|
81
|
+
const fp = opinionsPath();
|
|
82
|
+
if (!existsSync(fp)) return { lastReflect: "", opinions: [] };
|
|
83
|
+
try {
|
|
84
|
+
const raw = JSON.parse(readFileSync(fp, "utf-8"));
|
|
85
|
+
// Migrate from plain array format
|
|
86
|
+
if (Array.isArray(raw)) return { lastReflect: "", opinions: raw };
|
|
87
|
+
return raw as OpinionStore;
|
|
88
|
+
} catch {
|
|
89
|
+
return { lastReflect: "", opinions: [] };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeStore(store: OpinionStore): void {
|
|
94
|
+
writeFileSync(opinionsPath(), JSON.stringify(store, null, 2), "utf-8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function readOpinions(): Opinion[] {
|
|
98
|
+
return readStore().opinions;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getLastReflectDate(): string {
|
|
102
|
+
return readStore().lastReflect;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function setLastReflectDate(date: string): void {
|
|
106
|
+
const store = readStore();
|
|
107
|
+
store.lastReflect = date;
|
|
108
|
+
writeStore(store);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function slugify(text: string): string {
|
|
112
|
+
return text
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9\s]/g, "")
|
|
115
|
+
.trim()
|
|
116
|
+
.split(/\s+/)
|
|
117
|
+
.slice(0, 6)
|
|
118
|
+
.join("-");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Find an existing opinion similar to the given text. Returns the opinion or null. */
|
|
122
|
+
export function findSimilarOpinion(
|
|
123
|
+
text: string,
|
|
124
|
+
opinions: Opinion[],
|
|
125
|
+
threshold = 0.3
|
|
126
|
+
): Opinion | null {
|
|
127
|
+
for (const op of opinions) {
|
|
128
|
+
if (similarity(text, op.statement) >= threshold) return op;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Create a new opinion from a recurring note. Starts at confidence 0.50. */
|
|
134
|
+
export function createOpinion(statement: string, source: string): Opinion {
|
|
135
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
136
|
+
return {
|
|
137
|
+
id: slugify(statement),
|
|
138
|
+
statement,
|
|
139
|
+
confidence: 0.5,
|
|
140
|
+
category: classifyCategory(statement),
|
|
141
|
+
evidence: [{ date: now, type: "supporting", source }],
|
|
142
|
+
created: now,
|
|
143
|
+
updated: now,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Add evidence to an opinion and adjust its confidence. */
|
|
148
|
+
export function addEvidence(
|
|
149
|
+
opinion: Opinion,
|
|
150
|
+
type: EvidenceType,
|
|
151
|
+
source: string
|
|
152
|
+
): Opinion {
|
|
153
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
154
|
+
const delta = CONFIDENCE_DELTAS[type];
|
|
155
|
+
const newConfidence = Math.min(
|
|
156
|
+
MAX_CONFIDENCE,
|
|
157
|
+
Math.max(MIN_CONFIDENCE, opinion.confidence + delta)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
...opinion,
|
|
162
|
+
confidence: Math.round(newConfidence * 100) / 100,
|
|
163
|
+
evidence: [...opinion.evidence, { date: now, type, source }],
|
|
164
|
+
updated: now,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Upsert an opinion into the store. */
|
|
169
|
+
export function saveOpinion(opinion: Opinion): void {
|
|
170
|
+
const store = readStore();
|
|
171
|
+
const idx = store.opinions.findIndex((o) => o.id === opinion.id);
|
|
172
|
+
if (idx >= 0) {
|
|
173
|
+
store.opinions[idx] = opinion;
|
|
174
|
+
} else {
|
|
175
|
+
store.opinions.push(opinion);
|
|
176
|
+
}
|
|
177
|
+
writeStore(store);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Context Loading ──
|
|
181
|
+
|
|
182
|
+
/** Load high-confidence opinions formatted for system-reminder injection. */
|
|
183
|
+
export function loadOpinionContext(threshold = HIGH_CONFIDENCE_THRESHOLD): string {
|
|
184
|
+
const opinions = readOpinions().filter((o) => o.confidence >= threshold);
|
|
185
|
+
if (opinions.length === 0) return "";
|
|
186
|
+
|
|
187
|
+
const lines = opinions.map(
|
|
188
|
+
(o) => `- [${o.category}] ${o.statement} (${Math.round(o.confidence * 100)}%)`
|
|
189
|
+
);
|
|
190
|
+
return ["## Tracked Opinions", ...lines].join("\n");
|
|
191
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Notes live at memory/relationship/YYYY-MM/YYYY-MM-DD.md
|
|
6
6
|
* W = world (facts about user's situation)
|
|
7
7
|
* O = opinion (preference with confidence)
|
|
8
|
+
* B = belief (behavioral pattern with confidence)
|
|
8
9
|
*
|
|
9
10
|
* Extraction is handled by the relationship handler via Haiku inference.
|
|
10
11
|
* This lib provides storage and reading utilities only.
|
|
@@ -14,7 +15,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
14
15
|
import { resolve } from "node:path";
|
|
15
16
|
import { ensureDir, paths } from "./paths";
|
|
16
17
|
|
|
17
|
-
export type NoteType = "W" | "O";
|
|
18
|
+
export type NoteType = "W" | "O" | "B";
|
|
18
19
|
|
|
19
20
|
export interface RelationshipNote {
|
|
20
21
|
type: NoteType;
|
|
@@ -79,8 +80,8 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): void
|
|
|
79
80
|
if (sessionId) lines.push(`<!-- session:${sessionId} -->`);
|
|
80
81
|
|
|
81
82
|
for (const note of fresh) {
|
|
82
|
-
if (note.type === "O" && note.confidence !== undefined) {
|
|
83
|
-
lines.push(`-
|
|
83
|
+
if ((note.type === "O" || note.type === "B") && note.confidence !== undefined) {
|
|
84
|
+
lines.push(`- ${note.type}(c=${note.confidence}): ${note.text}`);
|
|
84
85
|
} else {
|
|
85
86
|
lines.push(`- ${note.type}: ${note.text}`);
|
|
86
87
|
}
|
|
@@ -103,8 +104,8 @@ export function loadRecentNotes(days: number = 2): string {
|
|
|
103
104
|
const sections: string[] = [];
|
|
104
105
|
|
|
105
106
|
for (const monthDir of readdirSync(relDir).sort().reverse()) {
|
|
107
|
+
if (!/^\d{4}-\d{2}$/.test(monthDir)) continue;
|
|
106
108
|
const monthPath = resolve(relDir, monthDir);
|
|
107
|
-
if (!existsSync(monthPath)) continue;
|
|
108
109
|
|
|
109
110
|
let files: string[];
|
|
110
111
|
try {
|
|
@@ -31,6 +31,8 @@ export const HOOK_MANAGED_FILES = [
|
|
|
31
31
|
"token-usage.jsonl",
|
|
32
32
|
"graduated.json",
|
|
33
33
|
"update-available.json",
|
|
34
|
+
"debug.log.prev",
|
|
35
|
+
"opinions.json",
|
|
34
36
|
];
|
|
35
37
|
|
|
36
38
|
/** Hook-managed directories — AI must not write to or delete from these */
|
|
@@ -40,6 +42,7 @@ export const HOOK_MANAGED_DIRS = [
|
|
|
40
42
|
"memory/learning/session",
|
|
41
43
|
"memory/learning/synthesis",
|
|
42
44
|
"memory/relationship",
|
|
45
|
+
"memory/wisdom/state",
|
|
43
46
|
];
|
|
44
47
|
|
|
45
48
|
/** Escape a string for use in a RegExp */
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
import { autoBackup } from "../handlers/backup";
|
|
9
9
|
import { captureFailure } from "../handlers/failure";
|
|
10
|
+
import { checkReflectTrigger } from "../handlers/reflect-trigger";
|
|
10
11
|
import { captureRelationship } from "../handlers/relationship";
|
|
11
12
|
import { resetTab } from "../handlers/tab";
|
|
12
13
|
import { updateCounts } from "../handlers/update-counts";
|
|
@@ -43,6 +44,7 @@ export async function runStopHandlers(
|
|
|
43
44
|
checkPendingFailure(transcript),
|
|
44
45
|
updateCounts(),
|
|
45
46
|
autoBackup(),
|
|
47
|
+
checkReflectTrigger(),
|
|
46
48
|
]);
|
|
47
49
|
|
|
48
50
|
const handlerNames = [
|
|
@@ -53,6 +55,7 @@ export async function runStopHandlers(
|
|
|
53
55
|
"pending-failure",
|
|
54
56
|
"update-counts",
|
|
55
57
|
"backup",
|
|
58
|
+
"reflect-trigger",
|
|
56
59
|
];
|
|
57
60
|
for (let i = 0; i < results.length; i++) {
|
|
58
61
|
const r = results[i];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text similarity using Dice coefficient on keyword sets.
|
|
3
|
+
*
|
|
4
|
+
* Dice is more generous than Jaccard for short, variable-length texts
|
|
5
|
+
* (like chatbot messages and failure contexts) because it divides by
|
|
6
|
+
* average set size instead of union:
|
|
7
|
+
*
|
|
8
|
+
* Dice = 2 * |intersection| / (|A| + |B|)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const STOP_WORDS = new Set([
|
|
12
|
+
"the",
|
|
13
|
+
"a",
|
|
14
|
+
"an",
|
|
15
|
+
"is",
|
|
16
|
+
"was",
|
|
17
|
+
"are",
|
|
18
|
+
"were",
|
|
19
|
+
"be",
|
|
20
|
+
"been",
|
|
21
|
+
"being",
|
|
22
|
+
"have",
|
|
23
|
+
"has",
|
|
24
|
+
"had",
|
|
25
|
+
"do",
|
|
26
|
+
"does",
|
|
27
|
+
"did",
|
|
28
|
+
"will",
|
|
29
|
+
"would",
|
|
30
|
+
"could",
|
|
31
|
+
"should",
|
|
32
|
+
"may",
|
|
33
|
+
"might",
|
|
34
|
+
"can",
|
|
35
|
+
"shall",
|
|
36
|
+
"to",
|
|
37
|
+
"of",
|
|
38
|
+
"in",
|
|
39
|
+
"for",
|
|
40
|
+
"on",
|
|
41
|
+
"with",
|
|
42
|
+
"at",
|
|
43
|
+
"by",
|
|
44
|
+
"from",
|
|
45
|
+
"as",
|
|
46
|
+
"into",
|
|
47
|
+
"through",
|
|
48
|
+
"during",
|
|
49
|
+
"before",
|
|
50
|
+
"after",
|
|
51
|
+
"and",
|
|
52
|
+
"but",
|
|
53
|
+
"or",
|
|
54
|
+
"nor",
|
|
55
|
+
"not",
|
|
56
|
+
"no",
|
|
57
|
+
"so",
|
|
58
|
+
"if",
|
|
59
|
+
"then",
|
|
60
|
+
"than",
|
|
61
|
+
"that",
|
|
62
|
+
"this",
|
|
63
|
+
"it",
|
|
64
|
+
"its",
|
|
65
|
+
"i",
|
|
66
|
+
"you",
|
|
67
|
+
"he",
|
|
68
|
+
"she",
|
|
69
|
+
"we",
|
|
70
|
+
"they",
|
|
71
|
+
"my",
|
|
72
|
+
"your",
|
|
73
|
+
"his",
|
|
74
|
+
"her",
|
|
75
|
+
"our",
|
|
76
|
+
"their",
|
|
77
|
+
"what",
|
|
78
|
+
"which",
|
|
79
|
+
"who",
|
|
80
|
+
"when",
|
|
81
|
+
"where",
|
|
82
|
+
"how",
|
|
83
|
+
"all",
|
|
84
|
+
"each",
|
|
85
|
+
"both",
|
|
86
|
+
"few",
|
|
87
|
+
"more",
|
|
88
|
+
"most",
|
|
89
|
+
"other",
|
|
90
|
+
"some",
|
|
91
|
+
"such",
|
|
92
|
+
"up",
|
|
93
|
+
"out",
|
|
94
|
+
"about",
|
|
95
|
+
"just",
|
|
96
|
+
"also",
|
|
97
|
+
"very",
|
|
98
|
+
"too",
|
|
99
|
+
"only",
|
|
100
|
+
"own",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
export function extractKeywords(text: string): Set<string> {
|
|
104
|
+
return new Set(
|
|
105
|
+
text
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
108
|
+
.split(/\s+/)
|
|
109
|
+
.filter((w) => w.length > 2 && !STOP_WORDS.has(w))
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Dice coefficient on keyword sets. Returns 0-1. */
|
|
114
|
+
export function similarity(a: string, b: string): number {
|
|
115
|
+
const ka = extractKeywords(a);
|
|
116
|
+
const kb = extractKeywords(b);
|
|
117
|
+
if (ka.size === 0 || kb.size === 0) return 0;
|
|
118
|
+
|
|
119
|
+
let intersection = 0;
|
|
120
|
+
for (const w of ka) {
|
|
121
|
+
if (kb.has(w)) intersection++;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (2 * intersection) / (ka.size + kb.size);
|
|
125
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structured work tracking: session history + persistent projects.
|
|
3
|
-
*
|
|
3
|
+
* Used by both Claude Code (StopOrchestrator) and opencode (plugin).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Deploys plugin, installs skills, generates AGENTS.md.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
|
|
9
9
|
import { palPkg, platform } from "../../hooks/lib/paths";
|
|
@@ -15,7 +15,11 @@ const OC_PLUGINS_DIR = resolve(OC_GLOBAL_DIR, "plugins");
|
|
|
15
15
|
|
|
16
16
|
mkdirSync(OC_PLUGINS_DIR, { recursive: true });
|
|
17
17
|
|
|
18
|
-
// --- 1. Deploy plugin ---
|
|
18
|
+
// --- 1. Deploy plugin (clean up legacy filename) ---
|
|
19
|
+
const legacyPlugin = resolve(OC_PLUGINS_DIR, "pai-plugin.ts");
|
|
20
|
+
if (existsSync(legacyPlugin)) {
|
|
21
|
+
unlinkSync(legacyPlugin);
|
|
22
|
+
}
|
|
19
23
|
const pluginSrc = resolve(PKG_ROOT, "src", "targets", "opencode", "plugin.ts");
|
|
20
24
|
const pluginDst = resolve(OC_PLUGINS_DIR, "pal-plugin.ts");
|
|
21
25
|
// Embed PKG_ROOT as a hardcoded constant so no env config is needed
|