@hypercarrier/rarebit 0.2.0 → 0.3.0

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.
@@ -1,4 +1,5 @@
1
- import { exactSelectionApplies } from "./rarebit-artifact-state.mjs";
1
+ import { RECAP_ENTRY_TYPE, RECAP_READ_TYPE, latestRecap, recapIsRead } from "./rarebit-read-checkpoint.mjs";
2
+ import { exactSelectionApplies, projectRarebitArtifactState } from "./rarebit-artifact-state.mjs";
2
3
  import { selectRarebits } from "./rarebit-core.mjs";
3
4
  import {
4
5
  DEFAULT_RAREBIT_RECAP_DELAY_MS,
@@ -28,65 +29,48 @@ function branchSnapshot(ctx) {
28
29
  return Array.isArray(branch) ? branch.slice() : [];
29
30
  }
30
31
 
31
- function receiptAppliesToContext(receipt, ctx) {
32
- if (receipt?.status !== "ok") return false;
32
+ function receiptCoverage(receipt, ctx) {
33
+ if (receipt?.status !== "ok") return { state: null, coverage: null };
33
34
  const identity = sessionIdentity(ctx);
34
- if (!identity.sessionId || !identity.sessionFile) return false;
35
- if (receipt.sessionId !== identity.sessionId) return false;
36
- const branch = branchSnapshot(ctx);
37
- return exactSelectionApplies(receipt.selection, selectRarebits(branch));
35
+ if (!identity.sessionId || !identity.sessionFile || receipt.sessionId !== identity.sessionId) return { state: null, coverage: null };
36
+ const selection = selectRarebits(branchSnapshot(ctx));
37
+ const current = projectRarebitArtifactState({
38
+ native: { availability: "available", sessionId: identity.sessionId,
39
+ selection: { ...selection, selectorVersion: selection.manifest.selectorVersion } },
40
+ materialization: { availability: "available", records: [receipt] },
41
+ });
42
+ if (current.receiptRef?.jobId !== receipt.jobId ||
43
+ !["request_current", "assessment_current"].includes(current.syncState)) return { state: current, coverage: null };
44
+ if (current.applicability === "exact_selection" && exactSelectionApplies(receipt.selection, selection))
45
+ return { state: current, coverage: { coveredEntryId: selection.occurrences.at(-1)?.sourceEntryId, selectionHash: receipt.selection.manifestHash } };
46
+ if (current.applicability === "request_generation") {
47
+ const covered = selection.occurrences[receipt.selection.occurrenceCount - 1];
48
+ return { state: current, coverage: covered ? { coveredEntryId: covered.sourceEntryId, selectionHash: receipt.selection.manifestHash } : null };
49
+ }
50
+ return { state: current, coverage: null };
38
51
  }
39
52
 
40
- function observedAtLabel(observedAt, timezone) {
53
+ export function observedAtLabel(observedAt, timezone = DEFAULT_RAREBIT_RECAP_TIMEZONE) {
54
+ if (observedAt === null || observedAt === undefined) return "unknown";
41
55
  const date = new Date(observedAt);
42
- if (Number.isNaN(date.valueOf())) return String(observedAt ?? "unknown");
56
+ if (Number.isNaN(date.valueOf())) return "unknown";
43
57
  const usesHostTimezone = timezone === DEFAULT_RAREBIT_RECAP_TIMEZONE;
44
- const dateOptions = {
45
- dateStyle: "medium",
46
- timeStyle: "short",
47
- ...(usesHostTimezone ? {} : { timeZone: timezone }),
48
- };
49
- let dateText;
50
- try {
51
- dateText = new Intl.DateTimeFormat(undefined, dateOptions).format(date);
52
- } catch {
53
- dateText = new Intl.DateTimeFormat(undefined, {
54
- dateStyle: "medium",
55
- timeStyle: "short",
56
- }).format(date);
57
- }
58
-
59
- let zoneText;
58
+ const options = usesHostTimezone ? {} : { timeZone: timezone };
59
+ const parts = new Intl.DateTimeFormat("en-US", {
60
+ month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
61
+ hourCycle: "h23", ...options,
62
+ }).formatToParts(date);
63
+ const value = (type) => parts.find((part) => part.type === type)?.value ?? "00";
64
+ let offset = "GMT";
60
65
  try {
61
- const zoneOptions = {
62
- year: "numeric",
63
- month: "numeric",
64
- day: "numeric",
65
- timeZoneName: "shortOffset",
66
- ...(usesHostTimezone ? {} : { timeZone: timezone }),
67
- };
68
- zoneText = new Intl.DateTimeFormat(undefined, zoneOptions)
69
- .formatToParts(date)
70
- .find((part) => part.type === "timeZoneName")?.value;
71
- } catch {
72
- zoneText = undefined;
73
- }
74
- let resolvedTimezone;
75
- try {
76
- resolvedTimezone = new Intl.DateTimeFormat(
77
- undefined,
78
- usesHostTimezone ? {} : { timeZone: timezone },
79
- ).resolvedOptions().timeZone;
80
- } catch {
81
- resolvedTimezone = undefined;
82
- }
83
- const zoneLabel = usesHostTimezone
84
- ? `host${resolvedTimezone ? `: ${resolvedTimezone}` : ""}`
85
- : resolvedTimezone ?? timezone;
86
- return `${dateText} (${zoneLabel}${zoneText ? `, ${zoneText}` : ""})`;
66
+ offset = new Intl.DateTimeFormat("en", { timeZoneName: "shortOffset", ...options })
67
+ .formatToParts(date).find((part) => part.type === "timeZoneName")?.value ?? "GMT";
68
+ } catch {}
69
+ return `${value("month")}/${value("day")} ${value("hour")}:${value("minute")}(${offset})`;
87
70
  }
88
71
 
89
72
  let recapWidgetComponents;
73
+ let loadedComponents;
90
74
 
91
75
  async function loadRecapWidgetComponents() {
92
76
  if (!recapWidgetComponents) {
@@ -97,7 +81,9 @@ async function loadRecapWidgetComponents() {
97
81
  Container: tui.Container,
98
82
  Text: tui.Text,
99
83
  DynamicBorder: agent.DynamicBorder,
100
- }));
84
+ truncateToWidth: tui.truncateToWidth,
85
+ visibleWidth: tui.visibleWidth,
86
+ })).then((components) => (loadedComponents = components));
101
87
  }
102
88
  return recapWidgetComponents;
103
89
  }
@@ -107,12 +93,70 @@ function summaryContent(
107
93
  { timezone = DEFAULT_RAREBIT_RECAP_TIMEZONE } = {},
108
94
  ) {
109
95
  const presentation = rarebitSummaryPresentation(receipt.sessionStatus);
110
- const mark = presentation.mark ? `${presentation.mark} ` : "";
111
- const heading = `${mark}Recap · ${presentation.label} · as of ${observedAtLabel(receipt.observedAt, timezone)}`;
96
+ const mark = presentation.mark ?? "";
97
+ const heading = `${mark}Recap·${presentation.label.replaceAll(" ", "")}·${observedAtLabel(receipt.observedAt, timezone)}`;
112
98
  const summary = String(receipt.summary ?? "").trim();
113
99
  return { heading, summary };
114
100
  }
115
101
 
102
+ function recapComponent(content, theme) {
103
+ const border = (text) => theme.fg("borderMuted", text);
104
+ const widget = new loadedComponents.Container();
105
+ const summary = new loadedComponents.Text("", 1, 0);
106
+ const compactSummary = new loadedComponents.Text("", 0, 0);
107
+ let previousSummary;
108
+ let previousCompactSummary;
109
+ const themed = (text, color, component, previous) => {
110
+ const rendered = theme.fg(color, text);
111
+ if (rendered !== previous.value) {
112
+ previous.value = rendered;
113
+ component.setText(rendered);
114
+ }
115
+ };
116
+ const themedHeading = {
117
+ render(width) {
118
+ const action = content.action
119
+ ? loadedComponents.truncateToWidth(theme.fg("dim", content.action), Math.max(0, width), "")
120
+ : "";
121
+ const actionWidth = loadedComponents.visibleWidth(action);
122
+ const heading = loadedComponents.truncateToWidth(
123
+ theme.fg("dim", content.heading), Math.max(0, width - actionWidth), "",
124
+ );
125
+ return [`${heading}${action}`];
126
+ },
127
+ invalidate() {},
128
+ };
129
+ const themedSummary = {
130
+ render(width) {
131
+ if (width < 3) {
132
+ themed(content.summary, "muted", compactSummary, previousCompactSummary ??= {});
133
+ return compactSummary.render(width);
134
+ }
135
+ themed(content.summary, "muted", summary, previousSummary ??= {});
136
+ return summary.render(width);
137
+ },
138
+ invalidate() {
139
+ previousSummary = undefined;
140
+ previousCompactSummary = undefined;
141
+ summary.invalidate();
142
+ compactSummary.invalidate();
143
+ },
144
+ };
145
+ widget.addChild(new loadedComponents.DynamicBorder(border));
146
+ widget.addChild(themedHeading);
147
+ widget.addChild(themedSummary);
148
+ widget.addChild(new loadedComponents.DynamicBorder(border));
149
+ return {
150
+ render(width) {
151
+ const lines = widget.render(width);
152
+ return width < 3
153
+ ? lines.map((line) => loadedComponents.truncateToWidth(line, Math.max(0, width), ""))
154
+ : lines;
155
+ },
156
+ invalidate() { widget.invalidate(); },
157
+ };
158
+ }
159
+
116
160
  function currentContextOptions(ctx, options) {
117
161
  return {
118
162
  sessionFile: sessionIdentity(ctx).sessionFile,
@@ -133,13 +177,14 @@ function sameIdentity(left, right) {
133
177
 
134
178
  /**
135
179
  * Private Pi-only presentation controller. It consumes existing receipts and
136
- * writes only to the interactive TUI widget surface. It never appends to a
137
- * Session and never invokes a model.
180
+ * uses native custom entries for scrollable, human-only Recaps and read state.
181
+ * Older Pi versions retain the widget fallback. It never invokes a model.
138
182
  */
139
183
  export function createRarebitRecapController({
140
184
  delayMs = DEFAULT_RAREBIT_RECAP_DELAY_MS,
141
185
  timezone = DEFAULT_RAREBIT_RECAP_TIMEZONE,
142
186
  readCurrent = readRarebitCurrent,
187
+ appendEntry,
143
188
  setTimeoutFn = setTimeout,
144
189
  clearTimeoutFn = clearTimeout,
145
190
  } = {}) {
@@ -223,88 +268,65 @@ export function createRarebitRecapController({
223
268
  ...currentContextOptions(ctx, options),
224
269
  });
225
270
  const receipt = current?.receipt;
226
- if (!receiptAppliesToContext(receipt, ctx)) return null;
227
271
  const artifactState = current?.artifactState;
228
- if (
229
- !["request_current", "assessment_current"].includes(
230
- artifactState?.syncState,
231
- ) ||
232
- !["request_generation", "exact_selection"].includes(
233
- artifactState?.applicability,
234
- ) ||
235
- artifactState?.receiptRef?.jobId !== receipt.jobId ||
236
- artifactState?.projection?.status !== receipt.sessionStatus
237
- )
238
- return null;
239
- return receipt;
272
+ if (!receipt || !["request_current", "assessment_current"].includes(artifactState?.syncState) ||
273
+ !["request_generation", "exact_selection"].includes(artifactState?.applicability) ||
274
+ artifactState?.receiptRef?.jobId !== receipt.jobId ||
275
+ artifactState?.projection?.status !== receipt.sessionStatus)
276
+ return { receipt: null, reason: artifactState?.projection?.reason ?? artifactState?.retry?.reason ?? artifactState?.syncState ?? "no_summary" };
277
+ const projection = receiptCoverage(receipt, ctx);
278
+ return projection.coverage ? { receipt, coverage: projection.coverage } : {
279
+ receipt: null, reason: projection.state?.retry?.reason ?? projection.state?.projection?.reason ?? projection.state?.syncState ?? "stale_or_divergent",
280
+ };
240
281
  };
241
282
 
242
283
  const display = async (
243
284
  ctx,
244
285
  receipt,
245
- { timezone = recapTimezone, token, expectedOfferEpoch } = {},
286
+ { timezone = recapTimezone, token, expectedOfferEpoch, storeOptions } = {},
246
287
  ) => {
247
288
  const target = tuiContext(ctx);
248
- if (!target || !receiptAppliesToContext(receipt, ctx)) return false;
289
+ let coverage = receiptCoverage(receipt, ctx).coverage;
290
+ if (!target || !coverage) return false;
249
291
  const observedControllerGeneration = controllerGeneration;
250
292
  const observedLifecycleGeneration = liveLifecycleGeneration;
251
293
  const observedOfferEpoch = expectedOfferEpoch ?? offerEpoch;
252
294
  const observedIdentity = sessionIdentity(ctx);
253
295
  try {
254
- const components = await loadRecapWidgetComponents();
296
+ await loadRecapWidgetComponents();
297
+ const current = await readReceipt(liveContext, storeOptions).catch(() => null);
255
298
  if (
256
299
  observedControllerGeneration !== controllerGeneration ||
257
300
  observedLifecycleGeneration !== liveLifecycleGeneration ||
258
301
  observedOfferEpoch !== offerEpoch ||
259
302
  (token && !tokenIsCurrent(token)) ||
260
303
  !sameIdentity(observedIdentity, sessionIdentity(liveContext)) ||
261
- !receiptAppliesToContext(receipt, liveContext)
304
+ !current?.receipt || current.receipt.jobId !== receipt.jobId ||
305
+ current.coverage.coveredEntryId !== coverage.coveredEntryId ||
306
+ current.coverage.selectionHash !== coverage.selectionHash
262
307
  )
263
308
  return false;
309
+ receipt = current.receipt;
310
+ coverage = current.coverage;
264
311
  const content = summaryContent(receipt, {
265
312
  timezone: normalizeRarebitRecapTimezone(timezone),
266
313
  });
314
+ if (typeof appendEntry === "function") {
315
+ const existing = branchSnapshot(ctx).findLast((entry) =>
316
+ entry.type === "custom" && entry.customType === RECAP_ENTRY_TYPE &&
317
+ entry.data?.version === 1 && entry.data.jobId === receipt.jobId &&
318
+ entry.data.coveredEntryId === coverage.coveredEntryId &&
319
+ entry.data.selectionHash === coverage.selectionHash);
320
+ if (!existing) appendEntry(RECAP_ENTRY_TYPE, {
321
+ version: 1, ...content, jobId: receipt.jobId,
322
+ coveredEntryId: coverage.coveredEntryId, selectionHash: coverage.selectionHash,
323
+ });
324
+ return true;
325
+ }
267
326
  target.ui.setWidget(
268
327
  RAREBIT_RECAP_WIDGET_KEY,
269
328
  (_tui, theme) => {
270
- const border = (text) => theme.fg("borderMuted", text);
271
- const widget = new components.Container();
272
- const heading = new components.Text("", 1, 0);
273
- const summary = new components.Text("", 1, 0);
274
- let previousHeading;
275
- let previousSummary;
276
- const themed = (text, color, component, previous) => {
277
- const rendered = theme.fg(color, text);
278
- if (rendered !== previous.value) {
279
- previous.value = rendered;
280
- component.setText(rendered);
281
- }
282
- };
283
- const themedHeading = {
284
- render: (width) => {
285
- themed(content.heading, "dim", heading, previousHeading ??= {});
286
- return heading.render(width);
287
- },
288
- invalidate: () => {
289
- previousHeading = undefined;
290
- heading.invalidate();
291
- },
292
- };
293
- const themedSummary = {
294
- render: (width) => {
295
- themed(content.summary, "muted", summary, previousSummary ??= {});
296
- return summary.render(width);
297
- },
298
- invalidate: () => {
299
- previousSummary = undefined;
300
- summary.invalidate();
301
- },
302
- };
303
- widget.addChild(new components.DynamicBorder(border));
304
- widget.addChild(themedHeading);
305
- widget.addChild(themedSummary);
306
- widget.addChild(new components.DynamicBorder(border));
307
- return widget;
329
+ return recapComponent(content, theme);
308
330
  },
309
331
  { placement: "aboveEditor" },
310
332
  );
@@ -321,25 +343,23 @@ export function createRarebitRecapController({
321
343
  const observedGeneration = controllerGeneration;
322
344
  const observedOfferEpoch = offerEpoch;
323
345
  const identity = sessionIdentity(ctx);
324
- const receipt = await readReceipt(ctx, options).catch(() => null);
346
+ const current = await readReceipt(ctx, options).catch(() => null);
325
347
  if (
326
348
  observedGeneration !== controllerGeneration ||
327
349
  observedOfferEpoch !== offerEpoch ||
328
350
  !sameIdentity(identity, sessionIdentity(liveContext))
329
351
  )
330
352
  return { shown: false, reason: "stale" };
331
- if (!receipt) {
353
+ if (!current?.receipt) {
332
354
  clearWidget(ctx);
333
- return { shown: false, reason: "no_current_summary" };
355
+ return { shown: false, reason: current?.reason ?? "no_current_summary" };
334
356
  }
335
- return {
336
- shown: await display(ctx, receipt, {
337
- timezone: options.timezone ?? options.recapTimezone ?? recapTimezone,
338
- expectedOfferEpoch: observedOfferEpoch,
339
- }),
340
- reason: "current_summary",
341
- receipt,
342
- };
357
+ const shown = await display(ctx, current.receipt, {
358
+ storeOptions: options,
359
+ timezone: options.timezone ?? options.recapTimezone ?? recapTimezone,
360
+ expectedOfferEpoch: observedOfferEpoch,
361
+ });
362
+ return { shown, reason: shown ? "current_summary" : "stale_or_unavailable", receipt: current.receipt };
343
363
  };
344
364
 
345
365
  const offer = ({ ctx, result, token, ...options }) => {
@@ -379,7 +399,7 @@ export function createRarebitRecapController({
379
399
  const current = liveContext;
380
400
  const currentIdentity = sessionIdentity(current);
381
401
  if (!sameIdentity(offerState.identity, currentIdentity)) return;
382
- const receipt = await readReceipt(current, offerState.options).catch(
402
+ const currentReceipt = await readReceipt(current, offerState.options).catch(
383
403
  () => null,
384
404
  );
385
405
  if (
@@ -387,8 +407,9 @@ export function createRarebitRecapController({
387
407
  !tokenIsCurrent(offerState.token)
388
408
  )
389
409
  return;
390
- if (!receipt || receipt.jobId !== offerState.record.jobId) return;
391
- if (await display(current, receipt, {
410
+ if (!currentReceipt?.receipt || currentReceipt.receipt.jobId !== offerState.record.jobId) return;
411
+ if (await display(current, currentReceipt.receipt, {
412
+ storeOptions: offerState.options,
392
413
  token: offerState.token,
393
414
  expectedOfferEpoch: offerState.offerEpoch,
394
415
  timezone:
@@ -406,12 +427,49 @@ export function createRarebitRecapController({
406
427
  return true;
407
428
  };
408
429
 
430
+ const renderEntry = (entry, _options, theme) => {
431
+ if (entry.data?.version !== 1 || typeof entry.data.summary !== "string" ||
432
+ typeof entry.data.heading !== "string") return undefined;
433
+ let component;
434
+ // Resume can render entries before the optional Pi imports settle. Keep a
435
+ // placeholder while the optional components load, then request a redraw.
436
+ if (!loadedComponents)
437
+ void loadRecapWidgetComponents().then(() => clearWidget()).catch(() => {});
438
+ return {
439
+ render(width) {
440
+ if (!loadedComponents) return [theme.fg("dim", "Recap loading…".slice(0, width))];
441
+ const read = recapIsRead(branchSnapshot(liveContext), entry);
442
+ component = recapComponent({ ...entry.data,
443
+ action: read ? " ✓ got it" : " [got it]" }, theme);
444
+ return component.render(width);
445
+ },
446
+ invalidate() { component?.invalidate(); },
447
+ };
448
+ };
449
+
450
+ const acknowledge = (ctx) => {
451
+ if (typeof appendEntry !== "function" || !sameIdentity(sessionIdentity(ctx), sessionIdentity(liveContext)))
452
+ return { acknowledged: false, reason: "unavailable" };
453
+ const branch = branchSnapshot(ctx);
454
+ const recap = latestRecap(branch);
455
+ if (!recap) return { acknowledged: false, reason: "no_recap" };
456
+ if (recapIsRead(branch, recap))
457
+ return { acknowledged: false, reason: "already_read" };
458
+ appendEntry(RECAP_READ_TYPE, { version: 1, recapEntryId: recap.id,
459
+ jobId: recap.data.jobId, coveredEntryId: recap.data.coveredEntryId,
460
+ selectionHash: recap.data.selectionHash });
461
+ clearWidget(ctx);
462
+ return { acknowledged: true };
463
+ };
464
+
409
465
  const dispose = () => {
410
466
  invalidate(undefined, undefined);
411
467
  liveContext = undefined;
412
468
  };
413
469
 
414
470
  return {
471
+ renderEntry,
472
+ acknowledge,
415
473
  captureMaterialization,
416
474
  offer,
417
475
  showExisting,
@@ -1,3 +1,4 @@
1
+ import { recapReadCheckpoint } from "./rarebit-read-checkpoint.mjs";
1
2
  import {
2
3
  DEFAULT_RAREBIT_SUMMARY_POLICY,
3
4
  DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE,
@@ -35,7 +36,7 @@ import {
35
36
  } from "./automatic-summary-policy.mjs";
36
37
 
37
38
  export const RAREBIT_TITLE_IMPLEMENTATION_VERSION = "hc-rarebit-title-v4";
38
- export const RAREBIT_SUMMARY_IMPLEMENTATION_VERSION = "hc-rarebit-summary-v6";
39
+ export const RAREBIT_SUMMARY_IMPLEMENTATION_VERSION = "hc-rarebit-summary-v7";
39
40
  export const RAREBIT_SUMMARY_SCHEMA_VERSION = 4;
40
41
  export const RAREBIT_INPUT_CHARS_PER_TOKEN = 4;
41
42
  export const DEFAULT_RAREBIT_MAX_PROMPT_CHARS =
@@ -151,6 +152,7 @@ export async function processRarebitSummary(ctx, config = {}) {
151
152
  const sessionFile = config.sessionFile ?? sessionFileFrom(ctx);
152
153
  const sessionId = config.sessionId ?? sessionIdFrom(ctx);
153
154
  const selection = selectRarebits(branch);
155
+ const readCheckpoint = recapReadCheckpoint(branch, selection);
154
156
  const measurement = measureRarebits(branch, selection);
155
157
  const policy = normalizeRarebitSummaryPolicy(
156
158
  config.summaryPolicy ?? DEFAULT_RAREBIT_SUMMARY_POLICY,
@@ -227,7 +229,7 @@ export async function processRarebitSummary(ctx, config = {}) {
227
229
  branch: branchRef,
228
230
  selection,
229
231
  policy,
230
- inputPolicy: inputCoveragePolicy,
232
+ inputPolicy: { ...inputCoveragePolicy, ...(readCheckpoint ? { readCheckpoint } : {}) },
231
233
  lifecycleBoundary,
232
234
  promptVersion,
233
235
  promptIdentity: summaryPrompt.promptIdentity,
@@ -322,6 +324,7 @@ export async function processRarebitSummary(ctx, config = {}) {
322
324
  const derivationInput = composeRarebitSummaryDerivationInput(selection, {
323
325
  promptVersion,
324
326
  summaryPrompt: config.summaryPrompt,
327
+ readCheckpoint,
325
328
  lifecycleBoundary,
326
329
  maxPromptChars,
327
330
  });
@@ -200,6 +200,7 @@ export const RAREBIT_SETTINGS_FIELDS = Object.freeze([
200
200
  ]);
201
201
 
202
202
  const paletteItems = [
203
+ { id: "view", tab: "Actions", label: "View", description: "Read Rarebits in the TUI." },
203
204
  { id: "settings", tab: "Actions", label: "Settings", description: "Open the interactive Rarebit settings editor." },
204
205
  { id: "status", tab: "Actions", label: "Status", description: "Show effective Rarebit configuration." },
205
206
  { id: "help", tab: "Actions", label: "Command help", description: "Show direct /rarebit commands." },
@@ -466,6 +467,7 @@ export async function openRarebitSettings(ctx, {
466
467
 
467
468
  export function rarebitPaletteCommand(selection) {
468
469
  switch (selection?.id) {
470
+ case "view": return "view";
469
471
  case "settings": case "summary_settings": case "recap_settings": case "session_settings": return "settings";
470
472
  case "summary_status": return "status";
471
473
  case "recap": return "recap";