wendkeep 0.68.0 → 0.68.5

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.
@@ -8,6 +8,18 @@ export function isBootstrapPrompt(text = '') {
8
8
  || clean.startsWith('## Memory');
9
9
  }
10
10
 
11
+ const SYNTHETIC_EVENT_TAG = /^<\/?(?:task-notification|system-reminder|local-command-stdout|local-command-stderr|local-command-caveat|command-message|command-name|command-args|user-prompt-submit-hook|subagent_notification|subagent-notification|ide_[A-Za-z0-9_-]+|environment_context)\b/i;
12
+
13
+ // Shared by transcript and token-usage parsers. These wrappers are harness metadata, not human
14
+ // prompts; keeping the filter here prevents the two import paths from drifting.
15
+ export function isSyntheticTranscriptText(text = '') {
16
+ const trimmed = String(text || '').trim();
17
+ return SYNTHETIC_EVENT_TAG.test(trimmed)
18
+ || isBootstrapPrompt(trimmed)
19
+ || /^Generate a concise( UI)? title/i.test(trimmed)
20
+ || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(trimmed);
21
+ }
22
+
11
23
  export function redactSecrets(text) {
12
24
  if (!text) return '';
13
25
  return String(text)
@@ -1,5 +1,5 @@
1
1
  import {
2
- isBootstrapPrompt,
2
+ isSyntheticTranscriptText,
3
3
  redactSecrets,
4
4
  sanitizeAssistantMessage,
5
5
  } from './prompt-content.mjs';
@@ -20,14 +20,8 @@ function extractContentText(content) {
20
20
  .trim();
21
21
  }
22
22
 
23
- const SYNTHETIC_EVENT_TAG = /^<\/?(?:task-notification|system-reminder|local-command-stdout|local-command-stderr|command-message|command-name|command-args|user-prompt-submit-hook|ide_selection|ide_opened_file|environment_context)\b/i;
24
-
25
23
  function shouldIgnoreUserText(text) {
26
- const trimmed = String(text || '').trim();
27
- return SYNTHETIC_EVENT_TAG.test(trimmed)
28
- || isBootstrapPrompt(trimmed)
29
- || /^Generate a concise( UI)? title/i.test(trimmed)
30
- || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(trimmed);
24
+ return isSyntheticTranscriptText(text);
31
25
  }
32
26
 
33
27
  function addUnique(list, value) {
@@ -180,6 +174,16 @@ export function completedCodexTurnIdsContent(content = '') {
180
174
  return completed;
181
175
  }
182
176
 
177
+ export function abortedCodexTurnIdsContent(content = '') {
178
+ const aborted = new Set();
179
+ for (const event of jsonLines(content)) {
180
+ if (event.type !== 'event_msg' || event.payload?.type !== 'turn_aborted') continue;
181
+ const turnId = String(event.payload?.turn_id || event.turn_id || '').trim();
182
+ if (turnId) aborted.add(turnId);
183
+ }
184
+ return aborted;
185
+ }
186
+
183
187
  export function parseCodexTranscriptContent(content, options = {}) {
184
188
  const result = createResult('codex');
185
189
  const eventUserPrompts = [];
@@ -208,6 +212,11 @@ export function parseCodexTranscriptContent(content, options = {}) {
208
212
  ensureTurn(result.latestTurnId, event.timestamp);
209
213
  continue;
210
214
  }
215
+ if (event.type === 'event_msg' && event.payload?.type === 'turn_aborted') {
216
+ const turn = ensureTurn(event.payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
217
+ turn.status = 'aborted';
218
+ continue;
219
+ }
211
220
  if (event.type === 'turn_context') {
212
221
  result.latestTurnId = event.payload?.turn_id || result.latestTurnId;
213
222
  result.model = event.payload?.model || result.model;
@@ -280,6 +289,26 @@ export function parseCodexTranscriptContent(content, options = {}) {
280
289
  }
281
290
  }
282
291
  }
292
+ if (payload.type === 'custom_tool_call') {
293
+ const name = payload.name || payload.tool_name || payload.tool || 'custom_tool_call';
294
+ const turn = ensureTurn(payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
295
+ addUnique(result.tools, name);
296
+ addUnique(turn.tools, name);
297
+ const parsed = parseToolArguments(payload.arguments ?? payload.input ?? payload.parameters);
298
+ const combined = typeof parsed.raw === 'string' ? parsed.raw : toolArgumentText(parsed);
299
+ for (const path of extractPaths(combined, paths)) {
300
+ addUnique(result.consultedFiles, path);
301
+ addUnique(turn.consultedFiles, path);
302
+ }
303
+ for (const path of extractPatchFiles(combined)) {
304
+ addUnique(result.changedFiles, path);
305
+ addUnique(turn.changedFiles, path);
306
+ }
307
+ }
308
+ if (payload.type === 'custom_tool_call_output') {
309
+ // A custom tool output closes an existing call; it is deliberately not a second tool.
310
+ ensureTurn(payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
311
+ }
283
312
  if (payload.type === 'tool_search_call') {
284
313
  const turn = ensureTurn(payload.turn_id || event.turn_id || result.latestTurnId, event.timestamp);
285
314
  addUnique(result.tools, 'tool_search');
@@ -446,6 +446,27 @@ function currentEventFromRecord(record) {
446
446
  return { ...record.source, value: record.value, revision: record.revision };
447
447
  }
448
448
 
449
+ function supersededTransitively(superseded, sourceEventId, finalEventId) {
450
+ if (!sourceEventId || !finalEventId || sourceEventId === finalEventId) return false;
451
+ const edges = new Map();
452
+ for (const item of superseded) {
453
+ if (!edges.has(item.event_id)) edges.set(item.event_id, []);
454
+ edges.get(item.event_id).push(item.by_event_id);
455
+ }
456
+ const pending = [sourceEventId];
457
+ const visited = new Set();
458
+ while (pending.length) {
459
+ const current = pending.shift();
460
+ if (visited.has(current)) continue;
461
+ visited.add(current);
462
+ for (const next of edges.get(current) || []) {
463
+ if (next === finalEventId) return true;
464
+ if (!visited.has(next)) pending.push(next);
465
+ }
466
+ }
467
+ return false;
468
+ }
469
+
449
470
  function isCausallyOlder(event, current) {
450
471
  if (!current) return false;
451
472
  if (sameCausalActivation(event, current)) {
@@ -636,6 +657,24 @@ export function reduceMemoryEvents(inputEvents = [], {
636
657
  }
637
658
  records.set(item.memory_key, { value: item.value, revision: current.revision + 1, source: item });
638
659
  tombstones.delete(item.memory_key);
660
+ const explicitlySupersededIds = new Set(
661
+ Array.isArray(item.supersedes)
662
+ ? item.supersedes
663
+ : (item.supersedes_event_id ? [item.supersedes_event_id] : []),
664
+ );
665
+ if (item.candidate_decision?.action === 'promote' && explicitlySupersededIds.size) {
666
+ for (const candidate of candidates) {
667
+ if (candidate.reason !== 'conflict' || !candidate.event_ids?.length) continue;
668
+ if (candidate.event_ids.every((eventId) => explicitlySupersededIds.has(eventId))) {
669
+ resolvedCandidateIds.add(candidate.candidate_id);
670
+ }
671
+ }
672
+ for (const pending of pendingAssertConflicts) {
673
+ if (explicitlySupersededIds.has(pending.event.event_id)) {
674
+ resolvedCandidateIds.add(pending.candidate.candidate_id);
675
+ }
676
+ }
677
+ }
639
678
  revision += 1;
640
679
  appliedEventIds.push(item.event_id);
641
680
  }
@@ -680,13 +719,36 @@ export function reduceMemoryEvents(inputEvents = [], {
680
719
  }
681
720
  }
682
721
 
722
+ const pendingByCandidateId = new Map(
723
+ pendingAssertConflicts.map((pending) => [pending.candidate.candidate_id, pending]),
724
+ );
725
+ const reanchoredCandidates = candidates
726
+ .map((candidate) => {
727
+ const pending = pendingByCandidateId.get(candidate.candidate_id);
728
+ if (!pending) return candidate;
729
+ const finalSource = currentEventFromRecord(records.get(candidate.memory_key));
730
+ const previousSource = candidate.events?.find(
731
+ (event) => event.event_id !== pending.event.event_id,
732
+ );
733
+ if (!finalSource || !previousSource || finalSource.event_id === previousSource.event_id) {
734
+ return candidate;
735
+ }
736
+ if (!sameCompleteCausalLineage(previousSource, finalSource)
737
+ || !supersededTransitively(superseded, previousSource.event_id, finalSource.event_id)) {
738
+ return candidate;
739
+ }
740
+ if (hashMemoryValue(finalSource.value) === hashMemoryValue(pending.event.value)) return null;
741
+ return conflictCandidate(candidate.memory_key, [finalSource, pending.event], finalSource);
742
+ })
743
+ .filter(Boolean);
744
+
683
745
  const stateEntries = [...records].map(([key, record]) => [key, record.value]);
684
746
  const recordEntries = [...records].map(([key, record]) => [key, record]);
685
747
  const tombstoneEntries = [...tombstones];
686
748
  const state = sortedObject(stateEntries);
687
749
  const recordObject = sortedObject(recordEntries);
688
750
  const tombstoneObject = sortedObject(tombstoneEntries);
689
- const unresolvedCandidates = candidates
751
+ const unresolvedCandidates = reanchoredCandidates
690
752
  .filter((item) => !candidateDecisions.has(item.candidate_id)
691
753
  && !resolvedCandidateIds.has(item.candidate_id));
692
754
  unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
@@ -335,19 +335,36 @@ function waitBriefly(ms) {
335
335
  Atomics.wait(signal, 0, 0, ms);
336
336
  }
337
337
 
338
- // A public lock may legitimately disappear while another owner releases it. Only ENOENT
339
- // observed by an operation explicitly scoped to that canonical lock receives bounded
340
- // backoff; private .pending paths and every unsafe topology fail closed.
341
- function retryablePublicLockError(lock, error, code, { allowRaw = true } = {}) {
338
+ // A public lock may legitimately disappear while another owner releases it. Structural shape
339
+ // only: which errno a concurrent removal produces is a platform detail — Windows reports
340
+ // UNKNOWN, EBADF or EPERM where Linux reports ENOENT so the code is merely the trigger to go
341
+ // re-observe, never the answer. Private .pending paths are siblings, not descendants, of the
342
+ // canonical lock and are excluded by the containment check.
343
+ function publicLockRetryCandidate(lock, error, code, { allowRaw = true } = {}) {
342
344
  const failure = error?.[VAULT_PATH_FAILURE];
345
+ // A raw error carries no component to re-observe, so it stays on the narrow ENOENT path.
343
346
  if (!failure) return allowRaw && error?.code === 'ENOENT' && !error?.cause;
344
- const causeCode = error?.cause?.code || failure.causeCode;
345
- return causeCode === 'ENOENT'
346
- && (error?.code === code || error?.code === 'VAULT_PATH_UNSAFE')
347
+ return (error?.code === code || error?.code === 'VAULT_PATH_UNSAFE')
347
348
  && ['component-realpath', 'component-missing'].includes(failure.kind)
348
349
  && containedBy(resolve(lock), resolve(failure.component));
349
350
  }
350
351
 
352
+ // The decision itself. Only a fresh walk that settles as a missing suffix or the canonical
353
+ // entry authorizes a retry; junction, symlink, reparse, a redirected component or a state that
354
+ // is still unresolvable keeps failing closed, exactly as a first-time validation would.
355
+ function publicLockComponentSettled(vaultBase, error, code) {
356
+ const component = error?.[VAULT_PATH_FAILURE]?.component;
357
+ if (typeof component !== 'string' || !component) return false;
358
+ try {
359
+ assertVaultPathSafe(vaultBase, component, {
360
+ label: 'revalidação do lock de escrita do Vault', code,
361
+ });
362
+ return true;
363
+ } catch {
364
+ return false;
365
+ }
366
+ }
367
+
351
368
  function vaultLockRenameCollision(error, pending, lock) {
352
369
  const acceptedCodes = process.platform === 'win32'
353
370
  ? ['EEXIST', 'ENOTEMPTY', 'EPERM']
@@ -374,17 +391,22 @@ function vaultLockRetryDeadlineError() {
374
391
  return error;
375
392
  }
376
393
 
377
- function withPublicLockRetry(lock, code, retryState, operation, initialError = null) {
394
+ function withPublicLockRetry(vaultBase, lock, code, retryState, operation, initialError = null) {
378
395
  let error = initialError;
379
396
  while (true) {
380
397
  if (error) {
381
- if (!retryablePublicLockError(lock, error, code)
398
+ if (!publicLockRetryCandidate(lock, error, code)
382
399
  || retryState.remaining <= 0) throw error;
383
400
  const remainingMs = retryState.deadline - Date.now();
384
401
  if (remainingMs <= 0) throw vaultLockRetryDeadlineError();
385
402
  retryState.remaining -= 1;
386
403
  waitBriefly(Math.min(VAULT_LOCK_TOPOLOGY_RETRY_MS, remainingMs));
387
404
  if (Date.now() >= retryState.deadline) throw vaultLockRetryDeadlineError();
405
+ // Reclassify only after the backoff, and only while the deadline still allows a retry:
406
+ // "settled" means observed once the concurrent owner had a chance to finish releasing.
407
+ // A raw error carries no component and already passed the narrow ENOENT filter above.
408
+ if (error[VAULT_PATH_FAILURE]
409
+ && !publicLockComponentSettled(vaultBase, error, code)) throw error;
388
410
  }
389
411
  try {
390
412
  return operation();
@@ -395,9 +417,11 @@ function withPublicLockRetry(lock, code, retryState, operation, initialError = n
395
417
  }
396
418
 
397
419
  function inspectVaultLock(vaultBase, lock, code, retryState, initialError = null) {
398
- return withPublicLockRetry(lock, code, retryState, () => assertVaultPathSafe(vaultBase, lock, {
399
- expectedType: 'directory', label: 'lock de escrita do Vault', code,
400
- }), initialError);
420
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => assertVaultPathSafe(
421
+ vaultBase, lock, {
422
+ expectedType: 'directory', label: 'lock de escrita do Vault', code,
423
+ },
424
+ ), initialError);
401
425
  }
402
426
 
403
427
  function processIsAlive(pid) {
@@ -447,7 +471,7 @@ function vaultLockOwner(vaultBase, lock, code, retryState = null) {
447
471
  }
448
472
  };
449
473
  return retryState
450
- ? withPublicLockRetry(lock, code, retryState, inspect)
474
+ ? withPublicLockRetry(vaultBase, lock, code, retryState, inspect)
451
475
  : inspect();
452
476
  }
453
477
 
@@ -492,7 +516,7 @@ function releaseOwnedVaultLock(vaultBase, lock, {
492
516
  // The token-specific lease is the filesystem CAS. An old finally/reaper can only
493
517
  // remove the directory after successfully unlinking the lease it originally saw;
494
518
  // a replacement lock never contains that unguessable path.
495
- const leaseRemoved = withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
519
+ const leaseRemoved = withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
496
520
  vaultBase, vaultLockLease(lock, token), {
497
521
  label: 'lease do lock de escrita do Vault', code,
498
522
  },
@@ -503,12 +527,12 @@ function releaseOwnedVaultLock(vaultBase, lock, {
503
527
  const current = currentState.owner;
504
528
  if (current?.pid !== pid || current?.token !== token) return false;
505
529
  const ownerPath = join(lock, VAULT_LOCK_OWNER_FILE);
506
- if (!withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
530
+ if (!withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
507
531
  vaultBase, ownerPath, {
508
532
  label: 'owner do lock de escrita do Vault', code,
509
533
  },
510
534
  ))) return false;
511
- return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
535
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
512
536
  vaultBase, lock, {
513
537
  missingOk: false, label: 'lock de escrita do Vault', code,
514
538
  },
@@ -516,7 +540,7 @@ function releaseOwnedVaultLock(vaultBase, lock, {
516
540
  }
517
541
 
518
542
  function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
519
- const initial = withPublicLockRetry(lock, code, retryState, () => {
543
+ const initial = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
520
544
  const checked = assertVaultPathSafe(vaultBase, lock, {
521
545
  expectedType: 'directory', label: 'lock de escrita do Vault', code,
522
546
  });
@@ -530,7 +554,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
530
554
  if (!observed.lockExists) return true;
531
555
  if (observed.owner) {
532
556
  if (processIsAlive(observed.owner.pid)) return false;
533
- const lease = withPublicLockRetry(lock, code, retryState, () => {
557
+ const lease = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
534
558
  const current = assertVaultPathSafe(vaultBase, lock, {
535
559
  expectedType: 'directory', label: 'lock de escrita do Vault', code,
536
560
  });
@@ -548,7 +572,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
548
572
  // Compatibility with owner-aware locks from 0.58.x, which predate token leases.
549
573
  // A dead PID plus byte-identical owner and directory identity is sufficient here;
550
574
  // a live legacy owner was returned above and is never reaped by age.
551
- const legacy = withPublicLockRetry(lock, code, retryState, () => {
575
+ const legacy = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
552
576
  const current = assertVaultPathSafe(vaultBase, lock, {
553
577
  expectedType: 'directory', label: 'lock legado de escrita do Vault', code,
554
578
  });
@@ -565,12 +589,12 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
565
589
  if (currentStat.birthtimeMs !== before.birthtimeMs
566
590
  || currentStat.mtimeMs !== before.mtimeMs
567
591
  || currentOwner.raw !== observed.raw) return false;
568
- if (!withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
592
+ if (!withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
569
593
  vaultBase, currentOwner.path, {
570
594
  label: 'owner legado morto do lock de escrita do Vault', code,
571
595
  },
572
596
  ))) return false;
573
- return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
597
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
574
598
  vaultBase, checked.target, {
575
599
  missingOk: false, label: 'lock legado de escrita do Vault', code,
576
600
  },
@@ -580,7 +604,7 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
580
604
  // Locks are published by atomic directory rename only after owner + lease exist.
581
605
  // Thus an old empty/partial directory is legacy or crash residue, never an in-flight
582
606
  // live acquisition. Unknown children remain fail-closed.
583
- const partial = withPublicLockRetry(lock, code, retryState, () => {
607
+ const partial = withPublicLockRetry(vaultBase, lock, code, retryState, () => {
584
608
  const current = assertVaultPathSafe(vaultBase, lock, {
585
609
  expectedType: 'directory', label: 'lock de escrita do Vault', code,
586
610
  });
@@ -604,12 +628,12 @@ function reapDeadVaultLock(vaultBase, lock, staleMs, code, retryState) {
604
628
  || currentStat.mtimeMs !== before.mtimeMs
605
629
  || currentOwner.raw !== observed.raw) return false;
606
630
  if (entries.includes(VAULT_LOCK_OWNER_FILE)
607
- && !withPublicLockRetry(lock, code, retryState, () => unlinkVaultFile(
631
+ && !withPublicLockRetry(vaultBase, lock, code, retryState, () => unlinkVaultFile(
608
632
  vaultBase, currentOwner.path, {
609
633
  label: 'owner parcial do lock de escrita do Vault', code,
610
634
  },
611
635
  ))) return false;
612
- return withPublicLockRetry(lock, code, retryState, () => removeVaultLockDirectory(
636
+ return withPublicLockRetry(vaultBase, lock, code, retryState, () => removeVaultLockDirectory(
613
637
  vaultBase, checked.target, {
614
638
  missingOk: false, label: 'lock de escrita do Vault', code,
615
639
  },
@@ -672,7 +696,8 @@ export function withVaultPathLock(vaultBase, path, fn, {
672
696
  });
673
697
  break;
674
698
  } catch (error) {
675
- const retryableRenameRace = retryablePublicLockError(lock, error, code, {
699
+ // Shape check only; inspectVaultLock below re-observes through the full retry path.
700
+ const retryableRenameRace = publicLockRetryCandidate(lock, error, code, {
676
701
  allowRaw: false,
677
702
  });
678
703
  const nativeRenameCollision = vaultLockRenameCollision(error, pending, lock);
package/src/init.mjs CHANGED
@@ -220,6 +220,7 @@ export function mergeCodexHooks(existing, { force = false } = {}) {
220
220
  const owning = groups.find((g) => (g.hooks || []).some(owns));
221
221
  if (owning) {
222
222
  const hk = owning.hooks.find(owns);
223
+ const matcher = CODEX_MATCHER_EVENTS.has(h.event) ? h.matcher : null;
223
224
  // `timeout` is the pre-0.46 key: Codex never read it. Migrate it even without --force,
224
225
  // otherwise the hook keeps running at the 600s default forever.
225
226
  const legacyTimeout = 'timeout' in hk;
@@ -233,6 +234,8 @@ export function mergeCodexHooks(existing, { force = false } = {}) {
233
234
  if (entry.statusMessage) hk.statusMessage = entry.statusMessage;
234
235
  }
235
236
  }
237
+ if (force && matcher) owning.matcher = matcher;
238
+ if (force && !matcher) delete owning.matcher;
236
239
  file.hooks[h.event] = groups;
237
240
  continue;
238
241
  }
package/src/memory.mjs CHANGED
@@ -387,9 +387,16 @@ function promotedSupersedes(vault, candidate, selected) {
387
387
  const memberIds = new Set(Array.isArray(candidate.event_ids) ? candidate.event_ids : []);
388
388
  const ledger = readMemoryLedger(vault);
389
389
  if (ledger.status !== 'ok') throw new Error('Ledger de memória inválido durante a promoção.');
390
- const current = deriveMemoryProjection(vault, ledger.events)
391
- .records?.[candidate.memory_key]?.source;
392
- if (!current?.event_id || memberIds.has(current.event_id)) return [...memberIds].sort();
390
+ const projection = deriveMemoryProjection(vault, ledger.events);
391
+ const current = projection.records?.[candidate.memory_key]?.source;
392
+ if (!current?.event_id) return [...memberIds].sort();
393
+ if (memberIds.has(current.event_id)) {
394
+ projection.superseded
395
+ .filter((item) => item.by_event_id === current.event_id)
396
+ .map((item) => item.event_id)
397
+ .forEach((eventId) => memberIds.add(eventId));
398
+ return [...memberIds].sort();
399
+ }
393
400
 
394
401
  const currentSelectedId = current.candidate_decision?.selected_event_id;
395
402
  const currentSelected = currentSelectedId