om-memory-system 4.30.1 → 4.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +1 -0
  2. package/dist/adapters/opencode/conversation.js +22 -17
  3. package/dist/adapters/opencode/skills-tool.d.ts +3 -0
  4. package/dist/adapters/opencode/skills-tool.js +31 -0
  5. package/dist/adapters/pi/capture.js +39 -23
  6. package/dist/adapters/pi/extension.d.ts +1 -0
  7. package/dist/adapters/pi/extension.js +3 -0
  8. package/dist/adapters/pi/skills-tool.d.ts +3 -0
  9. package/dist/adapters/pi/skills-tool.js +34 -0
  10. package/dist/cli/index.js +5 -0
  11. package/dist/cli/skills-command.d.ts +12 -0
  12. package/dist/cli/skills-command.js +242 -0
  13. package/dist/cli/skills-record-past.d.ts +5 -0
  14. package/dist/cli/skills-record-past.js +85 -0
  15. package/dist/config-template.d.ts +1 -1
  16. package/dist/config-template.js +12 -0
  17. package/dist/config.d.ts +64 -48
  18. package/dist/config.js +28 -5
  19. package/dist/core/host.d.ts +7 -0
  20. package/dist/core/profile-backoff.d.ts +16 -8
  21. package/dist/core/profile-backoff.js +25 -16
  22. package/dist/core/skill-agent-modes.d.ts +4 -0
  23. package/dist/core/skill-agent-modes.js +145 -0
  24. package/dist/core/skill-operation-reply.d.ts +16 -0
  25. package/dist/core/skill-operation-reply.js +35 -0
  26. package/dist/core/skill-operations.d.ts +58 -0
  27. package/dist/core/skill-operations.js +296 -0
  28. package/dist/core/skills-tool.d.ts +90 -0
  29. package/dist/core/skills-tool.js +64 -0
  30. package/dist/core/tool-input.d.ts +7 -0
  31. package/dist/core/tool-input.js +21 -0
  32. package/dist/core/tool-outcome.d.ts +13 -0
  33. package/dist/core/tool-outcome.js +22 -0
  34. package/dist/core/turn-outputs.d.ts +30 -0
  35. package/dist/core/turn-outputs.js +22 -0
  36. package/dist/importer/auto-backfill.js +13 -1
  37. package/dist/importer/claude-conversation.d.ts +1 -0
  38. package/dist/importer/claude-conversation.js +44 -10
  39. package/dist/importer/claude-hook-api.d.ts +0 -8
  40. package/dist/importer/claude-hook-api.js +36 -65
  41. package/dist/importer/claude-import.js +3 -1
  42. package/dist/importer/claude-reader.d.ts +2 -0
  43. package/dist/importer/claude-reader.js +2 -2
  44. package/dist/importer/claude-turn-units.d.ts +26 -0
  45. package/dist/importer/claude-turn-units.js +85 -0
  46. package/dist/importer/import-args.d.ts +3 -0
  47. package/dist/importer/import-args.js +8 -2
  48. package/dist/importer/import-project.d.ts +6 -0
  49. package/dist/importer/import-project.js +9 -0
  50. package/dist/importer/import-runs.d.ts +8 -2
  51. package/dist/importer/import-runs.js +3 -3
  52. package/dist/importer/import-sessions.d.ts +8 -1
  53. package/dist/importer/import-sessions.js +17 -4
  54. package/dist/importer/import-turn-outputs.d.ts +49 -0
  55. package/dist/importer/import-turn-outputs.js +88 -0
  56. package/dist/importer/importer.d.ts +5 -2
  57. package/dist/importer/importer.js +18 -32
  58. package/dist/importer/opencode-import.d.ts +2 -0
  59. package/dist/importer/opencode-import.js +6 -3
  60. package/dist/importer/opencode-reader.js +11 -5
  61. package/dist/importer/pi-conversation.d.ts +5 -0
  62. package/dist/importer/pi-conversation.js +33 -9
  63. package/dist/importer/profile-catch-up.d.ts +1 -1
  64. package/dist/importer/record-past-turns.d.ts +28 -0
  65. package/dist/importer/record-past-turns.js +63 -0
  66. package/dist/importer/run-import.d.ts +4 -0
  67. package/dist/importer/run-import.js +24 -3
  68. package/dist/importer/web-import-api.d.ts +14 -0
  69. package/dist/importer/web-import-api.js +6 -0
  70. package/dist/importer/web-import-group.d.ts +4 -1
  71. package/dist/importer/web-import-group.js +26 -6
  72. package/dist/importer/web-import-jobs.js +6 -2
  73. package/dist/index.js +6 -1
  74. package/dist/services/ai/session/ai-session-manager.js +13 -0
  75. package/dist/services/auto-capture.d.ts +1 -1
  76. package/dist/services/auto-capture.js +71 -19
  77. package/dist/services/chat/chat-runtime.d.ts +2 -0
  78. package/dist/services/chat/chat-runtime.js +3 -2
  79. package/dist/services/global-config-writer.js +32 -2
  80. package/dist/services/profile-cleanup-routes.d.ts +2 -2
  81. package/dist/services/profile-cleanup-routes.js +19 -2
  82. package/dist/services/settings-snapshot.js +7 -1
  83. package/dist/services/skill-learning/agent-proposals.d.ts +76 -0
  84. package/dist/services/skill-learning/agent-proposals.js +180 -0
  85. package/dist/services/skill-learning/apply.d.ts +50 -0
  86. package/dist/services/skill-learning/apply.js +247 -0
  87. package/dist/services/skill-learning/auto-review.d.ts +40 -0
  88. package/dist/services/skill-learning/auto-review.js +132 -0
  89. package/dist/services/skill-learning/draft-turns.d.ts +60 -0
  90. package/dist/services/skill-learning/draft-turns.js +131 -0
  91. package/dist/services/skill-learning/drafting.d.ts +73 -0
  92. package/dist/services/skill-learning/drafting.js +263 -0
  93. package/dist/services/skill-learning/line-diff.d.ts +6 -0
  94. package/dist/services/skill-learning/line-diff.js +94 -0
  95. package/dist/services/skill-learning/operations-deps.d.ts +7 -0
  96. package/dist/services/skill-learning/operations-deps.js +19 -0
  97. package/dist/services/skill-learning/proposal-store.d.ts +82 -0
  98. package/dist/services/skill-learning/proposal-store.js +164 -0
  99. package/dist/services/skill-learning/ranking.d.ts +36 -0
  100. package/dist/services/skill-learning/ranking.js +140 -0
  101. package/dist/services/skill-learning/settings-for.d.ts +11 -0
  102. package/dist/services/skill-learning/settings-for.js +21 -0
  103. package/dist/services/skill-learning/skill-config.d.ts +45 -0
  104. package/dist/services/skill-learning/skill-config.js +70 -0
  105. package/dist/services/skill-learning/skill-detect.d.ts +29 -0
  106. package/dist/services/skill-learning/skill-detect.js +130 -0
  107. package/dist/services/skill-learning/skill-folders.d.ts +24 -0
  108. package/dist/services/skill-learning/skill-folders.js +42 -0
  109. package/dist/services/skill-learning/skill-index.d.ts +28 -0
  110. package/dist/services/skill-learning/skill-index.js +118 -0
  111. package/dist/services/skill-learning/skill-model.d.ts +10 -0
  112. package/dist/services/skill-learning/skill-model.js +15 -0
  113. package/dist/services/skill-learning/store.d.ts +79 -0
  114. package/dist/services/skill-learning/store.js +245 -0
  115. package/dist/services/skill-learning/turn-output.d.ts +34 -0
  116. package/dist/services/skill-learning/turn-output.js +98 -0
  117. package/dist/services/skill-learning/turn-summaries.d.ts +24 -0
  118. package/dist/services/skill-learning/turn-summaries.js +64 -0
  119. package/dist/services/skill-learning/web-review.d.ts +18 -0
  120. package/dist/services/skill-learning/web-review.js +44 -0
  121. package/dist/services/skill-routes.d.ts +20 -0
  122. package/dist/services/skill-routes.js +249 -0
  123. package/dist/services/turso/connection-manager.js +11 -7
  124. package/dist/services/turso/table-lease.d.ts +40 -0
  125. package/dist/services/turso/table-lease.js +77 -0
  126. package/dist/services/turso/turso-db.d.ts +4 -0
  127. package/dist/services/turso/turso-db.js +13 -0
  128. package/dist/services/user-prompt/profile-catch-up-lease.d.ts +3 -22
  129. package/dist/services/user-prompt/profile-catch-up-lease.js +9 -65
  130. package/dist/services/web-jobs.d.ts +7 -0
  131. package/dist/services/web-jobs.js +14 -0
  132. package/dist/services/web-route-modules.d.ts +2 -2
  133. package/dist/services/web-route-modules.js +6 -1
  134. package/dist/services/web-server.js +9 -21
  135. package/dist/v2/adapter.js +13 -8
  136. package/dist/web/assets/index-9hu4Z7SV.css +2 -0
  137. package/dist/web/assets/index-BMt9gPNK.js +129 -0
  138. package/dist/web/index.html +2 -2
  139. package/package.json +1 -1
  140. package/dist/web/assets/index-BGDKFzoC.css +0 -2
  141. package/dist/web/assets/index-CE8tXPA_.js +0 -128
@@ -31,6 +31,11 @@ export async function runHistoryImport(host, args, run) {
31
31
  try {
32
32
  const { startImportRun } = await import("./import-runs.js");
33
33
  const { workProgress } = await import("./import-progress.js");
34
+ const { importSkillTurnsOn, skillTurnsOnly } = await import("./import-turn-outputs.js");
35
+ // Without memories, the source pass records skill turns only (no model call).
36
+ const skillTurnsPass = args.skipMemories && importSkillTurnsOn(args);
37
+ // A skills-only run reads sessions whose folder is gone, so its report misses them as unresolved.
38
+ const keepsMissingFolders = skillTurnsOnly(args, !args.skipProfile);
34
39
  const controller = new AbortController();
35
40
  const onAbort = () => controller.abort();
36
41
  run.signal?.addEventListener("abort", onAbort, { once: true });
@@ -38,12 +43,16 @@ export async function runHistoryImport(host, args, run) {
38
43
  controller.abort();
39
44
  const recorder = await startImportRun(host, run.track.surface, {
40
45
  onPause: () => controller.abort(),
46
+ ...(skillTurnsPass ? { phase: "skill-turns" } : {}),
41
47
  });
48
+ // Every unit the source pass reached is finished: the loop checks the signal before it.
49
+ let reached = 0;
42
50
  try {
43
51
  const report = await runUntracked(host, args, {
44
52
  ...run,
45
53
  signal: controller.signal,
46
54
  onProgress: (processed, total, preview, alreadyHandled = 0) => {
55
+ reached = processed;
47
56
  // Units already in the ledger need no model call: leave them out of
48
57
  // the progress so the bar and the time left describe real work.
49
58
  const work = workProgress(processed, total, alreadyHandled, run.track.expectedTotal);
@@ -64,14 +73,14 @@ export async function runHistoryImport(host, args, run) {
64
73
  ? "failed"
65
74
  : "done", {
66
75
  // The total stays the one progress last reported; a real run has no
67
- // "would import" count of its own.
68
- done: handled,
76
+ // "would import" count of its own. A skill-turns pass counts the turns it read.
77
+ done: skillTurnsPass ? reached : handled,
69
78
  imported: report.unitsImported,
70
79
  skipped: report.unitsSkipped,
71
80
  failed: report.unitsFailed,
72
81
  }, report.profile?.error ? profileFailureCode(report.profile.error) : undefined);
73
82
  // Only a run over every project and every session sees the full list.
74
- if (args.scope === "all-projects" && !run.selection)
83
+ if (args.scope === "all-projects" && !run.selection && !keepsMissingFolders)
75
84
  await recordUnresolved(host, report, args.since !== undefined || args.until !== undefined);
76
85
  return report;
77
86
  }
@@ -129,6 +138,7 @@ async function runUntracked(host, args, run) {
129
138
  force: args.force,
130
139
  skipMemories: args.skipMemories,
131
140
  skipProfile: args.skipProfile,
141
+ skipSkills: args.skipSkills,
132
142
  ...(run.signal ? { signal: run.signal } : {}),
133
143
  ...(run.onProgress ? { onProgress: run.onProgress } : {}),
134
144
  ...(run.models.capture ? { provider: capture } : {}),
@@ -164,6 +174,7 @@ async function runUntracked(host, args, run) {
164
174
  force: args.force,
165
175
  dryRun: args.dryRun,
166
176
  skipMemories: args.skipMemories,
177
+ skipSkills: args.skipSkills,
167
178
  pathMaps,
168
179
  };
169
180
  if (host === "claude-code") {
@@ -195,6 +206,12 @@ export function formatHistoryImportReport(host, report, model) {
195
206
  : ""));
196
207
  lines.push(` memory units: ${report.unitsWouldImport} pending, ${report.unitsAlreadyHandled} already done, ` +
197
208
  `${report.unitsImported} imported, ${report.unitsSkipped} skipped, ${report.unitsFailed} failed`);
209
+ if (report.skillTurnsWouldRecord !== undefined) {
210
+ lines.push(` skill turns: ${report.skillTurnsWouldRecord} pending, ${report.skillTurnsAlreadyRecorded ?? 0} already recorded`);
211
+ }
212
+ if (report.skillTurnsRecorded !== undefined) {
213
+ lines.push(` skill turns: ${report.skillTurnsRecorded} recorded, ${report.skillTurnsAlreadyPresent ?? 0} already present`);
214
+ }
198
215
  for (const project of report.projects) {
199
216
  lines.push(` project ${project.directory}: ${project.sessions} sessions, ${project.units} units`);
200
217
  }
@@ -236,6 +253,10 @@ export function summarizeHistoryImportReport(report) {
236
253
  unitsAlreadyHandled: report.unitsAlreadyHandled,
237
254
  unitsHeldBack: report.unitsHeldBack ?? 0,
238
255
  unitsUntimed: report.unitsUntimed ?? 0,
256
+ skillTurnsRecorded: report.skillTurnsRecorded ?? 0,
257
+ skillTurnsAlreadyPresent: report.skillTurnsAlreadyPresent ?? 0,
258
+ skillTurnsWouldRecord: report.skillTurnsWouldRecord ?? 0,
259
+ skillTurnsAlreadyRecorded: report.skillTurnsAlreadyRecorded ?? 0,
239
260
  projects: report.projects.length,
240
261
  unresolved: (report.unresolvedProjects?.length ?? 0) + report.unresolvableSessions.length,
241
262
  loadErrors: report.loadErrors.length,
@@ -11,3 +11,17 @@ export { BackfillControls } from "./backfill-controls.js";
11
11
  export { directoryMapsView, readStoreProjects, type KnownProject } from "./map-suggestions.js";
12
12
  export { testExternalApi } from "./external-api-test.js";
13
13
  export { catchUpState, pauseCatchUp, previewCatchUp, startCatchUp } from "./profile-catch-up.js";
14
+ import type { SettingsImportJobs } from "./web-import-jobs.js";
15
+ /** Start Record past turns in the web app's import job slot. */
16
+ export declare function startRecordPast(jobs: () => Promise<SettingsImportJobs>, cwd: string): Promise<{
17
+ id: string;
18
+ host: import("./import-args.js").ImportHost;
19
+ dryRun: boolean;
20
+ state: "failed" | "done" | "running" | "cancelling" | "cancelled";
21
+ sessions: number;
22
+ processed: number;
23
+ total: number;
24
+ report?: string;
25
+ summary?: ReturnType<typeof import("./run-import.js").summarizeHistoryImportReport>;
26
+ error?: string;
27
+ } | import("./web-import-group.js").GroupImportJob>;
@@ -11,3 +11,9 @@ export { BackfillControls } from "./backfill-controls.js";
11
11
  export { directoryMapsView, readStoreProjects } from "./map-suggestions.js";
12
12
  export { testExternalApi } from "./external-api-test.js";
13
13
  export { catchUpState, pauseCatchUp, previewCatchUp, startCatchUp } from "./profile-catch-up.js";
14
+ import { importReadiness } from "./import-readiness.js";
15
+ import { startRecordPastTurns } from "./record-past-turns.js";
16
+ /** Start Record past turns in the web app's import job slot. */
17
+ export async function startRecordPast(jobs, cwd) {
18
+ return startRecordPastTurns(await jobs(), cwd, await importReadiness());
19
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ImportHost } from "./import-args.js";
2
2
  import { type ImportReadiness } from "./import-readiness.js";
3
+ import type { ImportSurface } from "./import-runs.js";
3
4
  import { resolveImportSelection } from "./import-sessions.js";
4
5
  import { type ImportSourceIdentity } from "./import-sources.js";
5
6
  import { runHistoryImport, summarizeHistoryImportReport, type HistoryImportRun } from "./run-import.js";
@@ -17,7 +18,7 @@ export interface GroupHostJob {
17
18
  host: ImportHost;
18
19
  state: GroupHostState;
19
20
  sessions: number;
20
- phase: "preparing" | "memory" | "profile";
21
+ phase: "preparing" | "memory" | "skill-turns" | "profile";
21
22
  processed: number;
22
23
  total: number;
23
24
  profileProcessed: number;
@@ -46,6 +47,8 @@ export interface GroupImportDeps {
46
47
  prepareModels?: (choice: string, directory: string, signal: AbortSignal, host: ImportHost) => Promise<HistoryImportRun["models"]>;
47
48
  discardSelection?: (identity: ImportSourceIdentity) => Promise<void>;
48
49
  waitingPrompts?: () => Promise<Set<string>>;
50
+ /** Where the run record says the import started; `web` by default. */
51
+ surface?: ImportSurface;
49
52
  }
50
53
  type Deps = GroupImportDeps & {
51
54
  runner: typeof runHistoryImport;
@@ -3,6 +3,7 @@ import { CONFIG } from "../config.js";
3
3
  import { hostLabel } from "../types/host-label.js";
4
4
  import { parseHistoryImportArgs, webImportTokens, } from "./import-args.js";
5
5
  import { importBlockedReason } from "./import-readiness.js";
6
+ import { importSkillTurnsOn, skillTurnsOnly } from "./import-turn-outputs.js";
6
7
  import { resolveImportSelection } from "./import-sessions.js";
7
8
  import { importSourceKey, readImportSourceToken, } from "./import-sources.js";
8
9
  import { historyImportFailed, ImportAlreadyRunningError, runHistoryImport, summarizeHistoryImportReport, } from "./run-import.js";
@@ -53,10 +54,19 @@ function matchOptions(child, directory, signal) {
53
54
  cwd: directory,
54
55
  allowEmpty: true,
55
56
  signal,
56
- outputs: { memories: !child.args.skipMemories, profile: !child.args.skipProfile },
57
+ // Skill turns have no session ledger: while they are recorded, no session counts as handled.
58
+ outputs: importSkillTurnsOn(child.args)
59
+ ? { memories: false, profile: false }
60
+ : { memories: !child.args.skipMemories, profile: !child.args.skipProfile },
57
61
  ...(child.args.force ? { force: true } : {}),
62
+ // The list the selection came from passed the same flag.
63
+ ...(skillTurnsOnly(child.args, !child.args.skipProfile) ? { skillTurnsOnly: true } : {}),
58
64
  };
59
65
  }
66
+ /** A skill-turns-only run reads history and calls no model. */
67
+ function needsModel(args) {
68
+ return !args.skipMemories || !args.skipProfile;
69
+ }
60
70
  function hostError(host, error) {
61
71
  return new ImportJobError(`${hostLabel(host)}: ${safeHealthError(error, [CONFIG.memoryApiKey, CONFIG.embeddingApiKey])}`, error.status ?? 400);
62
72
  }
@@ -74,6 +84,10 @@ function emptySummary(dryRun) {
74
84
  unitsAlreadyHandled: 0,
75
85
  unitsHeldBack: 0,
76
86
  unitsUntimed: 0,
87
+ skillTurnsRecorded: 0,
88
+ skillTurnsAlreadyPresent: 0,
89
+ skillTurnsWouldRecord: 0,
90
+ skillTurnsAlreadyRecorded: 0,
77
91
  projects: 0,
78
92
  unresolved: 0,
79
93
  loadErrors: 0,
@@ -171,7 +185,7 @@ export async function startImportGroup(input, directory, deps, publish) {
171
185
  child.row.blocker =
172
186
  importBlockedReason(readiness, {
173
187
  host,
174
- needsModel: true,
188
+ needsModel: needsModel(child.args),
175
189
  modelChoice: child.input.modelChoice,
176
190
  }) ?? undefined;
177
191
  if (!job.dryRun && child.row.blocker)
@@ -269,12 +283,12 @@ async function executeGroup(children, job, signal, directory, deps, waiting) {
269
283
  // Reader/model readiness may change while earlier children finish their profile work.
270
284
  const blocked = importBlockedReason(await deps.readiness(), {
271
285
  host,
272
- needsModel: !job.dryRun,
286
+ needsModel: !job.dryRun && needsModel(child.args),
273
287
  modelChoice: child.input.modelChoice,
274
288
  });
275
289
  if (blocked)
276
290
  throw new Error(blocked);
277
- const models = job.dryRun
291
+ const models = job.dryRun || !needsModel(child.args)
278
292
  ? {}
279
293
  : await (deps.prepareModels ?? prepareModels)(child.input.modelChoice, directory, signal, host);
280
294
  if (signal.aborted) {
@@ -282,12 +296,16 @@ async function executeGroup(children, job, signal, directory, deps, waiting) {
282
296
  continue;
283
297
  }
284
298
  const eligible = new Set();
285
- child.row.phase = child.args.skipMemories ? "profile" : "memory";
299
+ child.row.phase = !child.args.skipMemories
300
+ ? "memory"
301
+ : importSkillTurnsOn(child.args)
302
+ ? "skill-turns"
303
+ : "profile";
286
304
  const report = await deps.runner(host, child.args, {
287
305
  cwd: directory,
288
306
  models,
289
307
  signal,
290
- track: { surface: "web" },
308
+ track: { surface: deps.surface ?? "web" },
291
309
  selection: {
292
310
  keys: pinned.keys,
293
311
  cutoff: pinned.cutoff,
@@ -340,6 +358,8 @@ async function executeGroup(children, job, signal, directory, deps, waiting) {
340
358
  else {
341
359
  const pending = report.unitsImported +
342
360
  report.unitsWouldImport +
361
+ (report.skillTurnsRecorded ?? 0) +
362
+ (report.skillTurnsWouldRecord ?? 0) +
343
363
  (report.profile?.promptsRecorded ?? 0) +
344
364
  (report.profile?.promptsWouldRecord ?? 0) +
345
365
  (report.profile?.batchesBuilt ?? 0) +
@@ -23,6 +23,7 @@ const fields = new Set([
23
23
  "force",
24
24
  "skipMemories",
25
25
  "skipProfile",
26
+ "skipSkills",
26
27
  "scope",
27
28
  "project",
28
29
  "since",
@@ -65,7 +66,7 @@ export function validateWebImportRequest(value) {
65
66
  for (const [key, option] of Object.entries(options)) {
66
67
  if (!fields.has(key))
67
68
  throw new Error(`Unknown import option: ${key}`);
68
- if (["dryRun", "force", "skipMemories", "skipProfile"].includes(key)) {
69
+ if (["dryRun", "force", "skipMemories", "skipProfile", "skipSkills"].includes(key)) {
69
70
  if (typeof option !== "boolean")
70
71
  throw new Error(`Invalid ${key}`);
71
72
  }
@@ -92,7 +93,10 @@ export function validateWebImportRequest(value) {
92
93
  throw new Error("The project must be an absolute path");
93
94
  }
94
95
  }
95
- if (options.skipMemories === true && options.skipProfile === true)
96
+ // Skill turns count as a target only while skillLearning is on.
97
+ if (options.skipMemories === true &&
98
+ options.skipProfile === true &&
99
+ (options.skipSkills === true || !CONFIG.skillLearning))
96
100
  throw new Error("Choose Project memories or User profile");
97
101
  if (input.modelChoice !== undefined &&
98
102
  (typeof input.modelChoice !== "string" ||
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { getLanguageName } from "./services/language-detector.js";
21
21
  import { getHostClientConfig } from "./adapters/opencode/opencode-host-config.js";
22
22
  import { applySkillsPathConfig } from "./adapters/opencode/package-skills.js";
23
23
  import { loadOpencodeProvider } from "./adapters/opencode/opencode-provider-loader.js";
24
+ import { opencodeSkillsTool } from "./adapters/opencode/skills-tool.js";
24
25
  import { STRUCTURED_OUTPUT_AGENT, STRUCTURED_OUTPUT_TOOLS, } from "./adapters/opencode/opencode-provider.js";
25
26
  import { INTERNAL_CAPTURE_SESSION_TITLE, isInternalCaptureSessionTitle, isTrackedInternalCaptureSession, } from "./adapters/opencode/internal-capture-sessions.js";
26
27
  import { registerOpencodeProfileModel } from "./adapters/opencode/profile-model.js";
@@ -544,6 +545,7 @@ export const OmmsPlugin = async (ctx) => {
544
545
  return JSON.stringify(result);
545
546
  },
546
547
  }),
548
+ skills: opencodeSkillsTool(directory),
547
549
  },
548
550
  event: async (input) => {
549
551
  const event = input.event;
@@ -559,7 +561,8 @@ export const OmmsPlugin = async (ctx) => {
559
561
  return;
560
562
  }
561
563
  if (event.type === "session.idle") {
562
- if (!isConfigured() || !CONFIG.autoCaptureEnabled)
564
+ // Skill learning records turns even when memory capture is off (ADR-036).
565
+ if (!isConfigured() || !(CONFIG.autoCaptureEnabled || CONFIG.skillLearning))
563
566
  return;
564
567
  const sessionID = event.properties?.sessionID;
565
568
  if (!sessionID)
@@ -575,6 +578,8 @@ export const OmmsPlugin = async (ctx) => {
575
578
  idleTimeout = setTimeout(async () => {
576
579
  try {
577
580
  await performAutoCapture(autoCaptureHost, sessionID, directory);
581
+ if (!CONFIG.autoCaptureEnabled)
582
+ return; // Skill turns only; the memory work stays off.
578
583
  await performUserProfileLearning(ctx, directory);
579
584
  const { cleanupService } = await import("./services/cleanup-service.js");
580
585
  if (await cleanupService.shouldRunCleanup())
@@ -24,6 +24,19 @@ export class AISessionManager {
24
24
  this.initPromise = (async () => {
25
25
  try {
26
26
  this.db = await tursoConnectionManager.getConnection(this.dbPath);
27
+ // Sessions hold prompts, including skill drafts built from recorded
28
+ // turns. Zero deleted rows so their text does not stay in free pages.
29
+ await this.db.execute("PRAGMA secure_delete = ON");
30
+ // A file written before secure_delete was on can still hold deleted
31
+ // text in free pages. Rebuild it at open when it has free pages.
32
+ try {
33
+ const free = await this.db.get("PRAGMA freelist_count");
34
+ if (Number(free?.freelist_count ?? 0) > 0)
35
+ await this.db.execute("VACUUM");
36
+ }
37
+ catch {
38
+ // Best effort: another process may hold the file; the next open tries again.
39
+ }
27
40
  await this.initDatabase();
28
41
  }
29
42
  catch (error) {
@@ -1,4 +1,4 @@
1
- import { type CaptureResult, type CaptureWorkUnit } from "../core/capture.js";
1
+ import type { CaptureResult, CaptureWorkUnit } from "../core/capture.js";
2
2
  import type { AutoCaptureHost } from "../core/host.js";
3
3
  import { type CaptureRetryDrainResult } from "./capture-retry-drain.js";
4
4
  export { buildBoundedSummaryPrompt, buildMarkdownContext, getAutoCaptureMarkdownBudget, } from "../core/capture-context.js";
@@ -1,11 +1,19 @@
1
1
  import { CONFIG, refreshConfigIfChanged } from "../config.js";
2
- import { captureConversation } from "../core/capture.js";
3
2
  import { quickRetryDelayMs } from "../core/capture-retry-policy.js";
3
+ import { handleFinishedTurn } from "../core/turn-outputs.js";
4
4
  import { drainCaptureRetries, queueFailedCapture, startCaptureRetryDrain, } from "./capture-retry-drain.js";
5
5
  import { log } from "./logger.js";
6
+ import { skillTurnRecorder } from "./skill-learning/settings-for.js";
6
7
  import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
7
8
  export { buildBoundedSummaryPrompt, buildMarkdownContext, getAutoCaptureMarkdownBudget, } from "../core/capture-context.js";
8
9
  let isCaptureRunning = false;
10
+ /**
11
+ * With memory capture off, prompts stay uncaptured, so each idle event lists
12
+ * them again. This keeps, per session, the time of the last prompt recorded for
13
+ * skill learning, so no conversation is read twice. The prompts are not marked
14
+ * captured, so memory capture still sees them once it is turned on.
15
+ */
16
+ const skillRecordedUpTo = new Map();
9
17
  async function notifySafely(host, notification) {
10
18
  try {
11
19
  await host.notify?.(notification);
@@ -20,18 +28,74 @@ export async function performAutoCapture(host, sessionID, directory) {
20
28
  isCaptureRunning = true;
21
29
  try {
22
30
  const prompts = await userPromptManager.getUncapturedPromptsForSession(sessionID);
23
- if (prompts.length === 0 || !host.isCaptureReady())
31
+ if (prompts.length === 0)
24
32
  return;
33
+ const recorder = skillTurnRecorder(directory);
34
+ // Skill learning records turns even when memory capture is off (ADR-036).
35
+ if (CONFIG.autoCaptureEnabled === false || !host.isCaptureReady()) {
36
+ for (const prompt of prompts) {
37
+ if (prompt.createdAt <= (skillRecordedUpTo.get(sessionID) ?? -Infinity))
38
+ continue;
39
+ if (await recordSkillTurnOnly(host, sessionID, directory, prompt, recorder))
40
+ skillRecordedUpTo.set(sessionID, prompt.createdAt);
41
+ }
42
+ return;
43
+ }
25
44
  const maxRetries = CONFIG.autoCaptureMaxRetries ?? 3;
26
45
  for (const prompt of prompts) {
27
- await capturePrompt(host, sessionID, directory, prompt, maxRetries);
46
+ await capturePrompt(host, sessionID, directory, prompt, maxRetries, recorder);
28
47
  }
29
48
  }
30
49
  finally {
31
50
  isCaptureRunning = false;
32
51
  }
33
52
  }
34
- async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
53
+ function opencodeWorkUnit(host, sessionID, directory, prompt, conversation) {
54
+ return {
55
+ host: host.host,
56
+ hostSessionId: sessionID,
57
+ sourceType: "live-capture",
58
+ projectDirectory: directory,
59
+ userPrompt: prompt.content,
60
+ promptId: prompt.id,
61
+ prompt: {
62
+ id: prompt.id,
63
+ providerId: prompt.providerId,
64
+ modelId: prompt.modelId,
65
+ },
66
+ ...conversation,
67
+ };
68
+ }
69
+ /**
70
+ * The skill output keys a turn by OpenCode's message id, the id the history
71
+ * import reads, so a live turn imported later stays one row. The memory unit
72
+ * keeps the OMMS prompt id.
73
+ */
74
+ function opencodeSkillRecorder(prompt, recorder) {
75
+ return recorder && ((unit) => recorder({ ...unit, promptId: prompt.messageId }));
76
+ }
77
+ /**
78
+ * Memory capture is off or not ready: record the turn for skill learning only.
79
+ * Returns true when the finished turn went to the skill output.
80
+ */
81
+ async function recordSkillTurnOnly(host, sessionID, directory, prompt, recorder) {
82
+ if (!recorder)
83
+ return false;
84
+ try {
85
+ const conversation = await host.getConversation(sessionID, prompt.messageId);
86
+ if (!conversation)
87
+ return false;
88
+ if (conversation.textResponses.length === 0 && conversation.toolCalls.length === 0)
89
+ return false;
90
+ await handleFinishedTurn(opencodeWorkUnit(host, sessionID, directory, prompt, conversation), { memories: false, skills: true }, { recordSkillTurn: opencodeSkillRecorder(prompt, recorder) });
91
+ return true;
92
+ }
93
+ catch {
94
+ log("OpenCode skill turn read failed", { sessionID, code: "conversation-read-failed" }, "warn");
95
+ return false;
96
+ }
97
+ }
98
+ async function capturePrompt(host, sessionID, directory, prompt, maxRetries, recorder) {
35
99
  let claimedPromptId = null;
36
100
  let attempt = prompt.capture_attempts || 0;
37
101
  let workUnit = null;
@@ -47,21 +111,9 @@ async function capturePrompt(host, sessionID, directory, prompt, maxRetries) {
47
111
  return;
48
112
  if (conversation.textResponses.length === 0 && conversation.toolCalls.length === 0)
49
113
  return;
50
- workUnit = {
51
- host: host.host,
52
- hostSessionId: sessionID,
53
- sourceType: "live-capture",
54
- projectDirectory: directory,
55
- userPrompt: prompt.content,
56
- promptId: prompt.id,
57
- prompt: {
58
- id: prompt.id,
59
- providerId: prompt.providerId,
60
- modelId: prompt.modelId,
61
- },
62
- ...conversation,
63
- };
64
- const captureResult = await captureConversation(workUnit, host);
114
+ workUnit = opencodeWorkUnit(host, sessionID, directory, prompt, conversation);
115
+ const record = opencodeSkillRecorder(prompt, recorder);
116
+ const captureResult = await handleFinishedTurn(workUnit, { memories: true, skills: record !== undefined }, { provider: host, recordSkillTurn: record });
65
117
  if (captureResult.status === "skipped") {
66
118
  log("Auto-capture skipped", {
67
119
  promptId: prompt.id,
@@ -15,6 +15,8 @@ export interface ChatRuntimeConfig {
15
15
  }
16
16
  /** The chat store for this storage path, opened and migrated once. */
17
17
  export declare function chatStore(config: Pick<ChatRuntimeConfig, "storagePath">): Promise<ChatStore>;
18
+ /** One structured call to the external memory model. Skill drafting reuses it (design §6). */
19
+ export declare function callTool(config: ChatRuntimeConfig, system: string, user: string, tool: object, timeoutMs?: number): Promise<import("../ai/providers/base-provider.js").ToolCallResult>;
18
20
  /** The memory ids from `ids` that still exist in any project or user shard. */
19
21
  export declare function existingMemoryIds(ids: string[]): Promise<Set<string>>;
20
22
  /** Real dependencies for `askQuestion`, built from the settings passed in. */
@@ -16,11 +16,12 @@ export function chatStore(config) {
16
16
  }
17
17
  return store;
18
18
  }
19
- async function callTool(config, system, user, tool) {
19
+ /** One structured call to the external memory model. Skill drafting reuses it (design §6). */
20
+ export async function callTool(config, system, user, tool, timeoutMs = CHAT_MODEL_TIMEOUT_MS) {
20
21
  const { AIProviderFactory } = await import("../ai/ai-provider-factory.js");
21
22
  const { buildMemoryProviderConfig } = await import("../ai/provider-config.js");
22
23
  const { aiSessionManager } = await import("../ai/session/ai-session-manager.js");
23
- const provider = AIProviderFactory.createProvider(config.memoryProvider, buildMemoryProviderConfig(config, { maxIterations: 1, iterationTimeout: CHAT_MODEL_TIMEOUT_MS }));
24
+ const provider = AIProviderFactory.createProvider(config.memoryProvider, buildMemoryProviderConfig(config, { maxIterations: 1, iterationTimeout: timeoutMs }));
24
25
  const sessionId = `chat_${crypto.randomUUID()}`;
25
26
  try {
26
27
  return await provider.executeToolCall(system, user, tool, sessionId);
@@ -5,6 +5,7 @@ import { applyEdits, modify, parse } from "jsonc-parser";
5
5
  import { getGlobalConfigSourcePath, getGlobalConfigWritePath, validateGlobalConfig, } from "../config.js";
6
6
  import { CONFIG_TEMPLATE } from "../config-template.js";
7
7
  import { isValidClaudeConfigDir } from "./claude-folder.js";
8
+ import { missingSkillFolder } from "./skill-learning/skill-config.js";
8
9
  import { KEYWORD_MODES } from "../utils/keyword-boost.js";
9
10
  import { getMemoryLimitRule } from "../utils/memory-limits.js";
10
11
  import { RERANKER_TIMEOUT_MAX_MS, RERANKER_TIMEOUT_MIN_MS } from "./request-timeouts.js";
@@ -43,7 +44,18 @@ const keys = new Set([
43
44
  "rerankerModel",
44
45
  "rerankerApiKey",
45
46
  "rerankerTimeoutMs",
47
+ "skillLearning",
48
+ "skillLearningRedact",
49
+ "skillFolders",
46
50
  ]);
51
+ function isFolder(path) {
52
+ try {
53
+ return statSync(path).isDirectory();
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
47
59
  /**
48
60
  * Written only by the embedding apply route, together, after a passing test.
49
61
  * The general settings save refuses them, so a change always re-embeds.
@@ -70,6 +82,8 @@ export function isSecretReference(value) {
70
82
  }
71
83
  /** Reranker keys that a null edit removes, the same wire form as a limit reset. */
72
84
  const OPTIONAL_RERANKER_KEYS = ["rerankerApiUrl", "rerankerModel", "rerankerApiKey"];
85
+ /** Other keys whose `null` edit removes them, so they follow the default again. */
86
+ const RESETTABLE_KEYS = ["keywordMode", "skillFolders"];
73
87
  function isValidEdit(key, value) {
74
88
  // `undefined` removes the key from the file. `null` is the wire form of
75
89
  // "reset to default", accepted for the six memory limits and the optional reranker keys.
@@ -78,6 +92,9 @@ function isValidEdit(key, value) {
78
92
  return true;
79
93
  if (OPTIONAL_RERANKER_KEYS.includes(key) && value === null)
80
94
  return true;
95
+ // skillFolders: null removes the key, so the default skill folders apply again.
96
+ if (key === "skillFolders" && value === null)
97
+ return true;
81
98
  // keywordMode: null removes the key, so the mode follows the default again.
82
99
  if (key === "keywordMode")
83
100
  return value === null || KEYWORD_MODES.includes(value);
@@ -114,9 +131,15 @@ function isValidEdit(key, value) {
114
131
  if (key === "captureTrace" ||
115
132
  key === "autoBackfill" ||
116
133
  key === "webServerAutoStart" ||
117
- key === "userProfileAutoCleanupEnabled") {
134
+ key === "userProfileAutoCleanupEnabled" ||
135
+ key === "skillLearning" ||
136
+ key === "skillLearningRedact") {
118
137
  return typeof value === "boolean";
119
138
  }
139
+ // Path rules are checked by the startup validation below; existence just after this check.
140
+ if (key === "skillFolders") {
141
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
142
+ }
120
143
  // Each automatic cleanup is paid for, so the page refuses an interval that would run one
121
144
  // after almost every learning pass. A hand edit of the file has no floor.
122
145
  if (key === "userProfileAutoCleanupInterval") {
@@ -201,6 +224,11 @@ options = {}) {
201
224
  ? `Invalid ${key} setting: accepted ${rerankerAccepted[key]}`
202
225
  : `Invalid ${key} setting`);
203
226
  }
227
+ const missing = key === "skillFolders" && value !== null
228
+ ? missingSkillFolder(value, isFolder)
229
+ : null;
230
+ if (missing !== null)
231
+ throw new Error(`Skill folder not found: ${missing}`);
204
232
  }
205
233
  const wasQueued = pending > 0;
206
234
  pending++;
@@ -224,7 +252,9 @@ options = {}) {
224
252
  // null is the wire form of "reset to default": remove the key so the
225
253
  // value follows the default again (undefined removes in jsonc modify).
226
254
  const jsoncValue = value === null &&
227
- (getMemoryLimitRule(key) || OPTIONAL_RERANKER_KEYS.includes(key) || key === "keywordMode")
255
+ (getMemoryLimitRule(key) ||
256
+ OPTIONAL_RERANKER_KEYS.includes(key) ||
257
+ RESETTABLE_KEYS.includes(key))
228
258
  ? undefined
229
259
  : value;
230
260
  content = applyEdits(content, modify(content, jsoncPath, jsoncValue, {
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The profile cleanup routes: AI Cleanup (start, status, stop, apply) and the undo of the
3
- * last automatic cleanup. Returns the JSON body, or undefined when the path and method are
2
+ * The profile cleanup routes: AI Cleanup (start, status, stop, apply), the undo of the
3
+ * last automatic cleanup, and category cleanup (start, status, apply, undo). Returns the JSON body, or undefined when the path and method are
4
4
  * not a cleanup route. The body is read only for the routes that need it.
5
5
  */
6
6
  export declare function routeProfileCleanup(path: string, method: string, url: URL, readBody: () => Promise<any>): Promise<unknown | undefined>;
@@ -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) and the undo of the
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
- (key === "captureTrace"
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