clay-server 4.0.0-beta.16 → 4.0.0-beta.17

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.
@@ -13,7 +13,7 @@
13
13
  // - A project session is bound to exactly its own project's Logs.
14
14
  // - Canonical authorship belongs to Project Driver sessions alone. A human,
15
15
  // however privileged, may read and comment but may never create or revise
16
- // a canonical entry: the log is the agent's record of the work.
16
+ // a canonical entry. The project owner may delete an entry.
17
17
  // - Members of a shared project read each other's entries and comments with
18
18
  // attribution.
19
19
  // - A worktree resolves to its parent project for both authorization and
@@ -39,6 +39,10 @@ function driverOnly() {
39
39
  throw new Error("Only this project's agent sessions can review feedback or change revisions.");
40
40
  }
41
41
 
42
+ function ownerOnly() {
43
+ throw new Error("Only the project owner can delete Project Logs.");
44
+ }
45
+
42
46
  // Bounded discovery of comments still waiting on the Driver. The store owns the
43
47
  // one-pass scan over every live entry, so nothing here imposes a ceiling that
44
48
  // could hide a pending comment on an older record.
@@ -51,11 +55,17 @@ function attachProjectLogsService(ctx) {
51
55
  var findUserById = context.findUserById || function () { return null; };
52
56
  var storeCache = new Map();
53
57
 
54
- var openStore = context.openStore || function (cwd) {
55
- if (!storeCache.has(cwd)) {
56
- storeCache.set(cwd, logsStore.createProjectLogsStore({ cwd: cwd, baseDir: context.baseDir }));
58
+ var openStore = context.openStore || function (cwd, storeOptions) {
59
+ var options = storeOptions || {};
60
+ var key = options.projectKnowledgeId || cwd;
61
+ if (!storeCache.has(key)) {
62
+ storeCache.set(key, logsStore.createProjectLogsStore({
63
+ cwd: cwd,
64
+ baseDir: context.baseDir,
65
+ projectKnowledgeId: options.projectKnowledgeId || null,
66
+ }));
57
67
  }
58
- return storeCache.get(cwd);
68
+ return storeCache.get(key);
59
69
  };
60
70
 
61
71
  function projectFor(slug) {
@@ -98,7 +108,26 @@ function attachProjectLogsService(ctx) {
98
108
  function storeForStatus(status) {
99
109
  var governing = effectiveStatus(status);
100
110
  if (!governing || !governing.path) throw new Error("Project Logs are unavailable for this project.");
101
- return openStore(governing.path);
111
+ return openStore(governing.path, { projectKnowledgeId: governing.projectKnowledgeId || null });
112
+ }
113
+
114
+ function recordContext(status) {
115
+ if (!status || status.isWorktree !== true || !status.changeSetId) return null;
116
+ return {
117
+ kind: "worktree",
118
+ changeSetId: status.changeSetId,
119
+ branch: status.branch || null,
120
+ baseCommit: status.baseCommit || null,
121
+ headCommit: status.headCommit || null,
122
+ status: "active",
123
+ };
124
+ }
125
+
126
+ function scopedArgs(args, status) {
127
+ var out = Object.assign({}, args || {});
128
+ if (!out.contextMode) out.contextMode = status && status.isWorktree ? "current" : "project";
129
+ out.currentChangeSetId = status && status.changeSetId || null;
130
+ return out;
102
131
  }
103
132
 
104
133
  function statusFor(principal, slug) {
@@ -205,6 +234,7 @@ function attachProjectLogsService(ctx) {
205
234
  principal.kind = "user";
206
235
  principal.projectSlug = source.projectSlug;
207
236
  principal.isClay = false;
237
+ principal.isProjectOwner = ownsProject(principal, status);
208
238
  var envelope = { principal: principal, status: status, author: userAuthor(user) };
209
239
  return createProjectBound(envelope, function () { return envelope; });
210
240
  }
@@ -279,40 +309,85 @@ function attachProjectLogsService(ctx) {
279
309
  return revalidate().author;
280
310
  }
281
311
 
312
+ function currentStatus() {
313
+ var current = revalidate();
314
+ var project = projectFor(current.principal.projectSlug);
315
+ if (!project) throw new Error("Project not found.");
316
+ return project.getStatus();
317
+ }
318
+
319
+ function mutationContext() {
320
+ return recordContext(currentStatus());
321
+ }
322
+
282
323
  // A session may write canonically; a user may only comment. The capability
283
324
  // is decided by the binder that produced this envelope, never by a flag on
284
325
  // the request.
285
326
  var canonical = initial.principal.kind === "session";
327
+ var canDelete = canonical || initial.principal.isProjectOwner === true;
286
328
  return {
287
329
  isClay: false,
288
330
  canWrite: canonical,
289
331
  canComment: true,
332
+ canDelete: canDelete,
290
333
  projectSlug: initial.principal.projectSlug,
291
334
  scopeId: store.scopeId,
292
335
  root: store.root,
293
336
  author: initial.author,
294
- listLogs: function (args) { return live().list(args || {}); },
295
- searchLogs: function (args) { return live().search(args || {}); },
337
+ listLogs: function (args) {
338
+ var status = currentStatus();
339
+ var options = scopedArgs(args, status);
340
+ var result = live().list(options);
341
+ result.contextMode = options.contextMode;
342
+ result.currentChangeSetId = status.changeSetId || null;
343
+ result.isWorktree = status.isWorktree === true;
344
+ return result;
345
+ },
346
+ searchLogs: function (args) {
347
+ var status = currentStatus();
348
+ var options = scopedArgs(args, status);
349
+ var result = live().search(options);
350
+ result.contextMode = options.contextMode;
351
+ result.currentChangeSetId = status.changeSetId || null;
352
+ result.isWorktree = status.isWorktree === true;
353
+ return result;
354
+ },
296
355
  readLog: function (args) {
297
356
  var entry = live().read(args && args.ref, false);
298
357
  if (!entry) throw new Error("Log entry not found.");
299
358
  return entry;
300
359
  },
301
360
  logHistory: function (args) { return live().history(args && args.ref, args || {}); },
302
- commentLog: function (args) { return live().comment(args && args.ref, args || {}, author()); },
361
+ commentLog: function (args) { return live().comment(args && args.ref, args || {}, author(), mutationContext()); },
303
362
  readLogRevision: function (args) { return live().readRevision(args && args.ref, args && args.revision); },
304
363
  listLogFeedback: canonical ? function (args) { return live().feedback(args || {}); } : driverOnly,
305
- reviewLogComment: canonical ? function (args) { return live().review(args && args.ref, args || {}, author()); } : driverOnly,
364
+ reviewLogComment: canonical ? function (args) { return live().review(args && args.ref, args || {}, author(), mutationContext()); } : driverOnly,
306
365
  revertLog: canonical
307
- ? function (args) { return live().revert(args && args.ref, args && args.revision, args && args.reason, author()); }
366
+ ? function (args) { return live().revert(args && args.ref, args && args.revision, args && args.reason, author(), mutationContext()); }
308
367
  : driverOnly,
309
- createLog: canonical ? function (args) { return live().create(args || {}, author()); } : agentOnly,
310
- updateLog: canonical ? function (args) { return live().update(args && args.ref, args || {}, author()); } : agentOnly,
311
- linkLog: canonical ? function (args) { return live().link(args && args.ref, args && args.links, author()); } : agentOnly,
312
- removeLog: canonical ? function (args) { return live().remove(args && args.ref, author()); } : agentOnly,
368
+ createLog: canonical ? function (args) { return live().create(args || {}, author(), mutationContext()); } : agentOnly,
369
+ updateLog: canonical ? function (args) { return live().update(args && args.ref, args || {}, author(), mutationContext()); } : agentOnly,
370
+ linkLog: canonical ? function (args) { return live().link(args && args.ref, args && args.links, author(), mutationContext()); } : agentOnly,
371
+ removeLog: canDelete ? function (args) {
372
+ var current = revalidate();
373
+ var project = projectFor(current.principal.projectSlug);
374
+ var currentStatus = project && project.getStatus();
375
+ if (!project || !authorizedProject(current.principal, currentStatus)) {
376
+ throw new Error("Project Logs are not available for this project.");
377
+ }
378
+ if (current.principal.kind !== "session" && !ownsProject(current.principal, currentStatus)) ownerOnly();
379
+ return storeForStatus(currentStatus).remove(args && args.ref, current.author, recordContext(currentStatus));
380
+ } : ownerOnly,
313
381
  };
314
382
  }
315
383
 
384
+ function setContextState(projectSlug, status) {
385
+ var project = projectFor(projectSlug);
386
+ var projectStatus = project && project.getStatus();
387
+ if (!projectStatus || !projectStatus.isWorktree || !projectStatus.changeSetId) return false;
388
+ return storeForStatus(projectStatus).setContextState(recordContext(projectStatus), status);
389
+ }
390
+
316
391
  function createClayBound(revalidate) {
317
392
  function target(args) {
318
393
  var principal = revalidate();
@@ -325,6 +400,7 @@ function attachProjectLogsService(ctx) {
325
400
  isClay: true,
326
401
  canWrite: false,
327
402
  canComment: false,
403
+ canDelete: false,
328
404
  projectSlug: null,
329
405
  author: null,
330
406
  listLogs: function (args) { return target(args).list(args || {}); },
@@ -351,6 +427,7 @@ function attachProjectLogsService(ctx) {
351
427
  bindProjectSession: bindProjectSession,
352
428
  bindUser: bindUser,
353
429
  bindMate: bindMate,
430
+ setContextState: setContextState,
354
431
  };
355
432
  }
356
433
 
@@ -15,6 +15,7 @@
15
15
  // shape, so history is deterministic across the boundary.
16
16
 
17
17
  var logsSchema = require("./project-logs-schema");
18
+ var logContext = require("./project-log-context");
18
19
 
19
20
  // Ops that advance the canonical record. `comment` and a non-incorporating
20
21
  // `review` are participation, not revisions, and never appear here.
@@ -146,6 +147,7 @@ function revisions(chain, maxLinks) {
146
147
  op: record.op === "review" ? "incorporate" : record.op,
147
148
  at: record.at || 0,
148
149
  author: record.author || null,
150
+ context: record.context || null,
149
151
  snapshot: state,
150
152
  changed: record.op === "create" ? [] : changedFields(previous, state),
151
153
  // Present only on a revert, so a reader can see where it came from.
@@ -176,6 +178,7 @@ function describeRevision(entry) {
176
178
  op: entry.op,
177
179
  at: entry.at,
178
180
  author: entry.author,
181
+ context: logContext.normalizeRecordContext(entry.context),
179
182
  changed: entry.changed,
180
183
  revertedFrom: entry.revertedFrom,
181
184
  reason: entry.reason,
@@ -1,13 +1,4 @@
1
1
  // Project Logs projection over the Clay-wide knowledge record backend.
2
- //
3
- // Project Logs are dry, factual project records: decisions, investigations,
4
- // session notes, runbooks, references, incidents, and progress. They carry no
5
- // persona and no autobiographical memory; that separation is a product rule,
6
- // not a storage detail, so nothing here writes identity-shaped content.
7
- //
8
- // A worktree shares its parent project's Logs. The store root is resolved
9
- // through Git's common directory, so a worktree session and a parent session
10
- // read and write the same record file instead of forking project knowledge.
11
2
 
12
3
  var fs = require("fs");
13
4
  var path = require("path");
@@ -19,6 +10,8 @@ var logsSnapshot = require("./project-logs-snapshot");
19
10
  var logsComments = require("./project-logs-comments");
20
11
  var logsVersioning = require("./project-logs-versioning");
21
12
  var logsRoot = require("./project-logs-root");
13
+ var logContext = require("./project-log-context");
14
+ var contextState = require("./project-logs-context-state");
22
15
 
23
16
  var MAX_SUMMARY_CHARS = logsSchema.MAX_SUMMARY_CHARS;
24
17
  var MAX_COMMENT_CHARS = 4000;
@@ -56,18 +49,17 @@ function validateKind(kind, fallback) {
56
49
  function createProjectLogsStore(opts) {
57
50
  var options = opts || {};
58
51
  var root = options.root ? path.resolve(options.root) : logsRoot.resolveProjectRoot(options.cwd, options.runGit);
59
- var scopeId = logsRoot.scopeIdForRoot(root);
52
+ var scopeId = options.projectKnowledgeId
53
+ ? logsRoot.scopeIdForKnowledgeId(options.projectKnowledgeId)
54
+ : logsRoot.scopeIdForRoot(root);
60
55
  var store = recordStore.createRecordStore({ scopeId: scopeId, baseDir: options.baseDir });
56
+ var contexts = contextState.attachContextState(store, scopeId);
61
57
 
62
- // Stable across every revision of an entry: derived from the scope and the
63
- // chain root, never from a path or an internal record id.
64
58
  function refFor(rootId) {
65
59
  var digest = crypto.createHash("sha256").update(scopeId + "\u0000" + String(rootId)).digest("base64url");
66
60
  return "log:" + digest.substring(0, 24);
67
61
  }
68
62
 
69
- // File order is causal order. Wall-clock timestamps are recorded but never
70
- // used to order a chain, so a skewed clock cannot reorder a revision.
71
63
  function chains() {
72
64
  var records = store.all();
73
65
  var byRoot = new Map();
@@ -80,24 +72,19 @@ function createProjectLogsStore(opts) {
80
72
  return byRoot;
81
73
  }
82
74
 
83
- // The canonical record is reconstructed from its revision chain, and the
84
- // discussion is folded separately. Neither comments nor a clarify/decline
85
- // review touch the canonical state or the revision count.
86
- function fold(rootId, chain) {
75
+ function fold(rootId, chain, knownContextStates) {
87
76
  var history = logsSnapshot.revisions(chain, MAX_LINKS);
88
77
  if (!history.started || history.revisions.length === 0) return null;
89
78
  var last = history.revisions[history.revisions.length - 1];
90
79
  var first = history.revisions[0];
80
+ var originRecord = null;
81
+ for (var ci = 0; ci < chain.length; ci++) {
82
+ if (chain[ci].id === first.sourceRecordId) { originRecord = chain[ci]; break; }
83
+ }
91
84
 
92
- // A create record that does not carry the minimum a log entry needs is not
93
- // a log entry. Hand-edited files and torn writes that were later completed
94
- // can look structurally valid without being usable.
95
85
  if (!logsSchema.safeCategory(first.snapshot.category)) return null;
96
86
  if (!first.snapshot.title || !first.snapshot.title.trim()) return null;
97
87
 
98
- // Each reconstructed revision knows exactly which record produced it, so
99
- // an incorporating review maps to its revision by identity. Two reviews
100
- // sharing a millisecond cannot be confused for one another.
101
88
  var revisionForRecordId = {};
102
89
  for (var v = 0; v < history.revisions.length; v++) {
103
90
  var source = history.revisions[v].sourceRecordId;
@@ -128,7 +115,12 @@ function createProjectLogsStore(opts) {
128
115
  deleted: !!history.deleted,
129
116
  deletedAt: history.deleted ? history.deleted.at : null,
130
117
  deletedBy: history.deleted ? history.deleted.author : null,
118
+ context: logContext.normalizeRecordContext(originRecord && originRecord.context),
131
119
  };
120
+ if (entry.context.changeSetId) {
121
+ var states = knownContextStates || contexts.states();
122
+ entry.context.status = states[entry.context.changeSetId] || entry.context.status;
123
+ }
132
124
  // Entries written before summaries existed get a deterministic one derived
133
125
  // from their own body. Nothing on disk is rewritten.
134
126
  entry.summary = logsSchema.fallbackSummary(entry);
@@ -142,8 +134,9 @@ function createProjectLogsStore(opts) {
142
134
 
143
135
  function entries(includeDeleted) {
144
136
  var out = [];
137
+ var states = contexts.states();
145
138
  chains().forEach(function (chain, rootId) {
146
- var entry = fold(rootId, chain);
139
+ var entry = fold(rootId, chain, states);
147
140
  if (!entry) return;
148
141
  if (entry.deleted && !includeDeleted) return;
149
142
  out.push(entry);
@@ -181,7 +174,7 @@ function createProjectLogsStore(opts) {
181
174
  return located;
182
175
  }
183
176
 
184
- function create(input, author) {
177
+ function create(input, author, context) {
185
178
  var data = input || {};
186
179
  var validated = logsSchema.validateNewRecord(data);
187
180
  var title = cleanLine(data.title || "", MAX_TITLE_CHARS);
@@ -213,6 +206,7 @@ function createProjectLogsStore(opts) {
213
206
  links: snapshot.links,
214
207
  snapshot: snapshot,
215
208
  author: normalizeAuthor(author),
209
+ context: logContext.normalizeRecordContext(context),
216
210
  at: Date.now(),
217
211
  });
218
212
  return read(refFor(id), false);
@@ -243,7 +237,7 @@ function createProjectLogsStore(opts) {
243
237
  return logsSnapshot.cloneSnapshot(entry);
244
238
  }
245
239
 
246
- function update(ref, changes, author) {
240
+ function update(ref, changes, author, context) {
247
241
  var located = requireLive(ref);
248
242
  var data = changes || {};
249
243
  var validated = logsSchema.validateRevision(data);
@@ -252,7 +246,7 @@ function createProjectLogsStore(opts) {
252
246
  if (logsSnapshot.sameSnapshot(current, snapshot)) {
253
247
  throw new Error("An update requires at least one changed field.");
254
248
  }
255
- appendRevision(located.rootId, "update", snapshot, author, {});
249
+ appendRevision(located.rootId, "update", snapshot, author, { context: logContext.normalizeRecordContext(context) });
256
250
  return read(ref, false);
257
251
  }
258
252
 
@@ -284,7 +278,7 @@ function createProjectLogsStore(opts) {
284
278
  // Human participation. Append-only for this slice: no edit, no delete, and
285
279
  // never a canonical revision. The author is supplied by the authorization
286
280
  // layer, never by the caller's payload.
287
- function comment(ref, input, author) {
281
+ function comment(ref, input, author, context) {
288
282
  var located = requireLive(ref);
289
283
  var data = input || {};
290
284
  var body = cleanText(data.body || "", MAX_COMMENT_CHARS);
@@ -300,12 +294,13 @@ function createProjectLogsStore(opts) {
300
294
  scope: scopeId,
301
295
  body: body,
302
296
  author: normalizeAuthor(author),
297
+ context: logContext.normalizeRecordContext(context),
303
298
  at: Date.now(),
304
299
  });
305
300
  return read(ref, false);
306
301
  }
307
302
 
308
- function link(ref, links, author) {
303
+ function link(ref, links, author, context) {
309
304
  var located = requireLive(ref);
310
305
  var normalized = normalizeLinks(links);
311
306
  if (normalized.length === 0) throw new Error("At least one link reference is required.");
@@ -321,11 +316,11 @@ function createProjectLogsStore(opts) {
321
316
  if (logsSnapshot.sameSnapshot(current, snapshot)) {
322
317
  throw new Error("Those links are already attached to this entry.");
323
318
  }
324
- appendRevision(located.rootId, "link", snapshot, author, {});
319
+ appendRevision(located.rootId, "link", snapshot, author, { context: logContext.normalizeRecordContext(context) });
325
320
  return read(ref, false);
326
321
  }
327
322
 
328
- function remove(ref, author) {
323
+ function remove(ref, author, context) {
329
324
  var located = requireLive(ref);
330
325
  store.append({
331
326
  id: recordStore.newRecordId(),
@@ -333,6 +328,7 @@ function createProjectLogsStore(opts) {
333
328
  op: "delete",
334
329
  scope: scopeId,
335
330
  author: normalizeAuthor(author),
331
+ context: logContext.normalizeRecordContext(context),
336
332
  at: Date.now(),
337
333
  });
338
334
  return read(ref, true);
@@ -473,6 +469,7 @@ function createProjectLogsStore(opts) {
473
469
  readRevision: versioning.readRevision,
474
470
  link: link,
475
471
  remove: remove,
472
+ setContextState: contexts.set,
476
473
  read: read,
477
474
  list: list,
478
475
  search: search,
@@ -9,6 +9,7 @@ var logsSchema = require("./project-logs-schema");
9
9
  var logsSnapshot = require("./project-logs-snapshot");
10
10
  var logsComments = require("./project-logs-comments");
11
11
  var recordStore = require("./knowledge-record-store");
12
+ var logContext = require("./project-log-context");
12
13
 
13
14
  function attachVersioning(ctx) {
14
15
  var read = ctx.read;
@@ -27,7 +28,7 @@ function attachVersioning(ctx) {
27
28
  // create no canonical revision. `incorporate` appends exactly one record that
28
29
  // both resolves the comment and carries the new canonical snapshot, so the
29
30
  // two can never drift apart.
30
- function review(ref, input, author) {
31
+ function review(ref, input, author, context) {
31
32
  var located = requireLive(ref);
32
33
  var data = input || {};
33
34
  if (!logsComments.isAction(data.action)) {
@@ -56,6 +57,7 @@ function attachVersioning(ctx) {
56
57
  commentId: data.commentId,
57
58
  response: response,
58
59
  author: normalizeAuthor(author),
60
+ context: logContext.normalizeRecordContext(context),
59
61
  at: Date.now(),
60
62
  });
61
63
  return read(ref, false);
@@ -71,13 +73,14 @@ function attachVersioning(ctx) {
71
73
  action: "incorporate",
72
74
  commentId: data.commentId,
73
75
  response: response,
76
+ context: logContext.normalizeRecordContext(context),
74
77
  });
75
78
  return read(ref, false);
76
79
  }
77
80
 
78
81
  // Restore an earlier revision by appending a new one. Later history is never
79
82
  // erased: a revert is itself a revision, with the source recorded.
80
- function revert(ref, revision, reason, author) {
83
+ function revert(ref, revision, reason, author, context) {
81
84
  var located = requireLive(ref);
82
85
  var target = Number(revision);
83
86
  if (!Number.isInteger(target) || target < 1) throw new Error("A revision number is required.");
@@ -96,6 +99,7 @@ function attachVersioning(ctx) {
96
99
  appendRevision(located.rootId, "revert", logsSnapshot.cloneSnapshot(source.snapshot), author, {
97
100
  revertedFrom: target,
98
101
  reason: trimmedReason,
102
+ context: logContext.normalizeRecordContext(context),
99
103
  });
100
104
  return read(ref, false);
101
105
  }
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // Authorship is split deliberately. Canonical entries are created and revised
9
9
  // only by this project's agent sessions through `clay-logs`; a connected human
10
- // reads the ledger and appends attributed comments. That keeps the log an
11
- // honest record of the work rather than a shared wiki.
10
+ // reads the ledger, appends attributed comments, and lets the project owner
11
+ // delete an entry. That keeps the log an honest record rather than a wiki.
12
12
 
13
13
  var logsMcp = require("./project-logs-mcp-server");
14
14
 
@@ -19,7 +19,7 @@ var MAX_NOTICE_SUMMARY_CHARS = 240;
19
19
  // Canonical writes that move the ledger forward. A plain comment, a
20
20
  // clarification, and a decline are participation, not revisions, so they never
21
21
  // notify.
22
- var NOTIFYING_OPS = ["create", "update", "link", "incorporate", "revert"];
22
+ var NOTIFYING_OPS = ["create", "update", "link", "incorporate", "revert", "delete"];
23
23
 
24
24
  function clip(value, max) {
25
25
  if (typeof value !== "string") return "";
@@ -71,6 +71,7 @@ var HANDLED_TYPES = [
71
71
  "project_logs_list",
72
72
  "project_log_read",
73
73
  "project_log_comment",
74
+ "project_log_delete",
74
75
  // Retired human mutations. Still claimed so an older client gets an
75
76
  // explicit refusal rather than silence.
76
77
  "project_log_create",
@@ -105,6 +106,21 @@ function attachProjectLogs(ctx) {
105
106
  }
106
107
  }
107
108
 
109
+ function broadcastCommentReview(entry, request) {
110
+ if (isMate || !entry || !request || !request.commentId) return;
111
+ var notice = {
112
+ type: "project_log_comment_reviewed",
113
+ ref: entry.ref,
114
+ commentId: request.commentId,
115
+ action: request.action,
116
+ };
117
+ var clients = getClients();
118
+ for (var client of clients) {
119
+ if (!client || client.readyState !== 1 || client._clayPane) continue;
120
+ sendTo(client, notice);
121
+ }
122
+ }
123
+
108
124
  // Wrap a canonical write so a successful one notifies. A throw never reaches
109
125
  // here, so a failed or no-op write is silent.
110
126
  function notifying(op, run) {
@@ -149,13 +165,27 @@ function attachProjectLogs(ctx) {
149
165
  // read and neither path dumps a record body into the list.
150
166
  // Returns both the rows and this project's live category vocabulary, so the
151
167
  // filter is populated solely from what this project actually uses.
152
- function listEntries(bound, query, category) {
168
+ function listEntries(bound, query, category, contextMode) {
169
+ var options = { kind: category, limit: LIST_LIMIT, contextMode: contextMode };
153
170
  if (query) {
154
- var hits = bound.searchLogs({ query: query, kind: category, limit: LIST_LIMIT });
155
- return { entries: hits.results, categories: hits.categories || [] };
171
+ options.query = query;
172
+ var hits = bound.searchLogs(options);
173
+ return {
174
+ entries: hits.results,
175
+ categories: hits.categories || [],
176
+ contextMode: hits.contextMode,
177
+ currentChangeSetId: hits.currentChangeSetId,
178
+ isWorktree: hits.isWorktree,
179
+ };
156
180
  }
157
- var listed = bound.listLogs({ kind: category, limit: LIST_LIMIT });
158
- return { entries: listed.entries, categories: listed.categories || [] };
181
+ var listed = bound.listLogs(options);
182
+ return {
183
+ entries: listed.entries,
184
+ categories: listed.categories || [],
185
+ contextMode: listed.contextMode,
186
+ currentChangeSetId: listed.currentChangeSetId,
187
+ isWorktree: listed.isWorktree,
188
+ };
159
189
  }
160
190
 
161
191
  function handleLogsMessage(ws, msg) {
@@ -187,27 +217,40 @@ function attachProjectLogs(ctx) {
187
217
  var query = typeof msg.query === "string" ? msg.query.trim() : "";
188
218
  // A view preference, not an authorization input: the store validates it.
189
219
  var category = typeof msg.category === "string" && msg.category ? msg.category : null;
190
- var listing = listEntries(bound, query, category);
220
+ var contextMode = typeof msg.contextMode === "string" ? msg.contextMode : null;
221
+ var listing = listEntries(bound, query, category, contextMode);
191
222
  sendTo(ws, {
192
223
  type: "project_logs_state",
193
224
  requestId: requestId,
194
225
  entries: listing.entries,
195
226
  categories: listing.categories,
227
+ contextMode: listing.contextMode,
228
+ currentChangeSetId: listing.currentChangeSetId,
229
+ isWorktree: listing.isWorktree === true,
230
+ canDelete: bound.canDelete === true,
196
231
  });
197
232
  return true;
198
233
  }
199
234
 
200
235
  if (msg.type === "project_log_read") {
201
- sendTo(ws, { type: "project_log_entry", requestId: requestId, entry: bound.readLog({ ref: msg.ref }) });
236
+ sendTo(ws, { type: "project_log_entry", requestId: requestId, entry: bound.readLog({ ref: msg.ref }), canDelete: bound.canDelete === true });
237
+ return true;
238
+ }
239
+
240
+ if (msg.type === "project_log_delete") {
241
+ var removed = bound.removeLog({ ref: msg.ref });
242
+ sendTo(ws, { type: "project_log_deleted", requestId: requestId, ref: removed.ref });
243
+ broadcastUpdate(removed, "delete");
202
244
  return true;
203
245
  }
204
246
 
205
247
  if (msg.type === "project_log_comment") {
206
248
  var commented = bound.commentLog({ ref: msg.ref, body: msg.body });
207
- sendTo(ws, { type: "project_log_commented", requestId: requestId, entry: commented });
208
- try { onFeedback(commented); } catch (deliveryError) {
249
+ var reviewQueued = false;
250
+ try { reviewQueued = onFeedback(commented) === true; } catch (deliveryError) {
209
251
  console.error("[project-logs] Failed to deliver comment:", deliveryError.message || deliveryError);
210
252
  }
253
+ sendTo(ws, { type: "project_log_commented", requestId: requestId, entry: commented, reviewQueued: reviewQueued });
211
254
  return true;
212
255
  }
213
256
  } catch (e) {
@@ -232,6 +275,7 @@ function attachProjectLogs(ctx) {
232
275
  reviewLogComment: function (args) {
233
276
  var request = coerceToolArgs(args);
234
277
  var result = bound.reviewLogComment(request);
278
+ broadcastCommentReview(result, request);
235
279
  // Only an incorporation is a canonical revision; clarify and decline
236
280
  // resolve a comment and change nothing.
237
281
  if (request && request.action === "incorporate") broadcastUpdate(result, "incorporate");
@@ -0,0 +1,70 @@
1
+ // Idempotent acknowledgement support for ordinary project chat messages.
2
+
3
+ var CLIENT_MESSAGE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
4
+
5
+ function normalizeClientMessageId(value) {
6
+ if (typeof value !== "string" || !CLIENT_MESSAGE_ID_RE.test(value)) return null;
7
+ return value;
8
+ }
9
+
10
+ function ownerKey(ws) {
11
+ return ws && ws._clayUser && ws._clayUser.id ? String(ws._clayUser.id) : "_default";
12
+ }
13
+
14
+ function deliveryKey(ownerId, clientMessageId) {
15
+ return ownerId + ":" + clientMessageId;
16
+ }
17
+
18
+ function ensureRecordedMessages(session) {
19
+ if (session._recordedClientMessages) return session._recordedClientMessages;
20
+ var recorded = new Map();
21
+ var history = Array.isArray(session.history) ? session.history : [];
22
+ for (var i = 0; i < history.length; i++) {
23
+ var item = history[i];
24
+ var clientMessageId = normalizeClientMessageId(item && item.clientMessageId);
25
+ if (!clientMessageId) continue;
26
+ recorded.set(deliveryKey(item.from ? String(item.from) : "_default", clientMessageId), item);
27
+ }
28
+ session._recordedClientMessages = recorded;
29
+ return recorded;
30
+ }
31
+
32
+ function createProjectMessageDelivery(sendTo) {
33
+ function inspect(ws, session, msg) {
34
+ var clientMessageId = normalizeClientMessageId(msg && msg.clientMessageId);
35
+ if (!clientMessageId) return { clientMessageId: null, ownerId: ownerKey(ws), duplicate: false };
36
+ var ownerId = ownerKey(ws);
37
+ var recordedMessage = ensureRecordedMessages(session).get(deliveryKey(ownerId, clientMessageId)) || null;
38
+ return {
39
+ clientMessageId: clientMessageId,
40
+ ownerId: ownerId,
41
+ duplicate: !!recordedMessage,
42
+ recordedMessage: recordedMessage,
43
+ };
44
+ }
45
+
46
+ function markRecorded(session, receipt, message) {
47
+ if (!receipt || !receipt.clientMessageId) return;
48
+ ensureRecordedMessages(session).set(deliveryKey(receipt.ownerId, receipt.clientMessageId), message);
49
+ }
50
+
51
+ function acknowledge(ws, receipt, message) {
52
+ if (!receipt || !receipt.clientMessageId) return;
53
+ sendTo(ws, {
54
+ type: "message_ack",
55
+ clientMessageId: receipt.clientMessageId,
56
+ message: message || receipt.recordedMessage || null,
57
+ });
58
+ }
59
+
60
+ return {
61
+ inspect: inspect,
62
+ markRecorded: markRecorded,
63
+ acknowledge: acknowledge,
64
+ };
65
+ }
66
+
67
+ module.exports = {
68
+ createProjectMessageDelivery: createProjectMessageDelivery,
69
+ normalizeClientMessageId: normalizeClientMessageId,
70
+ };