om-memory-system 4.30.1 → 4.31.2
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 +1 -0
- package/dist/adapters/opencode/conversation.js +22 -17
- package/dist/adapters/opencode/skills-tool.d.ts +3 -0
- package/dist/adapters/opencode/skills-tool.js +31 -0
- package/dist/adapters/pi/capture.js +39 -23
- package/dist/adapters/pi/extension.d.ts +1 -0
- package/dist/adapters/pi/extension.js +3 -0
- package/dist/adapters/pi/skills-tool.d.ts +3 -0
- package/dist/adapters/pi/skills-tool.js +34 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/skills-command.d.ts +12 -0
- package/dist/cli/skills-command.js +242 -0
- package/dist/cli/skills-record-past.d.ts +5 -0
- package/dist/cli/skills-record-past.js +85 -0
- package/dist/config-template.d.ts +1 -1
- package/dist/config-template.js +12 -0
- package/dist/config.d.ts +64 -48
- package/dist/config.js +28 -5
- package/dist/core/host.d.ts +7 -0
- package/dist/core/profile-backoff.d.ts +16 -8
- package/dist/core/profile-backoff.js +25 -16
- package/dist/core/skill-agent-modes.d.ts +4 -0
- package/dist/core/skill-agent-modes.js +145 -0
- package/dist/core/skill-operation-reply.d.ts +16 -0
- package/dist/core/skill-operation-reply.js +35 -0
- package/dist/core/skill-operations.d.ts +58 -0
- package/dist/core/skill-operations.js +296 -0
- package/dist/core/skills-tool.d.ts +90 -0
- package/dist/core/skills-tool.js +64 -0
- package/dist/core/tool-input.d.ts +7 -0
- package/dist/core/tool-input.js +21 -0
- package/dist/core/tool-outcome.d.ts +13 -0
- package/dist/core/tool-outcome.js +22 -0
- package/dist/core/turn-outputs.d.ts +30 -0
- package/dist/core/turn-outputs.js +22 -0
- package/dist/importer/auto-backfill.js +13 -1
- package/dist/importer/claude-conversation.d.ts +1 -0
- package/dist/importer/claude-conversation.js +44 -10
- package/dist/importer/claude-hook-api.d.ts +0 -8
- package/dist/importer/claude-hook-api.js +36 -65
- package/dist/importer/claude-import.js +3 -1
- package/dist/importer/claude-reader.d.ts +2 -0
- package/dist/importer/claude-reader.js +2 -2
- package/dist/importer/claude-turn-units.d.ts +26 -0
- package/dist/importer/claude-turn-units.js +85 -0
- package/dist/importer/import-args.d.ts +3 -0
- package/dist/importer/import-args.js +8 -2
- package/dist/importer/import-project.d.ts +6 -0
- package/dist/importer/import-project.js +9 -0
- package/dist/importer/import-runs.d.ts +8 -2
- package/dist/importer/import-runs.js +3 -3
- package/dist/importer/import-sessions.d.ts +8 -1
- package/dist/importer/import-sessions.js +17 -4
- package/dist/importer/import-turn-outputs.d.ts +49 -0
- package/dist/importer/import-turn-outputs.js +91 -0
- package/dist/importer/importer.d.ts +5 -2
- package/dist/importer/importer.js +18 -32
- package/dist/importer/opencode-import.d.ts +2 -0
- package/dist/importer/opencode-import.js +6 -3
- package/dist/importer/opencode-reader.js +11 -5
- package/dist/importer/pi-conversation.d.ts +5 -0
- package/dist/importer/pi-conversation.js +33 -9
- package/dist/importer/profile-catch-up.d.ts +1 -1
- package/dist/importer/record-past-turns.d.ts +28 -0
- package/dist/importer/record-past-turns.js +63 -0
- package/dist/importer/run-import.d.ts +4 -0
- package/dist/importer/run-import.js +24 -3
- package/dist/importer/web-import-api.d.ts +14 -0
- package/dist/importer/web-import-api.js +6 -0
- package/dist/importer/web-import-group.d.ts +4 -1
- package/dist/importer/web-import-group.js +26 -6
- package/dist/importer/web-import-jobs.js +6 -2
- package/dist/index.js +6 -1
- package/dist/services/ai/providers/base-provider.d.ts +15 -0
- package/dist/services/ai/providers/base-provider.js +26 -0
- package/dist/services/ai/providers/openai-chat-completion.js +3 -6
- package/dist/services/ai/session/ai-session-manager.js +13 -0
- package/dist/services/auto-capture.d.ts +1 -1
- package/dist/services/auto-capture.js +71 -19
- package/dist/services/chat/chat-runtime.d.ts +2 -0
- package/dist/services/chat/chat-runtime.js +3 -2
- package/dist/services/global-config-writer.js +32 -2
- package/dist/services/profile-cleanup-routes.d.ts +2 -2
- package/dist/services/profile-cleanup-routes.js +19 -2
- package/dist/services/settings-snapshot.js +7 -1
- package/dist/services/skill-learning/agent-proposals.d.ts +76 -0
- package/dist/services/skill-learning/agent-proposals.js +180 -0
- package/dist/services/skill-learning/apply.d.ts +50 -0
- package/dist/services/skill-learning/apply.js +247 -0
- package/dist/services/skill-learning/auto-review.d.ts +40 -0
- package/dist/services/skill-learning/auto-review.js +132 -0
- package/dist/services/skill-learning/draft-turns.d.ts +60 -0
- package/dist/services/skill-learning/draft-turns.js +131 -0
- package/dist/services/skill-learning/drafting.d.ts +73 -0
- package/dist/services/skill-learning/drafting.js +264 -0
- package/dist/services/skill-learning/line-diff.d.ts +6 -0
- package/dist/services/skill-learning/line-diff.js +94 -0
- package/dist/services/skill-learning/operations-deps.d.ts +7 -0
- package/dist/services/skill-learning/operations-deps.js +19 -0
- package/dist/services/skill-learning/proposal-store.d.ts +82 -0
- package/dist/services/skill-learning/proposal-store.js +164 -0
- package/dist/services/skill-learning/ranking.d.ts +36 -0
- package/dist/services/skill-learning/ranking.js +140 -0
- package/dist/services/skill-learning/settings-for.d.ts +11 -0
- package/dist/services/skill-learning/settings-for.js +21 -0
- package/dist/services/skill-learning/skill-config.d.ts +45 -0
- package/dist/services/skill-learning/skill-config.js +70 -0
- package/dist/services/skill-learning/skill-detect.d.ts +29 -0
- package/dist/services/skill-learning/skill-detect.js +130 -0
- package/dist/services/skill-learning/skill-folders.d.ts +24 -0
- package/dist/services/skill-learning/skill-folders.js +42 -0
- package/dist/services/skill-learning/skill-index.d.ts +28 -0
- package/dist/services/skill-learning/skill-index.js +118 -0
- package/dist/services/skill-learning/skill-model.d.ts +10 -0
- package/dist/services/skill-learning/skill-model.js +15 -0
- package/dist/services/skill-learning/store.d.ts +79 -0
- package/dist/services/skill-learning/store.js +245 -0
- package/dist/services/skill-learning/turn-output.d.ts +34 -0
- package/dist/services/skill-learning/turn-output.js +98 -0
- package/dist/services/skill-learning/turn-summaries.d.ts +24 -0
- package/dist/services/skill-learning/turn-summaries.js +64 -0
- package/dist/services/skill-learning/web-review.d.ts +18 -0
- package/dist/services/skill-learning/web-review.js +44 -0
- package/dist/services/skill-routes.d.ts +20 -0
- package/dist/services/skill-routes.js +249 -0
- package/dist/services/turso/connection-manager.js +11 -7
- package/dist/services/turso/table-lease.d.ts +40 -0
- package/dist/services/turso/table-lease.js +77 -0
- package/dist/services/turso/turso-db.d.ts +4 -0
- package/dist/services/turso/turso-db.js +13 -0
- package/dist/services/user-prompt/profile-catch-up-lease.d.ts +3 -22
- package/dist/services/user-prompt/profile-catch-up-lease.js +9 -65
- package/dist/services/web-jobs.d.ts +7 -0
- package/dist/services/web-jobs.js +14 -0
- package/dist/services/web-route-modules.d.ts +2 -2
- package/dist/services/web-route-modules.js +6 -1
- package/dist/services/web-server.js +9 -21
- package/dist/v2/adapter.js +13 -8
- package/dist/web/assets/index-B_xLhsQO.css +2 -0
- package/dist/web/assets/index-BetjjXGs.js +129 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BGDKFzoC.css +0 -2
- package/dist/web/assets/index-CE8tXPA_.js +0 -128
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as aiCleanup from "./ai-cleanup-handlers.js";
|
|
2
2
|
import * as autoCleanup from "./auto-cleanup-handlers.js";
|
|
3
|
+
import * as categoryCleanup from "./api-handlers.js";
|
|
3
4
|
/**
|
|
4
|
-
* The profile cleanup routes: AI Cleanup (start, status, stop, apply)
|
|
5
|
-
* last automatic cleanup. Returns the JSON body, or undefined when the path and method are
|
|
5
|
+
* The profile cleanup routes: AI Cleanup (start, status, stop, apply), the undo of the
|
|
6
|
+
* last automatic cleanup, and category cleanup (start, status, apply, undo). Returns the JSON body, or undefined when the path and method are
|
|
6
7
|
* not a cleanup route. The body is read only for the routes that need it.
|
|
7
8
|
*/
|
|
8
9
|
export async function routeProfileCleanup(path, method, url, readBody) {
|
|
@@ -28,5 +29,21 @@ export async function routeProfileCleanup(path, method, url, readBody) {
|
|
|
28
29
|
const body = await readBody();
|
|
29
30
|
return autoCleanup.handleAutoCleanupUndo(body.userId || undefined);
|
|
30
31
|
}
|
|
32
|
+
if (path === "/api/user-profile/category-cleanup") {
|
|
33
|
+
if (method === "POST") {
|
|
34
|
+
const body = await readBody();
|
|
35
|
+
return categoryCleanup.handleCategoryCleanupStart(body.userId || undefined);
|
|
36
|
+
}
|
|
37
|
+
if (method === "GET")
|
|
38
|
+
return categoryCleanup.handleCategoryCleanupStatus(queryUser);
|
|
39
|
+
}
|
|
40
|
+
if (path === "/api/user-profile/category-cleanup/apply" && method === "POST") {
|
|
41
|
+
const body = await readBody();
|
|
42
|
+
return categoryCleanup.handleCategoryCleanupApply(body.userId || undefined, body);
|
|
43
|
+
}
|
|
44
|
+
if (path === "/api/user-profile/category-cleanup/undo" && method === "POST") {
|
|
45
|
+
const body = await readBody();
|
|
46
|
+
return categoryCleanup.handleCategoryCleanupUndo(body.userId || undefined);
|
|
47
|
+
}
|
|
31
48
|
return undefined;
|
|
32
49
|
}
|
|
@@ -34,6 +34,9 @@ const editable = [
|
|
|
34
34
|
"rerankerModel",
|
|
35
35
|
"rerankerTimeoutMs",
|
|
36
36
|
"keywordMode",
|
|
37
|
+
"skillLearning",
|
|
38
|
+
"skillLearningRedact",
|
|
39
|
+
"skillFolders",
|
|
37
40
|
...MEMORY_LIMIT_SETTINGS,
|
|
38
41
|
];
|
|
39
42
|
/** Keys a project config cannot override, so the page always shows the global value. */
|
|
@@ -55,6 +58,8 @@ const globalOnly = [
|
|
|
55
58
|
"rerankerModel",
|
|
56
59
|
"rerankerApiKey",
|
|
57
60
|
"rerankerTimeoutMs",
|
|
61
|
+
"skillLearningRedact",
|
|
62
|
+
"skillFolders",
|
|
58
63
|
];
|
|
59
64
|
function readSettingsFile(path) {
|
|
60
65
|
if (!path)
|
|
@@ -112,7 +117,8 @@ export function getSettingsSnapshot(directory) {
|
|
|
112
117
|
return [key, nestedMemorySetting(limitRule, global, project)];
|
|
113
118
|
}
|
|
114
119
|
const projectOverrides = Object.hasOwn(project, key) &&
|
|
115
|
-
|
|
120
|
+
// A project may turn these off, never on.
|
|
121
|
+
(key === "captureTrace" || key === "skillLearning"
|
|
116
122
|
? project[key] === false
|
|
117
123
|
: !key.endsWith("RetentionDays") && !globalOnly.includes(key));
|
|
118
124
|
const source = projectOverrides
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type SkillRef } from "./draft-turns.js";
|
|
2
|
+
import { NO_CHANGE_PROPOSED } from "./drafting.js";
|
|
3
|
+
import { type SkillProposal } from "./proposal-store.js";
|
|
4
|
+
import { type SkillIndex } from "./skill-index.js";
|
|
5
|
+
import type { SkillStore } from "./store.js";
|
|
6
|
+
/**
|
|
7
|
+
* The agent path (design §8): the coding agent reads a skill's evidence and
|
|
8
|
+
* proposes a revised file itself. No model call. The proposal is pending, and
|
|
9
|
+
* apply still waits for the user's request.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_EVIDENCE_TURNS = 10;
|
|
12
|
+
export declare const MAX_EVIDENCE_TURNS = 20;
|
|
13
|
+
export declare const MAX_PROPOSED_BYTES: number;
|
|
14
|
+
export interface AgentDeps {
|
|
15
|
+
store: SkillStore;
|
|
16
|
+
index: SkillIndex;
|
|
17
|
+
now: number;
|
|
18
|
+
}
|
|
19
|
+
export interface EvidenceTurn {
|
|
20
|
+
id: number;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
host: string;
|
|
23
|
+
prompt: string;
|
|
24
|
+
replies: string[];
|
|
25
|
+
tools: Array<{
|
|
26
|
+
name: string;
|
|
27
|
+
outcome: string;
|
|
28
|
+
error: string | null;
|
|
29
|
+
}>;
|
|
30
|
+
/** A tool call failed after the skill loaded in this turn. */
|
|
31
|
+
failedAfterUse: boolean;
|
|
32
|
+
/** Text was cut to keep the turn within 4,000 characters, or the total within 60,000. */
|
|
33
|
+
cut: boolean;
|
|
34
|
+
}
|
|
35
|
+
export type AgentFailure = {
|
|
36
|
+
ok: false;
|
|
37
|
+
code: string;
|
|
38
|
+
message: string;
|
|
39
|
+
};
|
|
40
|
+
export type EvidenceResult = {
|
|
41
|
+
ok: true;
|
|
42
|
+
ref: SkillRef;
|
|
43
|
+
content: string;
|
|
44
|
+
turns: EvidenceTurn[];
|
|
45
|
+
} | AgentFailure;
|
|
46
|
+
export type ProposeResult = {
|
|
47
|
+
ok: true;
|
|
48
|
+
code: "proposed";
|
|
49
|
+
proposal: SkillProposal;
|
|
50
|
+
} | {
|
|
51
|
+
ok: true;
|
|
52
|
+
code: "no-change";
|
|
53
|
+
message: typeof NO_CHANGE_PROPOSED;
|
|
54
|
+
} | AgentFailure;
|
|
55
|
+
/**
|
|
56
|
+
* The skill's current file and its newest recorded turns, at most `limit`.
|
|
57
|
+
* Turns with a failed call after the skill loaded come first. The text is as
|
|
58
|
+
* stored, which is redacted when redaction is on, within the drafting budget.
|
|
59
|
+
*/
|
|
60
|
+
export declare function skillEvidence(path: string, limit: number, deps: AgentDeps): Promise<EvidenceResult>;
|
|
61
|
+
export interface ProposalInput {
|
|
62
|
+
content: string;
|
|
63
|
+
summary: string;
|
|
64
|
+
evidenceTurnIds: number[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Saves the agent's revised `SKILL.md` as a pending `improve` proposal. The
|
|
68
|
+
* base hash and the diff come from the file on disk. The same content gives
|
|
69
|
+
* `no-change` and counts as a review, as a model draft does.
|
|
70
|
+
*/
|
|
71
|
+
export declare function proposeImprovement(path: string, input: ProposalInput, deps: AgentDeps): Promise<ProposeResult>;
|
|
72
|
+
/**
|
|
73
|
+
* Saves the agent's new `SKILL.md` as a pending `new` proposal at
|
|
74
|
+
* `<folder>/<name>/SKILL.md`, with the checks `draftNewSkill` makes.
|
|
75
|
+
*/
|
|
76
|
+
export declare function proposeNewSkill(name: string, folder: string, input: ProposalInput, deps: AgentDeps): Promise<ProposeResult>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hashSkillContent, SKILL_FILE_NAME } from "./apply.js";
|
|
4
|
+
import { countSkillUses, MAX_TOTAL_TURN_CHARS, MAX_TURN_CHARS, recentSkillTurns, skillRef, turnsById, USES_WHERE, useArgs, } from "./draft-turns.js";
|
|
5
|
+
import { NO_CHANGE_PROPOSED, READ_ONLY, SKILL_NAME, sameText, withFinalNewline, } from "./drafting.js";
|
|
6
|
+
import { lineDiff } from "./line-diff.js";
|
|
7
|
+
import { createProposal, setSkillReview } from "./proposal-store.js";
|
|
8
|
+
import { readFrontMatter } from "./skill-index.js";
|
|
9
|
+
/**
|
|
10
|
+
* The agent path (design §8): the coding agent reads a skill's evidence and
|
|
11
|
+
* proposes a revised file itself. No model call. The proposal is pending, and
|
|
12
|
+
* apply still waits for the user's request.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_EVIDENCE_TURNS = 10;
|
|
15
|
+
export const MAX_EVIDENCE_TURNS = 20;
|
|
16
|
+
export const MAX_PROPOSED_BYTES = 100 * 1024;
|
|
17
|
+
function readSkill(path, index) {
|
|
18
|
+
const ref = skillRef(index, path);
|
|
19
|
+
if (!ref)
|
|
20
|
+
return { ok: false, code: "not-found", message: `${path} is not a watched skill` };
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(path);
|
|
23
|
+
return { ok: true, ref, raw, current: raw.toString("utf8") };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { ok: false, code: "not-found", message: `${path} cannot be read` };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Turn ids and hosts, with whether a call failed after the skill loaded (ranking's rule). */
|
|
30
|
+
async function turnFacts(store, ref, ids) {
|
|
31
|
+
const db = await store.db();
|
|
32
|
+
const rows = await db.all(`SELECT t.id, t.host, EXISTS (
|
|
33
|
+
SELECT 1 FROM tool_calls tc WHERE tc.turn_id = t.id AND tc.outcome = 'error'
|
|
34
|
+
AND tc.position > (SELECT MIN(COALESCE(su.position, -1)) FROM skill_uses su
|
|
35
|
+
WHERE su.turn_id = t.id AND ${USES_WHERE})
|
|
36
|
+
) AS failed
|
|
37
|
+
FROM turns t WHERE t.id IN (SELECT value FROM json_each(?))`, [...useArgs(ref), JSON.stringify(ids)]);
|
|
38
|
+
return new Map(rows.map((row) => [
|
|
39
|
+
Number(row.id),
|
|
40
|
+
{ host: String(row.host), failed: Number(row.failed) === 1 },
|
|
41
|
+
]));
|
|
42
|
+
}
|
|
43
|
+
/** One turn cut to `allowance` characters: prompt, then tool errors, then replies. */
|
|
44
|
+
function budgetTurn(turn, allowance) {
|
|
45
|
+
let left = allowance;
|
|
46
|
+
let cut = false;
|
|
47
|
+
const take = (text) => {
|
|
48
|
+
const kept = text.slice(0, Math.max(0, left));
|
|
49
|
+
if (kept.length < text.length)
|
|
50
|
+
cut = true;
|
|
51
|
+
left -= kept.length;
|
|
52
|
+
return kept;
|
|
53
|
+
};
|
|
54
|
+
const prompt = take(turn.prompt);
|
|
55
|
+
const tools = turn.tools.map((t) => ({ ...t, error: t.error === null ? null : take(t.error) }));
|
|
56
|
+
const replies = turn.replies.map(take).filter((reply) => reply !== "");
|
|
57
|
+
return { prompt, tools, replies, cut, used: allowance - left };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The skill's current file and its newest recorded turns, at most `limit`.
|
|
61
|
+
* Turns with a failed call after the skill loaded come first. The text is as
|
|
62
|
+
* stored, which is redacted when redaction is on, within the drafting budget.
|
|
63
|
+
*/
|
|
64
|
+
export async function skillEvidence(path, limit, deps) {
|
|
65
|
+
const skill = readSkill(path, deps.index);
|
|
66
|
+
if (!skill.ok)
|
|
67
|
+
return skill;
|
|
68
|
+
const db = await deps.store.existingDb();
|
|
69
|
+
const recent = db ? await recentSkillTurns(db, skill.ref, limit) : [];
|
|
70
|
+
const facts = recent.length > 0
|
|
71
|
+
? await turnFacts(deps.store, skill.ref, recent.map((t) => t.id))
|
|
72
|
+
: new Map();
|
|
73
|
+
const failedFirst = [
|
|
74
|
+
...recent.filter((t) => facts.get(t.id)?.failed),
|
|
75
|
+
...recent.filter((t) => !facts.get(t.id)?.failed),
|
|
76
|
+
];
|
|
77
|
+
const turns = [];
|
|
78
|
+
let left = MAX_TOTAL_TURN_CHARS;
|
|
79
|
+
for (const turn of failedFirst) {
|
|
80
|
+
if (left <= 0)
|
|
81
|
+
break;
|
|
82
|
+
const { used, ...kept } = budgetTurn(turn, Math.min(MAX_TURN_CHARS, left));
|
|
83
|
+
left -= used;
|
|
84
|
+
const fact = facts.get(turn.id);
|
|
85
|
+
turns.push({
|
|
86
|
+
id: turn.id,
|
|
87
|
+
createdAt: turn.createdAt,
|
|
88
|
+
host: fact?.host ?? "",
|
|
89
|
+
...kept,
|
|
90
|
+
failedAfterUse: fact?.failed ?? false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, ref: skill.ref, content: skill.current, turns };
|
|
94
|
+
}
|
|
95
|
+
function tooLarge(content) {
|
|
96
|
+
if (Buffer.byteLength(content, "utf8") <= MAX_PROPOSED_BYTES)
|
|
97
|
+
return null;
|
|
98
|
+
return { ok: false, code: "too-large", message: "content is over 100 KB" };
|
|
99
|
+
}
|
|
100
|
+
/** The given ids that name a recorded turn. */
|
|
101
|
+
async function existingTurnIds(store, ids) {
|
|
102
|
+
const db = await store.existingDb();
|
|
103
|
+
if (!db || ids.length === 0)
|
|
104
|
+
return [];
|
|
105
|
+
const found = new Set((await turnsById(db, ids)).map((turn) => turn.id));
|
|
106
|
+
return ids.filter((id, i) => found.has(id) && ids.indexOf(id) === i);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Saves the agent's revised `SKILL.md` as a pending `improve` proposal. The
|
|
110
|
+
* base hash and the diff come from the file on disk. The same content gives
|
|
111
|
+
* `no-change` and counts as a review, as a model draft does.
|
|
112
|
+
*/
|
|
113
|
+
export async function proposeImprovement(path, input, deps) {
|
|
114
|
+
if (deps.store.isReadOnly())
|
|
115
|
+
return READ_ONLY;
|
|
116
|
+
const large = tooLarge(input.content);
|
|
117
|
+
if (large)
|
|
118
|
+
return large;
|
|
119
|
+
const skill = readSkill(path, deps.index);
|
|
120
|
+
if (!skill.ok)
|
|
121
|
+
return skill;
|
|
122
|
+
const content = withFinalNewline(input.content);
|
|
123
|
+
if (sameText(content, skill.current)) {
|
|
124
|
+
const db = await deps.store.existingDb();
|
|
125
|
+
const uses = db ? await countSkillUses(db, skill.ref) : 0;
|
|
126
|
+
await setSkillReview(deps.store, path, deps.now, uses);
|
|
127
|
+
return { ok: true, code: "no-change", message: NO_CHANGE_PROPOSED };
|
|
128
|
+
}
|
|
129
|
+
const proposal = await createProposal(deps.store, {
|
|
130
|
+
kind: "improve",
|
|
131
|
+
skillName: skill.ref.name,
|
|
132
|
+
skillPath: path,
|
|
133
|
+
baseHash: hashSkillContent(skill.raw),
|
|
134
|
+
content,
|
|
135
|
+
diff: lineDiff(skill.current, content),
|
|
136
|
+
summary: input.summary.trim(),
|
|
137
|
+
evidenceTurnIds: await existingTurnIds(deps.store, input.evidenceTurnIds),
|
|
138
|
+
draftedBy: "agent",
|
|
139
|
+
}, deps.now);
|
|
140
|
+
return { ok: true, code: "proposed", proposal };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Saves the agent's new `SKILL.md` as a pending `new` proposal at
|
|
144
|
+
* `<folder>/<name>/SKILL.md`, with the checks `draftNewSkill` makes.
|
|
145
|
+
*/
|
|
146
|
+
export async function proposeNewSkill(name, folder, input, deps) {
|
|
147
|
+
if (deps.store.isReadOnly())
|
|
148
|
+
return READ_ONLY;
|
|
149
|
+
if (!SKILL_NAME.test(name)) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
code: "invalid-name",
|
|
153
|
+
message: "A new skill name may hold only lower-case letters, digits and single hyphens",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const large = tooLarge(input.content);
|
|
157
|
+
if (large)
|
|
158
|
+
return large;
|
|
159
|
+
const content = withFinalNewline(input.content);
|
|
160
|
+
const front = readFrontMatter(content);
|
|
161
|
+
if (front.name !== name || !front.description?.trim()) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
code: "invalid-content",
|
|
165
|
+
message: `The front matter needs name: ${name} and a description`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const proposal = await createProposal(deps.store, {
|
|
169
|
+
kind: "new",
|
|
170
|
+
skillName: name,
|
|
171
|
+
skillPath: join(folder, name, SKILL_FILE_NAME),
|
|
172
|
+
baseHash: null,
|
|
173
|
+
content,
|
|
174
|
+
diff: lineDiff("", content),
|
|
175
|
+
summary: input.summary.trim(),
|
|
176
|
+
evidenceTurnIds: await existingTurnIds(deps.store, input.evidenceTurnIds),
|
|
177
|
+
draftedBy: "agent",
|
|
178
|
+
}, deps.now);
|
|
179
|
+
return { ok: true, code: "proposed", proposal };
|
|
180
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type SkillAppliedBy, type SkillProposal } from "./proposal-store.js";
|
|
2
|
+
import type { SkillStore } from "./store.js";
|
|
3
|
+
/** The only file name OMMS writes in a skill folder. */
|
|
4
|
+
export declare const SKILL_FILE_NAME = "SKILL.md";
|
|
5
|
+
export interface ApplyDeps {
|
|
6
|
+
/** An initialised store. */
|
|
7
|
+
store: SkillStore;
|
|
8
|
+
/** The watched skill folders. */
|
|
9
|
+
folders: readonly string[];
|
|
10
|
+
now?: () => number;
|
|
11
|
+
}
|
|
12
|
+
export type ApplyFailureCode = "not-found" | "not-pending" | "not-skill-file" | "outside-folder" | "invalid-name" | "stale" | "exists" | "read-only" | "write-failed";
|
|
13
|
+
export type ApplyResult = {
|
|
14
|
+
ok: true;
|
|
15
|
+
code: "applied";
|
|
16
|
+
proposal: SkillProposal;
|
|
17
|
+
path: string;
|
|
18
|
+
writtenHash: string;
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
code: ApplyFailureCode;
|
|
22
|
+
message: string;
|
|
23
|
+
};
|
|
24
|
+
export type UndoFailureCode = "not-found" | "not-applied" | "no-version" | "outside-folder" | "changed" | "read-only" | "write-failed";
|
|
25
|
+
export type UndoResult = {
|
|
26
|
+
ok: true;
|
|
27
|
+
code: "undone";
|
|
28
|
+
proposal: SkillProposal;
|
|
29
|
+
path: string;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
code: UndoFailureCode;
|
|
33
|
+
message: string;
|
|
34
|
+
/** For `changed`: the content undo would have restored, null for a new skill. */
|
|
35
|
+
savedContent?: string | null;
|
|
36
|
+
};
|
|
37
|
+
/** sha256 of a skill file's content, as stored in `base_hash` and `written_hash`. */
|
|
38
|
+
export declare function hashSkillContent(content: string | Buffer): string;
|
|
39
|
+
/**
|
|
40
|
+
* Writes a pending proposal to its `SKILL.md` (design §7). Checks the real
|
|
41
|
+
* path, the file name, a new skill's name and the base hash first. Saves the
|
|
42
|
+
* previous content before the write. Expected outcomes return a result code.
|
|
43
|
+
*/
|
|
44
|
+
export declare function applyProposal(id: string, by: SkillAppliedBy, deps: ApplyDeps): Promise<ApplyResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Restores the content saved before the last apply, or deletes the file the
|
|
47
|
+
* apply created. Only when the file still matches what OMMS wrote; otherwise
|
|
48
|
+
* refuses and returns the saved content.
|
|
49
|
+
*/
|
|
50
|
+
export declare function undoProposal(id: string, deps: ApplyDeps): Promise<UndoResult>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { deleteSkillVersion, getLatestVersion, getProposal, saveSkillVersion, updateProposalState, } from "./proposal-store.js";
|
|
5
|
+
import { clearSkillIndexCache } from "./skill-index.js";
|
|
6
|
+
/** The only file name OMMS writes in a skill folder. */
|
|
7
|
+
export const SKILL_FILE_NAME = "SKILL.md";
|
|
8
|
+
const NEW_SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
9
|
+
const TEMP_SUFFIX = ".omms-tmp";
|
|
10
|
+
/** sha256 of a skill file's content, as stored in `base_hash` and `written_hash`. */
|
|
11
|
+
export function hashSkillContent(content) {
|
|
12
|
+
return createHash("sha256").update(content).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
function readHash(path) {
|
|
15
|
+
try {
|
|
16
|
+
return hashSkillContent(readFileSync(path));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function realFolders(folders) {
|
|
23
|
+
const real = [];
|
|
24
|
+
for (const folder of folders) {
|
|
25
|
+
try {
|
|
26
|
+
real.push(realpathSync(folder));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// A missing folder holds nothing to write to.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return real;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The real path of `path` after following links. A part that does not exist
|
|
36
|
+
* yet (a new skill's folder or file) is joined onto the real path of the
|
|
37
|
+
* nearest part that does.
|
|
38
|
+
*/
|
|
39
|
+
function realTarget(path) {
|
|
40
|
+
const missing = [];
|
|
41
|
+
let current = resolve(path);
|
|
42
|
+
for (;;) {
|
|
43
|
+
try {
|
|
44
|
+
return join(realpathSync(current), ...missing.reverse());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
const parent = dirname(current);
|
|
48
|
+
if (parent === current)
|
|
49
|
+
return resolve(path);
|
|
50
|
+
missing.push(basename(current));
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function isInside(path, folder) {
|
|
56
|
+
return path.startsWith(folder.endsWith(sep) ? folder : folder + sep);
|
|
57
|
+
}
|
|
58
|
+
/** The real file to write, only when it is a `SKILL.md` inside a watched folder. */
|
|
59
|
+
function checkTarget(path, folders) {
|
|
60
|
+
if (!path || basename(path) !== SKILL_FILE_NAME) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
code: "not-skill-file",
|
|
64
|
+
message: `Only ${SKILL_FILE_NAME} files can be written`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const real = realTarget(path);
|
|
68
|
+
const roots = realFolders(folders);
|
|
69
|
+
if (basename(real) !== SKILL_FILE_NAME) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: "not-skill-file",
|
|
73
|
+
message: `${path} links to a file that is not ${SKILL_FILE_NAME}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (!roots.some((root) => isInside(real, root))) {
|
|
77
|
+
return { ok: false, code: "outside-folder", message: `${path} is not inside a watched folder` };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, real, folders: roots };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Writes a temp file beside the target, then moves it into place in one step.
|
|
83
|
+
* With `overwrite` false, a hard link puts it in place and fails when a file
|
|
84
|
+
* appeared there since the check, so a new skill never replaces one.
|
|
85
|
+
*/
|
|
86
|
+
function replaceFile(path, content, overwrite = true) {
|
|
87
|
+
const temp = path + TEMP_SUFFIX;
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(temp, content);
|
|
90
|
+
if (overwrite) {
|
|
91
|
+
renameSync(temp, path);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
linkSync(temp, path);
|
|
95
|
+
rmSync(temp, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
rmSync(temp, { force: true });
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function errorText(error) {
|
|
104
|
+
return error instanceof Error ? error.message : String(error);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Writes a pending proposal to its `SKILL.md` (design §7). Checks the real
|
|
108
|
+
* path, the file name, a new skill's name and the base hash first. Saves the
|
|
109
|
+
* previous content before the write. Expected outcomes return a result code.
|
|
110
|
+
*/
|
|
111
|
+
export async function applyProposal(id, by, deps) {
|
|
112
|
+
const now = deps.now ?? Date.now;
|
|
113
|
+
const { store } = deps;
|
|
114
|
+
if (store.isReadOnly()) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
code: "read-only",
|
|
118
|
+
message: "skills.db is from a newer OMMS and is read-only",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const proposal = await getProposal(store, id);
|
|
122
|
+
if (!proposal)
|
|
123
|
+
return { ok: false, code: "not-found", message: `No proposal ${id}` };
|
|
124
|
+
if (proposal.state !== "pending") {
|
|
125
|
+
return { ok: false, code: "not-pending", message: `Proposal ${id} is ${proposal.state}` };
|
|
126
|
+
}
|
|
127
|
+
if (proposal.kind === "new" && !NEW_SKILL_NAME.test(proposal.skillName)) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
code: "invalid-name",
|
|
131
|
+
message: "A new skill name may hold only lower-case letters, digits and single hyphens",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const target = checkTarget(proposal.skillPath, deps.folders);
|
|
135
|
+
if (!target.ok)
|
|
136
|
+
return target;
|
|
137
|
+
const { real } = target;
|
|
138
|
+
let previous = null;
|
|
139
|
+
if (proposal.kind === "new") {
|
|
140
|
+
if (existsSync(real)) {
|
|
141
|
+
return { ok: false, code: "exists", message: `${proposal.skillPath} already exists` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// A missing file or a changed file both mean the draft no longer fits.
|
|
146
|
+
const current = readHash(real);
|
|
147
|
+
if (current === null || current !== proposal.baseHash) {
|
|
148
|
+
await updateProposalState(store, id, "stale", now());
|
|
149
|
+
return { ok: false, code: "stale", message: "Stale: the file changed since this draft" };
|
|
150
|
+
}
|
|
151
|
+
previous = readFileSync(real, "utf8");
|
|
152
|
+
}
|
|
153
|
+
const writtenHash = hashSkillContent(proposal.content);
|
|
154
|
+
const versionId = await saveSkillVersion(store, { proposalId: id, path: real, previousContent: previous, writtenHash }, now());
|
|
155
|
+
try {
|
|
156
|
+
if (proposal.kind === "new")
|
|
157
|
+
mkdirSync(dirname(real), { recursive: true });
|
|
158
|
+
replaceFile(real, proposal.content, proposal.kind !== "new");
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
await deleteSkillVersion(store, versionId);
|
|
162
|
+
if (error.code === "EEXIST") {
|
|
163
|
+
return { ok: false, code: "exists", message: `${proposal.skillPath} already exists` };
|
|
164
|
+
}
|
|
165
|
+
return { ok: false, code: "write-failed", message: errorText(error) };
|
|
166
|
+
}
|
|
167
|
+
clearSkillIndexCache();
|
|
168
|
+
const appliedAt = now();
|
|
169
|
+
await updateProposalState(store, id, "applied", appliedAt, by);
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
code: "applied",
|
|
173
|
+
proposal: { ...proposal, state: "applied", appliedBy: by, updatedAt: appliedAt },
|
|
174
|
+
path: real,
|
|
175
|
+
writtenHash,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Removes a new skill's folder when undo left it empty. Never a watched folder itself. */
|
|
179
|
+
function removeEmptyFolder(folder, roots) {
|
|
180
|
+
if (roots.includes(folder))
|
|
181
|
+
return;
|
|
182
|
+
try {
|
|
183
|
+
if (readdirSync(folder).length === 0)
|
|
184
|
+
rmdirSync(folder);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// The folder is gone or not empty; leave it.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Restores the content saved before the last apply, or deletes the file the
|
|
192
|
+
* apply created. Only when the file still matches what OMMS wrote; otherwise
|
|
193
|
+
* refuses and returns the saved content.
|
|
194
|
+
*/
|
|
195
|
+
export async function undoProposal(id, deps) {
|
|
196
|
+
const now = deps.now ?? Date.now;
|
|
197
|
+
const { store } = deps;
|
|
198
|
+
if (store.isReadOnly()) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
code: "read-only",
|
|
202
|
+
message: "skills.db is from a newer OMMS and is read-only",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const proposal = await getProposal(store, id);
|
|
206
|
+
if (!proposal)
|
|
207
|
+
return { ok: false, code: "not-found", message: `No proposal ${id}` };
|
|
208
|
+
if (proposal.state !== "applied") {
|
|
209
|
+
return { ok: false, code: "not-applied", message: `Proposal ${id} is ${proposal.state}` };
|
|
210
|
+
}
|
|
211
|
+
const version = await getLatestVersion(store, id);
|
|
212
|
+
if (!version)
|
|
213
|
+
return { ok: false, code: "no-version", message: `No saved version for ${id}` };
|
|
214
|
+
const target = checkTarget(version.path, deps.folders);
|
|
215
|
+
if (!target.ok)
|
|
216
|
+
return { ok: false, code: "outside-folder", message: target.message };
|
|
217
|
+
const { real, folders } = target;
|
|
218
|
+
if (readHash(real) !== version.writtenHash) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
code: "changed",
|
|
222
|
+
message: "The file changed since OMMS wrote it, so undo would lose that change",
|
|
223
|
+
savedContent: version.previousContent,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
if (version.previousContent === null) {
|
|
228
|
+
unlinkSync(real);
|
|
229
|
+
removeEmptyFolder(dirname(real), folders);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
replaceFile(real, version.previousContent);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
return { ok: false, code: "write-failed", message: errorText(error) };
|
|
237
|
+
}
|
|
238
|
+
clearSkillIndexCache();
|
|
239
|
+
const undoneAt = now();
|
|
240
|
+
await updateProposalState(store, id, "undone", undoneAt);
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
code: "undone",
|
|
244
|
+
proposal: { ...proposal, state: "undone", updatedAt: undoneAt },
|
|
245
|
+
path: real,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { TableLease } from "../turso/table-lease.js";
|
|
2
|
+
import { type DraftDeps } from "./drafting.js";
|
|
3
|
+
import type { SkillStore } from "./store.js";
|
|
4
|
+
/** A skill in automatic mode is reviewed after this many new uses... */
|
|
5
|
+
export declare const AUTO_REVIEW_MIN_USES = 10;
|
|
6
|
+
/** ...and at least this long after its last review. */
|
|
7
|
+
export declare const AUTO_REVIEW_MIN_GAP_MS: number;
|
|
8
|
+
/** The web app checks for due skills this often. */
|
|
9
|
+
export declare const AUTO_REVIEW_INTERVAL_MS: number;
|
|
10
|
+
export declare const SKILL_REVIEW_LEASE_TABLE = "skill_review_lease";
|
|
11
|
+
/** The review run record in `skills.db`, shared by every process. */
|
|
12
|
+
export declare function skillReviewLease(store: SkillStore, now?: () => number): TableLease;
|
|
13
|
+
export interface AutoReviewDeps extends Omit<DraftDeps, "now"> {
|
|
14
|
+
/** The watched skill folders, for the apply checks. */
|
|
15
|
+
folders: readonly string[];
|
|
16
|
+
lease: TableLease;
|
|
17
|
+
}
|
|
18
|
+
export interface AutoReviewReport {
|
|
19
|
+
/** Why the run stopped before or between reviews. */
|
|
20
|
+
skipped?: "not-configured" | "backoff" | "busy" | "superseded";
|
|
21
|
+
/** One entry for each skill reviewed: `applied`, `no-change`, `stale` or a failure code. */
|
|
22
|
+
reviews: Array<{
|
|
23
|
+
skillPath: string;
|
|
24
|
+
code: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One automatic review pass (design §6). Drafts and applies an improvement
|
|
29
|
+
* for each due skill in automatic mode. Starts no draft during the skill
|
|
30
|
+
* draft wait, and runs only while it holds the `skill_review_lease`, so one
|
|
31
|
+
* review runs at a time across processes.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runAutoReview(deps: AutoReviewDeps, now: number): Promise<AutoReviewReport>;
|
|
34
|
+
/**
|
|
35
|
+
* Runs `runAutoReview` every 10 minutes. `makeDeps` returns null while skill
|
|
36
|
+
* learning is off, and the tick then does nothing. A tick never overlaps the
|
|
37
|
+
* one before it. The timer does not keep the process alive. Returns a stop
|
|
38
|
+
* function.
|
|
39
|
+
*/
|
|
40
|
+
export declare function startAutoReviewLoop(makeDeps: () => Promise<AutoReviewDeps | null>, intervalMs?: number): () => void;
|