iranti 0.3.30 → 0.3.32

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.
@@ -7,6 +7,7 @@ exports.extractRuleTriggers = extractRuleTriggers;
7
7
  exports.matchesRuleTriggers = matchesRuleTriggers;
8
8
  exports.formatMatchedUserRules = formatMatchedUserRules;
9
9
  exports.extractFilePathEntityHints = extractFilePathEntityHints;
10
+ exports.detectFileAction = detectFileAction;
10
11
  exports.resolveAutowriteTargetEntity = resolveAutowriteTargetEntity;
11
12
  exports.parseAutowriteEntity = parseAutowriteEntity;
12
13
  exports.buildToolResultContextLabel = buildToolResultContextLabel;
@@ -40,6 +41,7 @@ const sessionLedger_1 = require("../lib/sessionLedger");
40
41
  const sharedStateInvalidation_1 = require("../lib/sharedStateInvalidation");
41
42
  const hostMemoryFormatting_1 = require("../lib/hostMemoryFormatting");
42
43
  const council_1 = require("../staff/council");
44
+ const subTurnLoop_1 = require("../staff/subTurnLoop");
43
45
  // ─── Constants ───────────────────────────────────────────────────────────────
44
46
  const ATTENDANT_RULES_QUERY = {
45
47
  entityType: 'system',
@@ -96,6 +98,10 @@ const TOOL_COST_WRITE_NUDGE_THRESHOLD = 5;
96
98
  // concrete draft facts instead of a vague "write something" reminder.
97
99
  const RECENT_AUTOWRITE_BUFFER_LIMIT = 12;
98
100
  const AUTOWRITE_SOURCE_TAG = 'attendant_autowrite';
101
+ // M7: response file-change autowrite — constants governing the scan pass
102
+ // that runs at post-response to extract file paths from the agent's reply.
103
+ const RESPONSE_FILE_CAPTURE_MIN_CHARS = 50;
104
+ const RESPONSE_FILE_CAPTURE_MAX_FILES = 20;
99
105
  const MIN_ENTITY_CONFIDENCE = 0.75;
100
106
  const MEMORY_DECISION_CONTEXT_WINDOW_CHARS = 2000;
101
107
  const LEDGER_WORKING_MEMORY_PREFIX = 'system/session_ledger/recent_learning_';
@@ -527,6 +533,18 @@ function scanStringForPathEntities(text, projectId, seen, hints) {
527
533
  deriveFileEntityFromPath(match[1], projectId, seen, hints);
528
534
  }
529
535
  }
536
+ // M7: infer whether a file path mention in the agent's response represents
537
+ // an edit, a creation, or a read. Scans the ±150-char context window for
538
+ // action-bearing keywords. Defaults to 'read' when the signal is ambiguous.
539
+ // Exported for direct unit testing.
540
+ function detectFileAction(contextWindow) {
541
+ const ctx = contextWindow.toLowerCase();
542
+ if (/\bcreat\w*\b|\bnew file\b|\badded new\b|\bwrote new\b/.test(ctx))
543
+ return 'created';
544
+ if (/\bedit\w*\b|\bupdat\w*\b|\bmodif\w*\b|\bchanged\b|\bwrote\b|\brewrote\b|\brefactor\w*\b/.test(ctx))
545
+ return 'edited';
546
+ return 'read';
547
+ }
530
548
  function extractBasenameFromGlobPattern(pattern) {
531
549
  // A glob like "src/**/*.ts" has no meaningful basename; one like
532
550
  // "tests/attendant/run_mid_turn_attend_tests.ts" does. We only derive a
@@ -3280,6 +3298,149 @@ If no durable facts can be extracted, return an empty array: [].`,
3280
3298
  }
3281
3299
  return baseOutcome;
3282
3300
  }
3301
+ // M7: scan the agent's post-response text for file path mentions, infer
3302
+ // the most likely action (edited / created / read) from surrounding context,
3303
+ // and write one durable fact per file-scoped entity. This populates the
3304
+ // project/{id}/file/{basename} entity namespace so demand-driven recall
3305
+ // (extractFilePathEntityHints + observe) can surface the facts in future
3306
+ // sessions without any host-side collection effort.
3307
+ //
3308
+ // Isolation policy: extraction failures (regex errors, downstream write
3309
+ // errors) MUST NOT abort the attend call. This method swallows its own
3310
+ // errors and reports them via the skipped array.
3311
+ async runResponseFileCapture(responseText, batchId) {
3312
+ const t0 = Date.now();
3313
+ const skipped = [];
3314
+ const baseResult = {
3315
+ autowriteBatchId: batchId,
3316
+ filesDetected: 0,
3317
+ factsWritten: 0,
3318
+ entities: [],
3319
+ skipped,
3320
+ durationMs: 0,
3321
+ };
3322
+ if (!responseText || responseText.trim().length < RESPONSE_FILE_CAPTURE_MIN_CHARS) {
3323
+ skipped.push({ reason: 'response_too_short' });
3324
+ baseResult.durationMs = Date.now() - t0;
3325
+ return baseResult;
3326
+ }
3327
+ const projectEntity = (0, autoRemember_1.getProjectMemoryEntity)() ?? null;
3328
+ if (!projectEntity) {
3329
+ skipped.push({ reason: 'no_project_entity' });
3330
+ baseResult.durationMs = Date.now() - t0;
3331
+ return baseResult;
3332
+ }
3333
+ const projectId = (0, entity_resolution_1.parseEntityString)(projectEntity).entityId;
3334
+ // Scan the response text for file path occurrences. One fact per
3335
+ // distinct basename — first occurrence wins if the same file appears
3336
+ // multiple times in the same response.
3337
+ const seenEntities = new Set();
3338
+ const fileOccurrences = [];
3339
+ FILE_PATH_PATTERN.lastIndex = 0;
3340
+ let match;
3341
+ while ((match = FILE_PATH_PATTERN.exec(responseText)) !== null) {
3342
+ const filePath = match[1].trim();
3343
+ const basename = filePath.replace(/\\/g, '/').split('/').pop() ?? '';
3344
+ const stripped = basename.split(/[,:()]/)[0] ?? basename;
3345
+ const nameWithoutExt = stripped
3346
+ .replace(/\.\w+$/, '')
3347
+ .replace(/[^a-zA-Z0-9]/g, '_')
3348
+ .toLowerCase();
3349
+ if (!nameWithoutExt || nameWithoutExt.length < 2)
3350
+ continue;
3351
+ const entity = `project/${projectId}/file/${nameWithoutExt}`;
3352
+ if (seenEntities.has(entity))
3353
+ continue;
3354
+ seenEntities.add(entity);
3355
+ // Grab a context window around the match for action detection.
3356
+ const matchStart = match.index;
3357
+ const contextStart = Math.max(0, matchStart - 150);
3358
+ const contextEnd = Math.min(responseText.length, matchStart + filePath.length + 150);
3359
+ const contextWindow = responseText.slice(contextStart, contextEnd);
3360
+ fileOccurrences.push({ path: filePath, entity, contextWindow });
3361
+ if (fileOccurrences.length >= RESPONSE_FILE_CAPTURE_MAX_FILES)
3362
+ break;
3363
+ }
3364
+ baseResult.filesDetected = fileOccurrences.length;
3365
+ if (fileOccurrences.length === 0) {
3366
+ baseResult.durationMs = Date.now() - t0;
3367
+ return baseResult;
3368
+ }
3369
+ const writtenEntities = [];
3370
+ for (const { path, entity, contextWindow } of fileOccurrences) {
3371
+ const action = detectFileAction(contextWindow);
3372
+ const key = (action === 'edited' || action === 'created') ? 'last_edit' : 'last_access';
3373
+ const confidence = (action === 'edited' || action === 'created') ? 85 : 70;
3374
+ const parsedEntity = parseAutowriteEntity(entity);
3375
+ const displayName = path.replace(/\\/g, '/').split('/').pop() ?? path;
3376
+ const value = {
3377
+ action,
3378
+ path,
3379
+ capturedAt: new Date().toISOString(),
3380
+ };
3381
+ const summary = `${displayName} was ${action} — captured from agent response at post-response.`;
3382
+ try {
3383
+ await (0, librarian_1.librarianWrite)({
3384
+ entityType: parsedEntity.entityType,
3385
+ entityId: parsedEntity.entityId,
3386
+ key,
3387
+ valueRaw: value,
3388
+ valueSummary: summary.slice(0, 500),
3389
+ confidence,
3390
+ source: AUTOWRITE_SOURCE_TAG,
3391
+ createdBy: `attendant:${this.agentId}`,
3392
+ properties: {
3393
+ memoryScope: 'project',
3394
+ capturePhase: 'response_file_change_autowrite',
3395
+ autowriteBatchId: batchId,
3396
+ fileAction: action,
3397
+ autowriteOccurredAt: new Date().toISOString(),
3398
+ ...(0, semanticFactTags_1.buildSemanticFactTags)({
3399
+ memoryScope: 'project',
3400
+ durableClass: 'file_change',
3401
+ mergeStrategy: 'replace',
3402
+ extraTags: ['autowrite', 'response_capture', `action:${action}`],
3403
+ }),
3404
+ },
3405
+ });
3406
+ writtenEntities.push(entity);
3407
+ }
3408
+ catch (err) {
3409
+ skipped.push({
3410
+ reason: 'librarian_write_failed',
3411
+ detail: err instanceof Error ? err.message : String(err),
3412
+ });
3413
+ }
3414
+ }
3415
+ baseResult.factsWritten = writtenEntities.length;
3416
+ baseResult.entities = writtenEntities;
3417
+ baseResult.durationMs = Date.now() - t0;
3418
+ if (writtenEntities.length > 0) {
3419
+ // Autowriting file-change facts counts as durable persistence —
3420
+ // reset pressure counters and notify compliance tracking.
3421
+ this.attendsWithoutPersist = 0;
3422
+ this.turnsWithoutWrite = 0;
3423
+ this.writeOccurredThisTurn = true;
3424
+ this.recordMemoryEvidence('write');
3425
+ if (this.eventSource) {
3426
+ (0, staffEventRegistry_1.getStaffEventEmitter)().emit({
3427
+ staffComponent: 'Attendant',
3428
+ actionType: 'attendant_autowrite',
3429
+ agentId: this.agentId,
3430
+ source: this.eventSource,
3431
+ reason: 'response_file_change_autowrite',
3432
+ level: 'audit',
3433
+ metadata: this.buildEventMetadata({
3434
+ autowriteBatchId: batchId,
3435
+ filesDetected: baseResult.filesDetected,
3436
+ factsWritten: baseResult.factsWritten,
3437
+ entities: writtenEntities,
3438
+ }),
3439
+ });
3440
+ }
3441
+ }
3442
+ return baseResult;
3443
+ }
3283
3444
  // M3: build a forced write-nudge payload when tool-cost pressure crosses
3284
3445
  // TOOL_COST_WRITE_NUDGE_THRESHOLD and the nudge has not already been
3285
3446
  // emitted this turn. Returns null when no nudge should fire. The nudge
@@ -3741,6 +3902,10 @@ If no durable facts can be extracted, return an empty array: [].`,
3741
3902
  }
3742
3903
  if (phase === 'post-response') {
3743
3904
  const memoryAttributions = this.scorePendingMemoryAttributions(latestMessage || currentContext);
3905
+ // M7: scan the agent's response for file path mentions and write file-scoped facts.
3906
+ // Must run BEFORE buildComplianceState so that any writes here reset the
3907
+ // pressure counters and are reflected in the compliance snapshot.
3908
+ const responseFileCapture = await this.runResponseFileCapture(latestMessage, `rfca_${Date.now()}`);
3744
3909
  compliance = this.buildComplianceState(this.complianceUpdatedAt);
3745
3910
  if (memoryAttributions.some((entry) => !entry.used)) {
3746
3911
  complianceWarning = ignoredMemoryWarning;
@@ -3827,6 +3992,23 @@ If no durable facts can be extracted, return an empty array: [].`,
3827
3992
  outcome: 'no_context',
3828
3993
  budgetMax: 0,
3829
3994
  },
3995
+ // B8 M6: sub-turn reasoning loop is gated off on post-response.
3996
+ // Field echoed as a declined shell for destructure parity.
3997
+ subTurnLoopPlan: {
3998
+ outcome: 'declined_post_response',
3999
+ attempted: false,
4000
+ partialResponseLength: 0,
4001
+ initialFactCount: 0,
4002
+ addedFactCount: 0,
4003
+ rescoreTokens: [],
4004
+ proposedHints: [],
4005
+ budgetMax: 0,
4006
+ reason: 'post_response_closeout',
4007
+ note: 'Sub-turn loop is gated off on post-response attends.',
4008
+ },
4009
+ // M7: response file-change autowrite — populated at post-response.
4010
+ // Contains the outcome of the scan pass over the agent's reply.
4011
+ responseFileCapture,
3830
4012
  };
3831
4013
  }
3832
4014
  let decision = await this.decideMemoryNeed({
@@ -4005,6 +4187,17 @@ If no durable facts can be extracted, return an empty array: [].`,
4005
4187
  lowConfidenceCount: 0,
4006
4188
  },
4007
4189
  }),
4190
+ // B8 M6: sub-turn loop plan populated even on the not-needed
4191
+ // path — the helper will decline cleanly because no retrieval
4192
+ // ran (initialFactCount=0) and, on non-mid-turn phases, the
4193
+ // phase gate fires first. Pure helper so no latency concern.
4194
+ subTurnLoopPlan: (0, subTurnLoop_1.planSubTurnLoop)({
4195
+ phase,
4196
+ partialResponse: input.partialResponse,
4197
+ latestMessage,
4198
+ originalEntityHints: effectiveEntityHints,
4199
+ initialFactCount: 0,
4200
+ }),
4008
4201
  };
4009
4202
  }
4010
4203
  // Post-compaction recovery: re-surface facts that were recently injected (likely in context
@@ -4137,6 +4330,85 @@ If no durable facts can be extracted, return an empty array: [].`,
4137
4330
  note: 'No refinement pass required.',
4138
4331
  };
4139
4332
  }
4333
+ // B8 M6: sub-turn reasoning loop. When the caller passed a partial
4334
+ // assistant response on a mid-turn attend, re-score that partial
4335
+ // against the baseline retrieval and decide whether to fire ONE
4336
+ // bounded extra observe() call with broadened hints harvested from
4337
+ // the partial text. This is A3's refinement pattern re-applied on
4338
+ // response progress rather than initial-pass emptiness. Pure plan
4339
+ // + at most one retry; same union-of-original+proposed hint fix as
4340
+ // B6's refinement retry above. Gates ensure it only fires on
4341
+ // mid-turn with a non-trivial partial response carrying novel
4342
+ // tokens or type/id patterns beyond the baseline.
4343
+ const subTurnInitialCount = augmentedObservedFacts.length;
4344
+ let subTurnLoopPlan = (0, subTurnLoop_1.planSubTurnLoop)({
4345
+ phase,
4346
+ partialResponse: input.partialResponse,
4347
+ latestMessage,
4348
+ originalEntityHints: allObserveEntityHints,
4349
+ initialFactCount: subTurnInitialCount,
4350
+ });
4351
+ if (subTurnLoopPlan.attempted && subTurnLoopPlan.proposedHints.length > 0) {
4352
+ // Retry uses the UNION of allObserveEntityHints and the
4353
+ // sub-turn proposed hints — same preservation fix as B6 so a
4354
+ // specific host-supplied hint is not dropped when the sub-turn
4355
+ // widens with patterns harvested from the partial response.
4356
+ const subTurnRetryHints = Array.from(new Set([
4357
+ ...allObserveEntityHints,
4358
+ ...subTurnLoopPlan.proposedHints,
4359
+ ]));
4360
+ try {
4361
+ const subTurnObserved = await this.observe({
4362
+ currentContext: observationContext,
4363
+ maxFacts: effectiveMaxFacts,
4364
+ entityHints: subTurnRetryHints,
4365
+ priorityKeys: expandContinuityPriorityKeys(Array.from(new Set([
4366
+ ...(mandatoryRecall.key ? [mandatoryRecall.key] : []),
4367
+ ...(this.advisoryLearningProfile?.priorityKeys ?? []),
4368
+ ...freshState.priorityKeys,
4369
+ ]))),
4370
+ skipContextFilter: forceInject,
4371
+ recoveryKeys: postCompactionRecoveryKeys.length > 0 ? postCompactionRecoveryKeys : undefined,
4372
+ ledgerContext: input.ledgerContext,
4373
+ });
4374
+ // Merge new facts that weren't already in augmentedObservedFacts.
4375
+ // Uses entityKey as the dedup key, same as the mid-turn
4376
+ // filter below. A net-new fact count of 0 means the retry
4377
+ // resolved the same facts we already had — treat as empty.
4378
+ const existingKeys = new Set(augmentedObservedFacts.map((f) => f.entityKey));
4379
+ const netNewFacts = subTurnObserved.facts.filter((f) => !existingKeys.has(f.entityKey));
4380
+ if (netNewFacts.length > 0) {
4381
+ augmentedObservedFacts = [...augmentedObservedFacts, ...netNewFacts];
4382
+ augmentedObservedTotalFound = augmentedObservedTotalFound + netNewFacts.length;
4383
+ subTurnLoopPlan = {
4384
+ ...subTurnLoopPlan,
4385
+ outcome: 'attempted_added',
4386
+ addedFactCount: netNewFacts.length,
4387
+ note: `Sub-turn loop added ${netNewFacts.length} fact(s) via proposed hints (${subTurnLoopPlan.proposedHints.join(', ')}).`,
4388
+ };
4389
+ }
4390
+ else {
4391
+ subTurnLoopPlan = {
4392
+ ...subTurnLoopPlan,
4393
+ outcome: 'attempted_empty',
4394
+ addedFactCount: 0,
4395
+ note: `Sub-turn loop with proposed hints (${subTurnLoopPlan.proposedHints.join(', ')}) returned no net-new facts.`,
4396
+ };
4397
+ }
4398
+ }
4399
+ catch (err) {
4400
+ // Sub-turn loop must never crash attend. Record a declined
4401
+ // outcome with the error and continue.
4402
+ subTurnLoopPlan = {
4403
+ ...subTurnLoopPlan,
4404
+ outcome: 'attempted_empty',
4405
+ attempted: false,
4406
+ addedFactCount: 0,
4407
+ reason: `sub_turn_retry_threw: ${err instanceof Error ? err.message : String(err)}`,
4408
+ note: 'Sub-turn loop aborted due to retry error; baseline retrieval stands.',
4409
+ };
4410
+ }
4411
+ }
4140
4412
  // A5: tool-using Attendant (planned). Derive the proposed follow-up
4141
4413
  // tool calls from current brief state + retrieval outcome + drift.
4142
4414
  // Pure and deterministic — the Attendant does not execute them.
@@ -4368,6 +4640,13 @@ If no durable facts can be extracted, return an empty array: [].`,
4368
4640
  // the Attendant would consult if council mode were live.
4369
4641
  // Pure, deterministic, never executed here.
4370
4642
  councilConsultationPlan,
4643
+ // B8 M6: sub-turn reasoning loop plan — when a mid-turn attend
4644
+ // carries a partial assistant response, the Attendant re-scores
4645
+ // that partial against baseline retrieval and fires at most one
4646
+ // extra observe() call with broadened hints harvested from the
4647
+ // partial text. attempted=false / decline outcomes are echoed
4648
+ // on non-mid-turn phases so hosts can safely destructure.
4649
+ subTurnLoopPlan,
4371
4650
  };
4372
4651
  if (input.suppressEvents !== true) {
4373
4652
  (0, staffEventRegistry_1.getStaffEventEmitter)().emit({