forge-openclaw-plugin 0.2.49 → 0.2.51

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.
@@ -0,0 +1,328 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, readdir, readFile, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { getDatabase, getEffectiveDataRoot } from "../db.js";
6
+ import { getNoteById } from "../repositories/notes.js";
7
+ import { syncNoteWikiArtifacts } from "../repositories/wiki-memory.js";
8
+ const startupImportMarkerId = "runtime:legacy-wiki-markdown-import:v1";
9
+ function parseFrontmatter(markdown) {
10
+ const normalized = markdown.replace(/\r\n/g, "\n");
11
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
12
+ if (!match) {
13
+ return { frontmatter: {}, body: normalized };
14
+ }
15
+ const frontmatter = {};
16
+ for (const line of match[1].split("\n")) {
17
+ const separatorIndex = line.indexOf(":");
18
+ if (separatorIndex <= 0) {
19
+ throw new Error(`Malformed frontmatter line: ${line}`);
20
+ }
21
+ const key = line.slice(0, separatorIndex).trim();
22
+ const rawValue = line.slice(separatorIndex + 1).trim();
23
+ if (!key) {
24
+ throw new Error(`Malformed empty frontmatter key: ${line}`);
25
+ }
26
+ try {
27
+ frontmatter[key] = JSON.parse(rawValue);
28
+ }
29
+ catch {
30
+ frontmatter[key] = rawValue.replace(/^"(.*)"$/, "$1");
31
+ }
32
+ }
33
+ return { frontmatter, body: match[2] };
34
+ }
35
+ function slugify(value) {
36
+ const normalized = value
37
+ .toLowerCase()
38
+ .normalize("NFKD")
39
+ .replace(/[^\w\s-]/g, "")
40
+ .trim()
41
+ .replace(/[\s_]+/g, "-")
42
+ .replace(/-+/g, "-");
43
+ return normalized || "imported-page";
44
+ }
45
+ function stripMarkdown(markdown) {
46
+ return markdown
47
+ .replace(/```[\s\S]*?```/g, " ")
48
+ .replace(/`([^`]+)`/g, "$1")
49
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
50
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
51
+ .replace(/[#>*_\-~]/g, " ")
52
+ .replace(/\s+/g, " ")
53
+ .trim();
54
+ }
55
+ function inferTitle(markdown, fallback) {
56
+ const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
57
+ return heading || fallback.trim() || "Imported wiki page";
58
+ }
59
+ function inferSummary(markdown) {
60
+ return stripMarkdown(markdown).slice(0, 240);
61
+ }
62
+ function normalizeStringArray(value) {
63
+ return Array.isArray(value)
64
+ ? value.filter((entry) => typeof entry === "string")
65
+ : [];
66
+ }
67
+ function normalizeLinkedEntities(value) {
68
+ if (!Array.isArray(value)) {
69
+ return [];
70
+ }
71
+ return value.flatMap((entry) => {
72
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
73
+ return [];
74
+ }
75
+ const record = entry;
76
+ if (typeof record.entityType !== "string" ||
77
+ typeof record.entityId !== "string") {
78
+ return [];
79
+ }
80
+ return [
81
+ {
82
+ entityType: record.entityType,
83
+ entityId: record.entityId,
84
+ anchorKey: typeof record.anchorKey === "string" ? record.anchorKey : ""
85
+ }
86
+ ];
87
+ });
88
+ }
89
+ async function walkMarkdownFiles(root) {
90
+ const results = [];
91
+ async function visit(directory) {
92
+ if (!existsSync(directory)) {
93
+ return;
94
+ }
95
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
96
+ const entryPath = path.join(directory, entry.name);
97
+ if (entry.isDirectory()) {
98
+ await visit(entryPath);
99
+ continue;
100
+ }
101
+ if (entry.isFile() && entry.name.endsWith(".md")) {
102
+ results.push(entryPath);
103
+ }
104
+ }
105
+ }
106
+ await visit(root);
107
+ return results.sort();
108
+ }
109
+ function findSpaceForFile(dataRoot, filePath, parsed) {
110
+ const explicitSpaceId = typeof parsed.frontmatter.spaceId === "string"
111
+ ? parsed.frontmatter.spaceId
112
+ : "";
113
+ if (explicitSpaceId) {
114
+ const row = getDatabase()
115
+ .prepare("SELECT id FROM wiki_spaces WHERE id = ?")
116
+ .get(explicitSpaceId);
117
+ if (row) {
118
+ return row.id;
119
+ }
120
+ }
121
+ const relative = path.relative(path.join(dataRoot, "wiki"), filePath);
122
+ const parts = relative.split(path.sep);
123
+ if (parts[0] === "shared" && parts[1]) {
124
+ const row = getDatabase()
125
+ .prepare("SELECT id FROM wiki_spaces WHERE visibility = 'shared' AND slug = ?")
126
+ .get(parts[1]);
127
+ if (row) {
128
+ return row.id;
129
+ }
130
+ }
131
+ if (parts[0] === "users" && parts[1]) {
132
+ const row = getDatabase()
133
+ .prepare("SELECT id FROM wiki_spaces WHERE owner_user_id = ? OR slug = ?")
134
+ .get(parts[1], parts[1]);
135
+ if (row) {
136
+ return row.id;
137
+ }
138
+ }
139
+ return "wiki_space_shared";
140
+ }
141
+ function findExistingNote(input) {
142
+ if (input.id) {
143
+ const byId = getDatabase()
144
+ .prepare("SELECT id FROM notes WHERE id = ?")
145
+ .get(input.id);
146
+ if (byId) {
147
+ return byId.id;
148
+ }
149
+ }
150
+ const bySlug = getDatabase()
151
+ .prepare("SELECT id FROM notes WHERE space_id = ? AND slug = ?")
152
+ .get(input.spaceId, input.slug);
153
+ return bySlug?.id ?? null;
154
+ }
155
+ function upsertLinks(noteId, links) {
156
+ getDatabase().prepare("DELETE FROM note_links WHERE note_id = ?").run(noteId);
157
+ const createdAt = new Date().toISOString();
158
+ const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
159
+ VALUES (?, ?, ?, ?, ?)`);
160
+ for (const link of links) {
161
+ statement.run(noteId, link.entityType, link.entityId, link.anchorKey, createdAt);
162
+ }
163
+ }
164
+ function legacyBackupPath(dataRoot, backupLabel) {
165
+ return path.join(dataRoot, "backups", backupLabel, "wiki");
166
+ }
167
+ async function backupWikiRootOnce(input) {
168
+ const backupPath = legacyBackupPath(input.dataRoot, input.backupLabel);
169
+ if (existsSync(backupPath)) {
170
+ return { backupPath, backedUp: false };
171
+ }
172
+ await mkdir(path.dirname(backupPath), { recursive: true });
173
+ await cp(input.wikiRoot, backupPath, { recursive: true, force: false });
174
+ return { backupPath, backedUp: true };
175
+ }
176
+ export async function importLegacyWikiMarkdownToSqlite(input = {}) {
177
+ const options = {
178
+ dataRoot: path.resolve(input.dataRoot ?? getEffectiveDataRoot()),
179
+ apply: input.apply ?? false,
180
+ deleteFiles: input.deleteFiles ?? false,
181
+ backupBeforeApply: input.backupBeforeApply ?? false,
182
+ backupLabel: input.backupLabel ?? "legacy-wiki-markdown-pre-sqlite-import"
183
+ };
184
+ const wikiRoot = path.join(options.dataRoot, "wiki");
185
+ const files = await walkMarkdownFiles(wikiRoot);
186
+ let scanned = 0;
187
+ let inserted = 0;
188
+ let updated = 0;
189
+ let deleted = 0;
190
+ let backupPath = legacyBackupPath(options.dataRoot, options.backupLabel);
191
+ let backedUp = false;
192
+ if (options.apply && options.backupBeforeApply && files.length > 0) {
193
+ const backup = await backupWikiRootOnce({
194
+ wikiRoot,
195
+ dataRoot: options.dataRoot,
196
+ backupLabel: options.backupLabel
197
+ });
198
+ backupPath = backup.backupPath;
199
+ backedUp = backup.backedUp;
200
+ }
201
+ for (const filePath of files) {
202
+ scanned += 1;
203
+ const parsed = parseFrontmatter(await readFile(filePath, "utf8"));
204
+ const kind = filePath.includes(`${path.sep}pages${path.sep}`)
205
+ ? "wiki"
206
+ : "evidence";
207
+ const spaceId = findSpaceForFile(options.dataRoot, filePath, parsed);
208
+ const markdown = parsed.body.trim();
209
+ const id = typeof parsed.frontmatter.id === "string" && parsed.frontmatter.id.trim()
210
+ ? parsed.frontmatter.id.trim()
211
+ : `note_${createHash("sha1").update(filePath).digest("hex").slice(0, 10)}`;
212
+ const title = typeof parsed.frontmatter.title === "string"
213
+ ? parsed.frontmatter.title
214
+ : inferTitle(markdown, path.basename(filePath, ".md"));
215
+ const slug = typeof parsed.frontmatter.slug === "string"
216
+ ? parsed.frontmatter.slug
217
+ : slugify(path.basename(filePath, ".md"));
218
+ const aliases = normalizeStringArray(parsed.frontmatter.aliases);
219
+ const tags = normalizeStringArray(parsed.frontmatter.tags);
220
+ const summary = typeof parsed.frontmatter.summary === "string"
221
+ ? parsed.frontmatter.summary
222
+ : inferSummary(markdown);
223
+ const contentPlain = stripMarkdown(markdown);
224
+ const links = normalizeLinkedEntities(parsed.frontmatter.linkedEntities);
225
+ const noteId = findExistingNote({ id, spaceId, slug });
226
+ const now = new Date().toISOString();
227
+ if (!options.apply) {
228
+ if (noteId) {
229
+ updated += 1;
230
+ }
231
+ else {
232
+ inserted += 1;
233
+ }
234
+ continue;
235
+ }
236
+ if (noteId) {
237
+ getDatabase()
238
+ .prepare(`UPDATE notes
239
+ SET kind = ?, title = ?, slug = ?, space_id = ?, parent_slug = ?, index_order = ?, show_in_index = ?,
240
+ aliases_json = ?, summary = ?, content_markdown = ?, content_plain = ?, tags_json = ?,
241
+ source_path = '', frontmatter_json = ?, updated_at = ?
242
+ WHERE id = ?`)
243
+ .run(kind, title, slug, spaceId, typeof parsed.frontmatter.parentSlug === "string"
244
+ ? parsed.frontmatter.parentSlug
245
+ : null, typeof parsed.frontmatter.indexOrder === "number"
246
+ ? Math.trunc(parsed.frontmatter.indexOrder)
247
+ : 0, parsed.frontmatter.showInIndex === false ? 0 : 1, JSON.stringify(aliases), summary, markdown, contentPlain, JSON.stringify(tags), JSON.stringify(parsed.frontmatter), now, noteId);
248
+ upsertLinks(noteId, links);
249
+ const note = getNoteById(noteId, { skipCleanup: true });
250
+ if (note) {
251
+ syncNoteWikiArtifacts(note);
252
+ }
253
+ updated += 1;
254
+ }
255
+ else {
256
+ getDatabase()
257
+ .prepare(`INSERT INTO notes (
258
+ id, kind, title, slug, space_id, parent_slug, index_order, show_in_index, aliases_json, summary,
259
+ content_markdown, content_plain, author, source, tags_json, destroy_at, source_path, frontmatter_json,
260
+ revision_hash, last_synced_at, created_at, updated_at
261
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, '', NULL, ?, ?)`)
262
+ .run(id, kind, title, slug, spaceId, typeof parsed.frontmatter.parentSlug === "string"
263
+ ? parsed.frontmatter.parentSlug
264
+ : null, typeof parsed.frontmatter.indexOrder === "number"
265
+ ? Math.trunc(parsed.frontmatter.indexOrder)
266
+ : 0, parsed.frontmatter.showInIndex === false ? 0 : 1, JSON.stringify(aliases), summary, markdown, contentPlain, typeof parsed.frontmatter.author === "string"
267
+ ? parsed.frontmatter.author
268
+ : null, "system", JSON.stringify(tags), null, JSON.stringify(parsed.frontmatter), now, now);
269
+ upsertLinks(id, links);
270
+ const note = getNoteById(id, { skipCleanup: true });
271
+ if (note) {
272
+ syncNoteWikiArtifacts(note);
273
+ }
274
+ inserted += 1;
275
+ }
276
+ if (options.deleteFiles) {
277
+ await rm(filePath, { force: true });
278
+ deleted += 1;
279
+ }
280
+ }
281
+ return {
282
+ dataRoot: options.dataRoot,
283
+ scanned,
284
+ wouldApply: options.apply,
285
+ wouldDelete: options.deleteFiles,
286
+ inserted,
287
+ updated,
288
+ deleted,
289
+ backupPath,
290
+ backedUp,
291
+ skippedAlreadyImported: false
292
+ };
293
+ }
294
+ function hasStartupImportMarker() {
295
+ const row = getDatabase()
296
+ .prepare("SELECT id FROM migrations WHERE id = ?")
297
+ .get(startupImportMarkerId);
298
+ return Boolean(row);
299
+ }
300
+ function markStartupImportComplete() {
301
+ getDatabase()
302
+ .prepare("INSERT OR IGNORE INTO migrations (id, applied_at) VALUES (?, ?)")
303
+ .run(startupImportMarkerId, new Date().toISOString());
304
+ }
305
+ export async function importLegacyWikiMarkdownOnStartup(dataRoot = getEffectiveDataRoot()) {
306
+ if (hasStartupImportMarker()) {
307
+ return {
308
+ dataRoot: path.resolve(dataRoot),
309
+ scanned: 0,
310
+ wouldApply: true,
311
+ wouldDelete: false,
312
+ inserted: 0,
313
+ updated: 0,
314
+ deleted: 0,
315
+ backupPath: legacyBackupPath(path.resolve(dataRoot), "legacy-wiki-markdown-pre-sqlite-import"),
316
+ backedUp: false,
317
+ skippedAlreadyImported: true
318
+ };
319
+ }
320
+ const result = await importLegacyWikiMarkdownToSqlite({
321
+ dataRoot,
322
+ apply: true,
323
+ deleteFiles: false,
324
+ backupBeforeApply: true
325
+ });
326
+ markStartupImportComplete();
327
+ return result;
328
+ }
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.49",
5
+ "version": "0.2.51",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.49",
3
+ "version": "0.2.51",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@ VALUES (
57
57
  'wiki_space_shared',
58
58
  'shared',
59
59
  'Shared Forge Memory',
60
- 'Shared wiki space for file-backed Forge knowledge.',
60
+ 'Shared wiki space for SQLite-backed Forge knowledge.',
61
61
  NULL,
62
62
  'shared',
63
63
  CURRENT_TIMESTAMP,
@@ -0,0 +1,8 @@
1
+ UPDATE wiki_spaces
2
+ SET description = 'Shared wiki space for SQLite-backed Forge knowledge.'
3
+ WHERE id = 'wiki_space_shared'
4
+ AND description != 'Shared wiki space for SQLite-backed Forge knowledge.';
5
+
6
+ UPDATE notes
7
+ SET source_path = ''
8
+ WHERE source_path != '';
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: forge-openclaw
2
+ name: forge-openclaw-plugin
3
3
  description: use when the user wants to save, search, update, review, start, stop, reward, explain, compare, or run Forge records, or when the conversation is clearly about a Forge entity such as a goal, project, strategy, task, habit, note, calendar_event, work_block_template, task_timebox, task_run, insight, preference item, preference context, preference catalog, questionnaire instrument, questionnaire run, self observation, psyche_value, behavior_pattern, behavior, belief_entry, mode_profile, mode_guide_session, trigger_report, event_type, emotion_definition, sleep_session, or workout_session. identify the exact Forge object, keep the main conversation natural, guide psyche intake with active listening before storing it, and for psyche issues that need understanding first usually begin with one exploratory question before any formulation or save suggestion.
4
4
  ---
5
5
 
@@ -59,7 +59,7 @@ PM surface rule:
59
59
  human/bot ownership filters.
60
60
  - Guided modal flows handle create, edit, move, link, and closeout actions.
61
61
 
62
- Forge has four major surfaces. The planning side covers goals, projects, strategies, tasks, habits, notes, calendar events, recurring work blocks, task timeboxes, live work sessions, and agent-authored insights. The Health side covers sleep sessions, sports and workout sessions, companion pairing, and habit-generated workout records that should still stay linked to the broader Forge graph. The Preferences side covers contextual taste modeling, pairwise comparisons, direct signals, editable concept libraries, and preference items that can come from Forge entities or seeded concept domains such as food, activities, places, countries, fashion, people, media, and tools. The Psyche side covers values, patterns, behaviors, beliefs, modes, guided mode sessions, trigger reports, event types, and reusable emotion definitions. Forge also has a file-first Wiki memory layer with explicit spaces, local markdown pages, backlinks, optional embeddings, and structured links back to Forge entities. Forge is also multi-user: every entity can belong to a typed `human` or `bot` user through `userId`, and read routes can scope to one or many users with `userId` or repeated `userIds`. The current access posture is configurable through a directional user graph, but the live default is still permissive: Forge can list users directly, every relationship edge starts open, and a user can read or affect another user's linked records when the route explicitly asks for them. Use `forge_get_user_directory` when owner identity or cross-user access matters. Strategies can also be locked into a contract with `isLocked`; once locked, do not mutate the graph or target structure unless the user explicitly wants the strategy unlocked first. The model should use the real entity names, not vague substitutes. Say `project`, not “initiative”. Say `behavior_pattern`, not “theme”. Say `trigger_report`, not “incident note”.
62
+ Forge has four major surfaces. The planning side covers goals, projects, strategies, tasks, habits, notes, calendar events, recurring work blocks, task timeboxes, live work sessions, and agent-authored insights. The Health side covers sleep sessions, sports and workout sessions, companion pairing, and habit-generated workout records that should still stay linked to the broader Forge graph. The Preferences side covers contextual taste modeling, pairwise comparisons, direct signals, editable concept libraries, and preference items that can come from Forge entities or seeded concept domains such as food, activities, places, countries, fashion, people, media, and tools. The Psyche side covers values, patterns, behaviors, beliefs, modes, guided mode sessions, trigger reports, event types, and reusable emotion definitions. Forge also has a SQLite-backed Wiki memory layer with explicit spaces, Markdown content in database rows, backlinks, optional embeddings, and structured links back to Forge entities. Forge is also multi-user: every entity can belong to a typed `human` or `bot` user through `userId`, and read routes can scope to one or many users with `userId` or repeated `userIds`. The current access posture is configurable through a directional user graph, but the live default is still permissive: Forge can list users directly, every relationship edge starts open, and a user can read or affect another user's linked records when the route explicitly asks for them. Use `forge_get_user_directory` when owner identity or cross-user access matters. Strategies can also be locked into a contract with `isLocked`; once locked, do not mutate the graph or target structure unless the user explicitly wants the strategy unlocked first. The model should use the real entity names, not vague substitutes. Say `project`, not “initiative”. Say `behavior_pattern`, not “theme”. Say `trigger_report`, not “incident note”.
63
63
  Habits are a first-class recurring entity in the planning side.
64
64
  NEGATIVE HABIT CHECK-IN RULE: for a `negative` habit, the correct aligned/resisted outcome is `missed`. `missed` means the bad habit was resisted, the user stayed aligned, and the habit should award its XP bonus.
65
65
 
@@ -74,9 +74,9 @@ Preferences rule:
74
74
  Wiki rule:
75
75
 
76
76
  - Treat the Wiki as the canonical long-form memory surface, not as a loose note dump.
77
- - Use the wiki tools when the user wants file-first reference pages, backlink-aware recall, ingest from a URL or local file, or wiki maintenance work such as unresolved-link cleanup.
77
+ - Use the wiki tools when the user wants SQLite-backed reference pages, backlink-aware recall, ingest from a URL or local file, or wiki maintenance work such as unresolved-link cleanup.
78
78
  - `forge_ingest_wiki_source` now queues the ingest as background work; use the Forge UI handoff when the user wants to review or keep only selected wiki/entity candidates after the ingest finishes.
79
- - Keep evidence notes and wiki pages conceptually distinct: evidence notes are linked operating records, while wiki pages are the curated memory vault.
79
+ - Keep evidence notes and wiki pages conceptually distinct: evidence notes are linked operating records, while wiki pages are curated long-form memory.
80
80
 
81
81
  Wiki navigation and search rule:
82
82
 
@@ -90,7 +90,7 @@ Wiki navigation and search rule:
90
90
  - Use `forge_search_wiki` as the default wiki recall tool. It is the main route for people, conversations, concepts, and exact page lookup.
91
91
  - Use `forge_list_wiki_pages` when the user wants to browse structure, inspect a branch, or understand how pages are organized rather than search by phrase.
92
92
  - Use `forge_get_wiki_page` after search yields a likely hit, or when the user already identified the page to open.
93
- - Use `forge_get_wiki_settings` or `forge_get_wiki_health` when the task is about wiki maintenance, indexing, unresolved links, ingest setup, or vault integrity rather than content recall.
93
+ - Use `forge_get_wiki_settings` or `forge_get_wiki_health` when the task is about wiki maintenance, indexing, unresolved links, ingest setup, or memory integrity rather than content recall.
94
94
 
95
95
  Health rule:
96
96
 
@@ -353,7 +353,7 @@ Use the batch entity tools for stored records:
353
353
  These tools operate on:
354
354
  `goal`, `project`, `strategy`, `task`, `habit`, `tag`, `note`, `insight`, `calendar_event`, `work_block_template`, `task_timebox`, `psyche_value`, `behavior_pattern`, `behavior`, `belief_entry`, `mode_profile`, `mode_guide_session`, `trigger_report`, `event_type`, `emotion_definition`, `preference_catalog`, `preference_catalog_item`, `preference_context`, `preference_item`, `questionnaire_instrument`, `sleep_session`, `workout_session`
355
355
 
356
- Use the wiki tools for file-first memory work:
356
+ Use the wiki tools for SQLite-backed memory work:
357
357
  `forge_get_wiki_settings`, `forge_list_wiki_pages`, `forge_get_wiki_page`, `forge_search_wiki`, `forge_upsert_wiki_page`, `forge_get_wiki_health`, `forge_sync_wiki_vault`, `forge_reindex_wiki_embeddings`, `forge_ingest_wiki_source`
358
358
 
359
359
  Use the health tools for review and reflective enrichment, not as the default CRUD architecture:
@@ -63,6 +63,11 @@ Forge correctly, and gather only the structure that still matters.
63
63
  and then act.
64
64
  - Once the route family is clear, say it plainly enough that another agent could follow
65
65
  the same path without guessing.
66
+ - For updates, start with the smallest thing that now feels wrong, newly true, or
67
+ newly visible. Do not make the user retell the whole record unless the change is
68
+ genuinely structural.
69
+ - For review requests, ask what practical question they want the read to answer before
70
+ you ask for more scope.
66
71
  - For meaning-bearing updates, especially in Psyche-adjacent work, briefly say what
67
72
  feels newly true before you ask for the one structural detail that still changes the
68
73
  save.
@@ -222,6 +227,31 @@ When you are about to save:
222
227
  enough or needs one correction
223
228
  - if the user confirms it, stop asking and save
224
229
 
230
+ ## Update And Review Shortcuts
231
+
232
+ Use these when the user is correcting, reviewing, or tightening something that already
233
+ exists.
234
+
235
+ - When the user already gave the correction in usable language, reflect what still
236
+ seems true, then ask only for the one thing that no longer fits.
237
+ - A good narrow update line is:
238
+ "I can stay narrow here. What is the one thing that no longer fits?"
239
+ - When the user is revising placement, timing, or ownership rather than meaning, do
240
+ not reopen the whole story. Confirm only the parent, interval, owner, or route scope
241
+ that changes the write.
242
+ - When the record is abstract or reusable and the user wants an update, ask what
243
+ future decision, comparison, or retrieval moment got muddy with the old wording.
244
+ - When the user wants review rather than mutation, ask what answer they need from the
245
+ read:
246
+ what this would help them decide later is often the clearest scope signal.
247
+ - For specialized surfaces, ask what exact saved object, span, weekday, flow, run, or
248
+ node the user wants to check before you ask why it matters.
249
+ - If the next answer would not change the route, wording, timing, links, or useful
250
+ interpretation, stop asking and act.
251
+ - Close cleanly:
252
+ once the user says the wording or route lands, summarize once and move to the read
253
+ or write.
254
+
225
255
  When an adjacent record becomes visible:
226
256
 
227
257
  - name it gently and ask whether it should be linked now, saved separately later, or
@@ -72,6 +72,10 @@ Forge without turning the conversation into a worksheet.
72
72
  container first and hold the others lightly until the user wants to map them.
73
73
  - When the user has said enough for an accurate working formulation, stop deepening and
74
74
  help them name it cleanly.
75
+ - For Psyche updates, begin with the smallest part of the old wording that no longer
76
+ fits instead of reopening the whole formulation immediately.
77
+ - Do not reopen the full origin story when the update is really about one changed
78
+ meaning, one newly visible protection, or one new sentence the user can already say.
75
79
 
76
80
  ## First reflection menu
77
81
 
@@ -193,6 +197,23 @@ opening:
193
197
  - one missing-detail question
194
198
  - then move toward the write instead of forcing exploration
195
199
 
200
+ ## Update micro-openers
201
+
202
+ Use these when the user is revising an existing Psyche record and the tone should stay
203
+ therapist-like without becoming expansive again.
204
+
205
+ - "Something about the old wording no longer holds the whole experience. What felt
206
+ different in the moment that made that visible?"
207
+ - "The old name was trying to protect something real. What part of the recent episode
208
+ made it feel too small or off?"
209
+ - "It sounds like the same pain, but not quite the same meaning. What changed in what
210
+ it started to say?"
211
+ - If the user already gave the new sentence in usable language, reflect it once, ask
212
+ what part of the old wording it replaces, and then save.
213
+ - If the user wants review rather than storage, ask whether they need clearer
214
+ language, better understanding, or next-step help before you reopen the whole
215
+ formulation.
216
+
196
217
  ## Therapeutic turn shapes
197
218
 
198
219
  Keep the pacing human and intentional.