Repository navigation
feat(api,ui): revision-checked knowledge edit, delete and restore - #1987
devin-ai-integration[bot] wants to merge 15 commits into
Conversation
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
Coverage Results 📊✅ Patch coverage is 89.85% (292 of 325 changed executable lines covered; target 80%). Changed files with executable lines (13)
Coverage diff@@ Coverage Diff @@
## main #1987 +/-##
==========================================
+ Coverage 85.00% 85.84% +0.84%
==========================================
Files 318 334 +16
Tracked lines 49482 51789 +2307
Branches 40478 42780 +2302
==========================================
+ Hits 42061 44455 +2394
+ Misses 7421 7334 -87
- Partials 4285 4483 +198Generated by Coverage Action |
|
Manual browser verification. Run against isolated real gateways (local on 7995, and hosted with Round 1 at
Passed in round 1:
Round 2 at
Not covered: sync-enabled and inline-AGENTS effects (neither is configured in the fixture). Round 2 didn't repeat every flow in all four viewport/theme combinations.
|
…1804) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ly (#1804) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The shell's Folk status loader (#1982) now reads sync status once per workspace, so the apply test counts the confirmation's own read as a delta. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
8a3b398 to
df6b1eb
Compare
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…bered Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…store Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
985a16e to
6fc42ad
Compare
| setSaveError( | ||
| "This entry was deleted. Your draft is kept on this device; restore the entry from History to continue.", | ||
| ); | ||
| props.reloadEntry(); |
There was a problem hiding this comment.
Bug: Error messages from setSaveError() are not displayed because setEditing(false) is called first, hiding the UI element that would show the error.
Severity: MEDIUM
Suggested Fix
Refactor the component to display the saveError message outside of the conditional block that depends on editing(). This will ensure that even when the editor is closed, the user is still notified of the error that occurred during the save operation.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: packages/ui/src/components/lore/KnowledgeEditor.tsx#L392-L395
Potential issue: When a user attempts to save a knowledge entry and the save fails due
to concurrent deletion or because editing is forbidden, the code calls
`setEditing(false)` before `setSaveError()`. This hides the editing form, which is the
only place the error message is configured to be displayed. As a result, the editor
closes without providing any feedback to the user, leaving them unaware of why their
changes were not saved. The error message is never rendered because its container is
hidden or unmounted before the error state is set.
Also affects:
packages/ui/src/components/lore/KnowledgeEditor.tsx:415~417
| setActiveApplyKey(record.key); | ||
| if (!(await submitApply(record, true))) break; | ||
| } | ||
| } finally { |
There was a problem hiding this comment.
Bug: An unhandled error in applyAccepted occurs if a group's status changes while the confirmation dialog is open, breaking the UI.
Severity: MEDIUM
Suggested Fix
Wrap the call to applyPlans() within a try-catch block inside the applyAccepted function. The catch block should handle the error by displaying a notification to the user and preventing the application from entering a broken state.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: packages/ui/src/components/lore/DuplicateReview.tsx#L469
Potential issue: The `applyAccepted` function can fail with an unhandled error. This
occurs if a user opens the confirmation dialog and then interacts with the background
UI, changing a group's status from 'accepted' to something else (e.g., 'skipped').
Because the dialog does not block mouse clicks on background elements, this is a
realistic user action. When the user then confirms the action, `applyAccepted` calls
`applyPlans()`, which throws an error because the group's state has changed. The missing
`try-catch` block around this call results in an unhandled promise rejection, leaving
the UI in a broken state with the dialog open.
## Summary
Promoted shared entries (`project_id = P AND cross_project = 1`) were
never dedup candidates: the project run took `forProject(P)` (which
*included* them, so the curator's automatic `deduplicate(..., { dryRun:
false })` could even remove a promoted row in favour of a private
survivor), and the shared run only took `project_id IS NULL`. Dedup now
works over three explicit pools:
| pool | rows compared | group `scope` (unchanged enum) | survivor |
|---|---|---|---|
| `project` | private(P) × private(P) — `project_id = P AND
cross_project = 0` | `project` | existing ranking |
| `project_shared` (new) | private(P) × shared-visible | `project` |
**always the shared entry** |
| `shared` | shared-visible × shared-visible — `project_id IS NULL OR
cross_project = 1` | `global` | existing ranking |
A project run never pairs two entries owned by other projects; those are
left to the shared run.
**Core (`ltm.ts`)**
- `deduplicate(projectPath)` / `deduplicateGlobal()` use their own pool
queries; `forProject()` is unchanged for other callers.
- New `deduplicateAgainstShared(projectPath, { exclude })`: always a dry
run. It scores every private×shared pair with the same two signals as
`_dedup` (title overlap ≥ 0.7 with ≥ 4 words, or cosine ≥ threshold).
Each private entry goes to its single best shared match; ties use the
survivor ranking. That gives one cluster per shared keeper, and the
shared entry always survives. `exclude` matches version id or logical
id.
- `_dedup` and the new function share `loadDedupEmbeddings` /
`scoreDedupPair`. Titles are tokenized once per run (`titleTermSet` /
`titleOverlapSets`); `titleOverlap()` delegates to them, so its results
are unchanged.
**Apply (`dedup-apply.ts`)**: the scope check is role-aware. Revision
checks, per-group transactions, tombstones, provenance, receipts,
idempotent replay and recovery are untouched.
```
projectId === null : keep + merges must all be shared-visible
projectId === P : merges must be private(P); keep may be private(P) or shared-visible
otherwise : scope_mismatch (no new refusal reason)
```
So a private entry can be folded into a shared keeper. A shared entry is
never removed in favour of a private one, and the survivor never changes
visibility.
- **Keep the dropped entry's content (`contentFromId`, additive and
opt-in):** a decision may name one of its `mergeIds` as `contentFromId`.
- That entry's title and content become a new version of the survivor:
same logical id, `project_id`, `cross_project` and category/metadata.
The source is then tombstoned like any other merge.
- This is how a "Project + shared" group keeps the private copy. The
shared entry stays shared and keeps every reference, only its content
changes, and the private content becomes shared because the reviewer
chose it.
- Revision checks still cover both entries, and provenance records
source→survivor.
- The receipt gains `contentFrom: { id, revision, replacedKeepRevision,
keepVersionId }`. `keepRevision` is the survivor's revision after apply,
so it stays usable as the next expected revision.
- The survivor's earlier version stays in history for recovery.
- Replays return the stored receipt.
- A new title that collides with a third live entry in the survivor's
scope (`ltm.titleCollides`, now exported) refuses the whole group with a
new `title_conflict` reason and rolls it back. The group's own merges
never count as collisions.
- Requests without `contentFromId` behave, and hash
(`dedupApplyPayloadHash`), byte-identically to before.
- `.lore.md` export now runs once per touched project:
`request.projectId` plus the origin project of every merged row. A
shared-pool merge can tombstone a promoted Q row, and Q's `.lore.md`
lists it, because `buildSection` exports `forProject(Q)`, which includes
promoted rows. With `contentFromId`, a promoted survivor's origin
project is exported too. Replays and fully refused operations still
export nothing.
**Gateway (`dedup-api.ts`)**: additive fields only. Each group gets
`pool: "project" | "shared" | "project_shared"`, and the response gets
`project_shared: DedupResult`. Existing `scope`, `project`, `global` and
the group-id prefixes (`project:`, `global:`) are unchanged; new groups
use `project_shared:`. The preview runs project → shared →
project_shared. Entries already clustered by the first two runs are
passed as `exclude`, by both version id and `logicalIdOf(id)`. The runs
are async, so a clustered entry may already be on a newer version. A
logical id therefore appears in at most one group. Groups are ordered
project, project_shared, shared.
**UI (small label only)**: `contracts/dedup.ts` accepts `pool` /
`project_shared`. The group label in `DuplicateReview.tsx` is now
pool-based: `Project` / `Project + shared` / `Shared`. The old `Shared
(no project)` label became wrong once promoted entries joined the shared
pool. Apply grouping still keys off `scope`, so `project_shared` groups
apply under the project, which the new scope rule allows.


<img
src="https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmctODQ3Y2E3ZDFjOTIzNDZiMzhmOGM4NTZmZmYzZmY5MDUvYjc5ODFhNTQtNmFkMC00MTIxLTkzOTgtNzE5YjYwNzBhYTBhIiwiaWF0IjoxNzkxMTk2Mzc4LCJleHAiOjE3OTE4MDExNzgsIm9yZ19pZCI6Im9yZy04NDdjYTdkMWM5MjM0NmIzOGY4Yzg1NmZmZjNmZjkwNSIsImZpbGVuYW1lIjoiZGVkdXAtbW9iaWxlLWxpZ2h0LnBuZyJ9.Uq35FGMR7mp3V848S_EOLE7E5GJXsFz-hq3RDEiAUeQ"
width="300"> <img
src="https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjpudWxsLCJidWNrZXRfbmFtZSI6ImRldmluYXR0YWNobWVudHMiLCJidWNrZXRfa2V5IjoiYXR0YWNobWVudHNfcHJpdmF0ZS9vcmctODQ3Y2E3ZDFjOTIzNDZiMzhmOGM4NTZmZmYzZmY5MDUvMTc4OWE5ZDAtYjM5MC00NzRiLTkxYjktNWZjMTM2ZGU1YjAxIiwiaWF0IjoxNzkxMTk2Mzc4LCJleHAiOjE3OTE4MDExNzgsIm9yZ19pZCI6Im9yZy04NDdjYTdkMWM5MjM0NmIzOGY4Yzg1NmZmZjNmZjkwNSIsImZpbGVuYW1lIjoiZGVkdXAtbW9iaWxlLWRhcmsucG5nIn0.6gSJhmD7fEzEwjwwm3VVfR1_i0Z3f2uP2ma328mUF9I"
width="300">
## Product choices (please confirm)
1. **Keeping the private copy in `project_shared` groups** works as
"replace the shared entry's content" (`contentFromId`). The preview
still suggests the shared entry as keeper. A plain keep=private,
merge=shared decision is still `scope_mismatch`, because it would remove
a shared row for every project. The UI apply flow (#1986) needs to send
`{ keepId: <shared>, mergeIds: [<private>, ...], contentFromId:
<private> }` when the user picks the private copy, and accept the
additive `contentFrom` / `title_conflict` receipt fields.
- **UI hookup is a follow-up in this PR:** the "keep the private copy"
option in the apply UI (it sends `contentFromId`) lands here once #1986
merges, after a rebase, with unit/e2e tests and browser evidence. Until
then `contentFromId` is reachable only through the API.
- **Title collisions** are reachable today only for case-insensitive
equal titles (main's `titleCollides` uses `LOWER(title)`). Once #1987
merges, its trimmed title key and shared-title uniqueness rule also
catch whitespace variants. `titleCollides` is the shared seam, so no
change is needed here.
- **No migration:** the "content replaced" fact lives in the stored
receipt (`dedup_operations.receipt`) and in the survivor's version
history. `dedup_provenance` keeps its schema (source→survivor row with
the reviewed keep revision).
2. **Origin-project export after shared-pool merges.** See above.
Heads-up for MEM-02 (#1986): its confirm copy says shared groups don't
affect `.lore.md` ("shared entries are not exported"). That is no longer
true when the merged shared entry is a promoted row.
3. **Cost of the wider shared pool.** The shared run is still O(m²) and
runs synchronously on the preview request, as it did before. The pool is
now bigger. Numbers are below. If real shared pools reach the thousands,
moving the preview off the event loop would be a follow-up.
## Benchmarks
Script: `~/bench-dedup/bench.mts` (not committed). Synthetic normalized
768-d embeddings, median of 5 runs, wall-clock ms, same machine.
| | before | after |
|---|---:|---:|
| `deduplicateGlobal` m=200 | 96.0 | 56.9 |
| `deduplicateGlobal` m=1000 | 2228.2 | 1067.4 |
| `deduplicateGlobal` m=2000 | 9859.7 | 5896.9 |
| `deduplicateAgainstShared` 200×200 | 117.3¹ | 96.4 |
| `deduplicateAgainstShared` 200×1000 | 522.7¹ | 237.6 |
| `deduplicateAgainstShared` 200×2000 | 2108.7¹ | 465.4 |
"Before" for `deduplicateGlobal` is `main`. ¹ This function doesn't
exist on `main`; these numbers are from this branch before the
title-tokenization change.
Realistic preview (500 shared, 200 private): `deduplicate` 34.6 ms,
`deduplicateGlobal` 294.3 ms, `deduplicateAgainstShared` 132.0 ms. Total
439.7 ms; the slowest single call is 294.3 ms.
## Tests
New/extended:
- `core/test/dedup.test.ts`
- Project run ignores promoted rows.
- Shared run includes promoted Q + NULL and promoted Q + promoted R, and
excludes private rows.
- Against-shared:
- keeps the shared entry even when the private one has higher
confidence;
- assigns each private entry to its best match;
- never pairs P-private with Q-private, or shared with shared;
- honours `exclude` by id and by logical id;
- makes no writes, and returns empty when there are no shared entries.
- `core/test/dedup-apply.test.ts`
- Shared pool: merges Q-promoted into a NULL keeper; refuses private
rows.
- Project pool: merges private(P) into a NULL keeper and into a
Q-promoted keeper. It refuses:
- NULL→private keep;
- promoted(P)→private keep;
- NULL keep with a Q-promoted merge;
- a Q-private keep.
- Keeper identity and provenance are preserved.
- Export: Q is exported once; NULL↔NULL exports nothing; Q and R are
each exported once; a replay exports nothing.
- `core/test/dedup-apply.test.ts`, `contentFromId`:
- Success with a NULL survivor and with a Q-promoted survivor: content
replaced, scope, logical id and history kept, provenance and receipt
correct, exports P (plus Q).
- Stale survivor and stale source are refused with nothing written.
- Replay.
- `title_conflict` rolls back the whole group.
- A same-title source applies.
- Private↔private.
- Validation: must be in `mergeIds`.
- Payload hash unchanged without the field (literal pinned from the
previous code).
- `gateway/test/api.test.ts`
- Route-level `contentFromId` on a `project_shared` group returns 200
with `contentFrom`, and the survivor is still shared. A bad
`contentFromId` returns 400.
- A `project_shared` group has `scope: "project"`, the route's project
id, and the shared entry as suggested keep; it applies through
`/dedup/apply` and the shared keeper stays unchanged.
- Q↔R promoted pairs appear only in the shared pool.
- When all entries are mutual duplicates, no logical id repeats across
groups.
- Additive `project_shared` is in the response.
- An entry edited between runs, so the shared cluster holds a stale
version id, still appears only in the shared group. This test fails
without `a4658a9c`.
- `gateway/test/ui-contracts.test.ts`: the real gateway preview, which
now includes a `project_shared` group, parses against the UI contract.
The fixture was regenerated.
- UI: `duplicate-review.test.tsx` checks all three labels. The e2e
`dedup-review.spec.ts` and `seed.mjs` seed a private↔promoted pair and
assert `Project + shared`.
Commands, on `2ce492c2` with base `main` @ `34a4d852`. Later commits
were rechecked as follows, plus CI:
- `a4658a9c` (preview exclude by logical id): `api.test.ts`,
`ui-contracts.test.ts`, typecheck, lint and format:check.
- `468dbede` (`contentFromId`): `dedup-apply.test.ts`, `dedup.test.ts`,
`api.test.ts`, `ui-contracts.test.ts`, `data-dedup-policy.test.ts`
(251/251), typecheck, lint, format:check, build and the gateway bundle.
- `eb9eb3d7`: pins timestamps on the dedup seeds in
`ui-contracts.test.ts`. `/knowledge` sorts by `updated_at DESC, id
DESC`, and same-millisecond seeds with random v4 ids made the `limit: 1`
page nondeterministic. Checked with `ui-contracts.test.ts` ×10, `pnpm
--filter @loreai/ui test` (803), typecheck, lint and format:check.
`knowledge-all-page.json` is identical to `main` again.
- Full `pnpm test` at `468dbede`: 12,317 passed, 9 failed, all in known
sandbox classes (git URL rewrite ×4, gateway remote header ×1,
agents-file mtime ×3) plus the `ui-contracts` ordering flake that
`eb9eb3d7` fixes.
Full list for `2ce492c2`:
- `pnpm install`, `pnpm run typecheck`, `pnpm run lint` (pre-existing
warnings only), `pnpm run format:check`, `pnpm run build`: pass.
- `npx vitest run packages/core/test/dedup.test.ts
packages/core/test/dedup-apply.test.ts
packages/core/test/curator-changed-entries.test.ts
packages/core/test/ltm.test.ts packages/gateway/test/api.test.ts
packages/gateway/test/ui-contracts.test.ts
packages/gateway/test/data-dedup-policy.test.ts`: pass.
- `pnpm --filter @loreai/core build`, `pnpm --filter @loreai/ui test`
(803 passed), `pnpm --filter @loreai/ui build`, `pnpm --filter
@loreai/gateway run bundle`, `node scripts/ui-deep-link-smoke.mjs`:
pass.
- `pnpm --filter @loreai/ui test:e2e`: the dedup-review spec passes on
desktop and mobile; the rest of the suite passes.
- `pnpm test` (full run): 7 failures, all in known sandbox classes that
also fail on clean `main`: git URL rewrite ×4, agents-file mtime ×2,
gateway remote header ×1. A `ui-static` dev-marker failure came from a
stale staged bundle and passed after rebuilding.
## Definition of done
- [x] Shared run clusters every shared-visible entry.
- [x] Project run surfaces private(P)↔shared pairs and never pairs two
other-project entries.
- [x] Shared entries are never removed by a project-scoped merge, and
the survivor never changes visibility. Private content becomes shared
only through an explicit `contentFromId`.
- [x] Keeping the private copy (`contentFromId`) has revision checks on
both entries, receipts, provenance, replay, `title_conflict` and
`.lore.md` export.
- [x] Revision checks, receipts, provenance, recovery and idempotency
are unchanged; export covers origin projects.
- [x] Existing response shapes unchanged; only `pool` and
`project_shared` are added.
- [x] Regression tests in core, gateway, contracts, UI unit and e2e.
- [x] No version bump, CHANGELOG edit, proxy/pipeline change, or
migration.
Closes #1980
Link to Devin session:
https://app.devin.ai/sessions/ee193c0c438e4de1954a6867892eb986
Open in Devin Desktop:
https://app.devin.ai/desktop/session/ee193c0c438e4de1954a6867892eb986?variant=devin
Requested by: @BYK
---------
Co-authored-by: Burak Yigit Kaya <ben@byk.im>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Summary
MEM-03: knowledge entries can now be edited, deleted and restored from
/ui/…/knowledge/:id. Every write checks the revision, and each confirmation and receipt shows its.lore.md, AGENTS.md and sync consequences. Closes #1805. Part of the P2 gate of #1824.Stacked on #1986 (MEM-02). #1983 is merged and #1986 has been rebased onto
main. Review only the commits after558408f8. Once #1986 squash-merges, this branch gets rebased withgit rebase --onto origin/main 558408f8.Core:
packages/core/src/knowledge-edit.ts(exported asknowledgeEdit)editKnowledge(id, { expectedRevision, actor, title?, content?, category?, confidence?, scope? })cross_project, through a new optionalcrossProjectoverride onltm.appendVersion.ltm.update) and appends no version..lore.md.deleteKnowledgeChecked(id, { expectedRevision, actor })writes a tombstone only if the head is unchanged.restoreKnowledge(id, { expectedRevision, actor, versionId? })appends the chosen live version, or the latest live one, as a new revision. It returnsrestored_from. Tombstone targets and the version that is already current are refused, and history is never rewritten.knowledgeEffects(id)returns{ scope, project_id, revision, is_deleted, lore_file{enabled,path,affected}, agents_file{enabled, mode: pointer|inline|off, immediate:false}, sync{enabled} }.KnowledgeEditError, codedinvalid_request | not_found | deleted | stale_revision | title_conflict. Stale and deleted errors carryexpected_revisionandcurrent_revision. Edit and checked delete refuse a tombstone head asdeletedbefore comparing revisions; restore accepts a tombstone head.ltm.update, an edit refuses a colliding title with 409 instead of silently dropping it.Gateway:
knowledge-edit-api.tsandroutes/knowledge.tsPATCH /api/v1/knowledge/:id{expected_revision, title?, content?, category?, confidence?, scope?, actor?}, unknown keys → 400POST /api/v1/knowledge/:id/restore{expected_revision, version_id?, actor?}GET /api/v1/knowledge/:id/effectsDELETE /api/v1/knowledge/:id?expected_revision=NStatus codes:
stale_revision,deletedandtitle_conflictThe existing
GET /knowledge/:idand/versionsroutes still resolve; the route-registry test covers this.UI
KnowledgeEditor.tsxis an inline editor on the knowledge document, with title, content, category, confidence and a project/shared scope switch. A projectless entry is pinned to shared.drafts, withbaseRevision, and are written on a debounce or on Cancel. Nothing reaches the gateway until Save. A banner reads "Unsaved draft from … (based on vN)" with Resume and Discard.stale_revisionon save, keeps the draft. It shows the server version and the draft side by side, and asks for an explicit "Continue editing on vM" rebase before saving.title_conflictshows inline. A hosted 403 saves the draft and locks the editor.Saved as vN · <lore effect> · <AGENTS effect> · <sync>./effectsfirst. If the revision moved, the entry reloads before the confirmation opens. The confirmation lists the consequences and sends the checked revision, and the entry then opens in the deleted-entry view.RestoreKnowledgeAction.tsx) is available on the deleted-entry view from MEM-02 and on each superseded live version in History. It replaces the MEM-02 "Restoring arrives with knowledge editing (MEM-03: Safe knowledge edit/delete with versioned restoration and visible sync/export effects #1805)" placeholder. The confirmation, stale reload and receipt work the same way as delete.Shared-title uniqueness (owner request)
Rule: among live entries visible as shared (
project_id IS NULL OR cross_project = 1), titles must be unique after normalisation:ltm.normalizeTitleKey(t) = t.trim().toLowerCase(), orltm.titleKeySql(col)in SQL. Internal whitespace is not collapsed, soX YandX Ycount as different titles. Core enforces the rule on every path that makes an entry shared-visible (ltm.findSharedTitleConflict/findTitleConflict). No unique index or migration was added, because existing data may already contain duplicates (see below).User-facing paths reject with a 409
title_conflict. The error carries the additiveconflicting_entry: { id, title, project_id, scope }, which names the existing entry:PATCH /knowledge/:idand restore:editKnowledgeandrestoreKnowledgecheck the target state whenever the normalised title or the scope changes, so a project→shared flip is checked too. Two exceptions are allowed:X→x;Errors are checked in the order
deleted→stale_revision→title_conflict.Move:
reassignKnowledge,POST …/moveandlore data move knowledgecheck inside the transaction. A refused move leaves the project, the scope and the transfer rows unchanged. The API throwsTitleConflictError, which becomes the same 409. The CLI names the existing entry and points to duplicate review.UI: the editor and Restore show the existing title (as inert text) with a link to it (
knowledgeHreforglobalKnowledgeHref) and a Review duplicates link toduplicatesHref. If the details are absent or malformed, they fall back to the generic message.Automated paths never throw or drop knowledge. They reuse the existing
ltm.create/updatededup guard, now with normalised titles:tryCreate: a title that matches a shared entry merges into that entry (content and metrics) instead of creating a duplicate, as the existing exact-title guard already did. Explicit-ID shared and projectless creates are covered too. This applies to the curator, to the entries pattern extraction hands toltm.create, and to structured import.ltm.updatebehaviour.promoteCrossProject: a candidate that would collide stays project-scoped with its content intact. It is listed in the additiveconflictsresult and in the curator log, and a dry run reports the same list, including collisions between candidates in the same run..lore.mdimport stays project-scoped (crossProject: false), so it never creates a shared entry. A hand-written entry whose title matches a shared entry merges into it without loss, and an unknown UUID is created project-only.Existing duplicates: nothing is renamed or deleted.
ltm.listSharedTitleDuplicates()reports them. The dedup preview gains an additiveshared_title_conflictsfield, and the duplicate-review page shows a read-only Shared title conflicts section that links each entry. Merging across projects is #1980.While doing this I found that
reassignKnowledgesetscross_project = 1on project→project moves, which makes a project-only entry shared. Filed as #1989 and not changed here..lore.mdcopy follows the real exporter.buildSectionexports every current entry whoseproject_idis this project, including project-owned shared (cross_project = 1) entries, even though its comment says otherwise. That mismatch is filed as #1988. Solore_file.affectedisproject_id !== null. Only entries without a project say ".lore.md files are not affected (entries without a project are not exported)".AGENTS.md copy is honest: pointer mode says "AGENTS.md pointer unchanged". Inline mode says the section updates on the next idle export. Nothing claims an immediate AGENTS change.
Notes / open questions
"lore-ui", the same as MEM-02.Tests
Shared-title commits (
9b20940b..df6b1eb8), full gate ondf6b1eb8:pnpm install --frozen-lockfile,pnpm --filter @loreai/core build,pnpm run typecheck,pnpm run lint(existing warnings only),pnpm run format:check: passpnpm test(full): 549 files passed, 2 failed, 17 skipped. Tests: 12,336 passed, 5 failed, 229 skipped. The 5 failures are the known sandbox ones that also fail on cleanmain: four git URL rewrites andX-Lore-Git-Remote.pnpm --filter @loreai/ui test: 50 files, 849 tests passedpnpm --filter @loreai/ui build,pnpm --filter @loreai/gateway run bundle,node scripts/ui-deep-link-smoke.mjs,pnpm run build: passpnpm --filter @loreai/ui test:e2e: 203 passed, 11 skipped (the existing viewport-specific skips)New shared-title tests:
test/shared-title.test.ts:X: sharing one is allowed; sharing the other is refused and its row and revision stay unchangedtryCreatemergeltm.updateretitle collision is handledcrossProjectcreate is mergedlistSharedTitleDuplicatesis deterministic and read-only.lore.mdtitle variant merges without loss, and an unknown UUID stays project-onlytest/api.test.tsandtest/cli-data-contract.test.ts: the PATCH, restore and move 409 envelopes withconflicting_entry; the CLI move refusaltest/api-client.test.ts,test/knowledge-edit.test.tsxandtest/duplicate-review.test.tsx:knowledge-edit.spec.tsanddedup-review.spec.ts(seeded inseed.mjs): a sharing conflict with its links, and the duplicate-review section985a16e1fixes a bug found during browser verification. After an edit, delete or restore,Browsenow callsrefreshAfterKnowledgeWrite(), which reloads the entry, the versions, the active knowledge-page loaders andws.projects. Before this, a delete followed by browser Back left the entry in the sidebar with a stale count.KnowledgeEditorgainsonSaved.test/shell.test.tsx: after a delete, a restore and a retitle, the sidebar and counts refresh.knowledge-edit.spec.ts: after a delete, browser Back shows the entry gone from the list and the count down, without a reload.Earlier MEM-03 commits:
Full gate run on
cd9367bc(baseorigin/mainfde28620). The follow-ups were re-verified with the commands that cover them:b308bf27(only changed fields are sent): UI suite 799 passed; E2E 195 passed, 11 skipped.44fdc3ce(the autosave re-arms on every edit; export effects followproject_id): focused coreknowledge-edit.test.ts10 passed;knowledge-edit.spec.tsE2E 4 passed. The full UI suite had 800 passed and 1 failed: the in-session-search virtualization assertion insession-view.test.tsx, which this PR doesn't touch. That spec passes in isolation on this branch and on clean main (66/66 each); being tracked in CI.16df62ca(the editor handlesdeletedrefusals, from the Seer review): UI suite 803 passed.138f2018(edit and checked delete reportdeletedbeforestale_revision, from the Seer review): focused core and gateway tests 101 passed; UI 803 passed.Full gate on
cd9367bc:pnpm install,pnpm run typecheck,pnpm run lint(warnings only, exit 0),pnpm run format:check: passpnpm test(full): 545 passed, 17 skipped, 7 failures, all in the sandbox-noise classes. Five reproduce on cleanorigin/main: four git URL rewrites and the gatewayX-Lore-Git-Remoteheader. The other two areagents-filemtime tests, the same flaky class documented on feat(ui): apply reviewed dedup decisions with receipts and recovery #1986 (repeated runs of that file fail a different subset each time). This PR doesn't touch agents-file or the export code.pnpm run build,pnpm --filter @loreai/core build,pnpm --filter @loreai/ui build,pnpm --filter @loreai/gateway run bundle: passnode scripts/ui-deep-link-smoke.mjs: passpnpm --filter @loreai/ui test: 48 files, 794 tests passedpnpm --filter @loreai/ui test:e2e: 195 passed, 11 skipped (the existing viewport-specific skips)packages/gateway/test/sync.property.test.ts×10: all passedNew tests:
test/knowledge-edit.test.ts:test/api.test.ts:test/management-access.test.ts: an off-loopback request to each new route gets an empty-body 404 and mutates nothingtest/route-registry.test.ts: the new routes don't shadow GET/knowledge/:idor/versionstest/knowledge-edit.test.tsx(28 cases):deletedrefusal on save keeps the draft and reloads; on delete it counts as donetest/api-client.test.ts: the new client methods and how the error type is surfacede2e/knowledge-edit.spec.ts, on disposable per-viewport entries:Definition of done
.lore.md, AGENTS.md and sync effects are shown before and after every write.Screenshots
Link to Devin session: https://app.devin.ai/sessions/36dfe06b1fff4f36932bc410ca42a2cc
Open in Devin Desktop: https://app.devin.ai/desktop/session/36dfe06b1fff4f36932bc410ca42a2cc?variant=devin
Requested by: @BYK